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
88 changed files with 150027 additions and 1429 deletions
+10 -3
View File
@@ -133,7 +133,7 @@ indicators.
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.
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.
**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 |
| 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) |
| 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 |
| 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 |
> **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:
- **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)
- 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
- 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)
- 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
@@ -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()))
+2
View File
@@ -17,6 +17,7 @@ from app.models.regime_snapshot import RegimeSnapshot
from app.models.benchmark_price import BenchmarkPrice
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.system_event import SystemEvent
from app.models.sec_filing_gap import SecFilingGap
__all__ = [
"Ticker",
@@ -40,4 +41,5 @@ __all__ = [
"BenchmarkPrice",
"SignalContextSnapshot",
"SystemEvent",
"SecFilingGap",
]
+4 -2
View File
@@ -10,7 +10,8 @@ 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), or ``failed``.
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
@@ -28,7 +29,7 @@ class DataImportRun(Base):
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 | failed
# 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)
@@ -39,4 +40,5 @@ class DataImportRun(Base):
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)
+10 -2
View File
@@ -12,8 +12,10 @@ class FundamentalSnapshot(Base):
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 pick the newest valid ``accepted_at`` per
(cik, fiscal_year, fiscal_period) at read time — no flags, no mutation.
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,
@@ -70,6 +72,12 @@ class FundamentalSnapshot(Base):
# 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
+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)
+22
View File
@@ -13,6 +13,7 @@ from app.schemas.admin import (
AlertConfigUpdate,
CreateUserRequest,
DataCleanupRequest,
FundamentalsCutoverConfigUpdate,
JobTriggerRequest,
JobToggle,
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)
async def get_recommendation_settings(
_admin: User = Depends(require_admin),
+10 -1
View File
@@ -10,6 +10,7 @@ from app.schemas.common import APIEnvelope
from app.schemas.fundamental import FundamentalResponse
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"])
@@ -34,6 +35,7 @@ async def read_fundamentals(
"""Get latest fundamental data for a symbol (legacy fields + additive v1)."""
record = await get_fundamental(db, symbol)
v1 = await build_fundamentals_v1(db, symbol)
quality = await fundamentals_quality_service.ticker_quality(db, symbol)
legacy: dict = {}
if record is not None:
@@ -47,5 +49,12 @@ async def read_fundamentals(
unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json),
)
data = FundamentalResponse(symbol=symbol.strip().upper(), **legacy, **v1)
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())
+115 -14
View File
@@ -41,8 +41,14 @@ from app.services import (
settings_store,
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.data_import import 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
@@ -506,13 +512,14 @@ async def collect_ohlcv(
job_name: str = "data_collector",
*,
refetch_days: int = 0,
refresh_sr: bool = True,
) -> None:
"""Fetch latest daily OHLCV for all tracked tickers.
Uses AlpacaOHLCVProvider. Processes each ticker independently.
On rate limit, records last successful ticker for resume.
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
``full_backfill`` forces every ticker to re-fetch the full
@@ -574,6 +581,7 @@ async def collect_ohlcv(
try:
result = await ingestion_service.fetch_and_ingest(
db, provider, symbol, start_date=backfill_start, end_date=end_date,
refresh_sr=refresh_sr,
)
_last_successful[job_name] = symbol
processed += 1
@@ -613,6 +621,11 @@ async def collect_ohlcv(
_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:
"""Deep historical backfill: re-fetch the full ``settings.ohlcv_history_days``
window for every ticker, ignoring incremental resume.
@@ -652,7 +665,7 @@ async def run_shadow_book() -> None:
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
return False
if not await shadow_book_service.is_enabled(db):
_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")
@@ -825,6 +838,22 @@ async def collect_fundamentals() -> None:
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
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)
if not symbols:
@@ -924,8 +953,13 @@ async def collect_fundamentals() -> None:
# ---------------------------------------------------------------------------
async def _run_shadow_import(job_name: str, importer: SourceImporter) -> None:
"""Run one source importer and surface its audit result in Admin → Jobs."""
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)
@@ -941,15 +975,20 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> 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
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
return True
_log_event(
logging.INFO,
@@ -959,6 +998,7 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> None:
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
@@ -971,6 +1011,7 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> None:
message=str(exc),
)
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
return True
async def run_dolt_earnings_import() -> None:
@@ -979,8 +1020,67 @@ async def run_dolt_earnings_import() -> None:
async def run_sec_fundamentals_import() -> None:
"""Import tracked-universe SEC facts in shadow."""
await _run_shadow_import("sec_fundamentals_import", SecFundamentalsImporter())
"""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:
@@ -1403,8 +1503,8 @@ _DAILY_PIPELINE_STEPS = [
("alerts", "dispatch_alerts_job"),
]
# Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (already how
# the intraday pipeline keeps the dashboard live), then the only daily
# Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (incremental
# ingestion overlaps the latest stored session), then the only daily
# 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
# may see ~15:15 prices — immaterial for a 12-1 momentum signal.
@@ -1415,7 +1515,7 @@ _DAILY_PIPELINE_STEPS = [
_NEAR_CLOSE_PIPELINE_STEPS = [
# 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.
("data_collector", "collect_ohlcv"),
("data_collector", "collect_ohlcv_for_scan"),
("rr_scanner", "scan_rr"),
# Straight after the scan so shadow entries mark at the same near-close
# prices the discretionary book is looking at.
@@ -1566,7 +1666,8 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_timezone": "America/New_York",
# Morning data/display refresh (no qualifying R:R scan).
"schedule_daily_pipeline_cron": "0 2 * * *",
# Shadow source imports. They never write legacy fundamental_data before A5.
# 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 * * *",
@@ -1689,7 +1790,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
"schedule_sec_fundamentals_cron",
),
id="sec_fundamentals_import",
name="SEC Fundamentals Import (shadow)",
name="SEC Fundamentals Import",
replace_existing=True,
)
scheduler.add_job(
+5
View File
@@ -73,6 +73,11 @@ class ActivationConfigUpdate(BaseModel):
exclude_neutral: bool | None = None
class FundamentalsCutoverConfigUpdate(BaseModel):
"""Switch the legacy fundamentals cache from quota APIs to SEC/Dolt."""
enabled: bool
class ScheduleConfigUpdate(BaseModel):
"""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)."""
+3
View File
@@ -91,3 +91,6 @@ class FundamentalResponse(BaseModel):
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.
trailing_stop: 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
+24 -2
View File
@@ -17,7 +17,7 @@ from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
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__)
@@ -159,6 +159,28 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
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
# ---------------------------------------------------------------------------
@@ -637,7 +659,7 @@ JOB_LABELS = {
"sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector",
"dolt_earnings_import": "Dolt Earnings Import (shadow)",
"sec_fundamentals_import": "SEC Fundamentals Import (shadow)",
"sec_fundamentals_import": "SEC Fundamentals Import",
"fundamentals_parity_report": "Fundamentals Parity Report (read-only)",
"rr_scanner": "R:R Scanner",
"ticker_universe_sync": "Ticker Universe Sync",
+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
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # v2 Warning divider
QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
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_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = {
+436 -36
View File
@@ -1320,6 +1320,7 @@ def _replay_candidates_for_period(
cadence: str = DEFAULT_BACKTEST_CADENCE,
include_short_candidates: bool = False,
include_universe_rank_observations: bool = False,
outcome_horizon_sessions: int = HORIZON,
) -> list[dict]:
"""Slim picklable replay used by local event studies.
@@ -1343,10 +1344,13 @@ def _replay_candidates_for_period(
)
]
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] = []
for i in range(
MIN_LOOKBACK - 1,
len(bars) - HORIZON,
len(bars) - replay_horizon,
backtest_step_sessions(cadence),
):
if bars[i].date < start_date:
@@ -1942,6 +1946,7 @@ def _make_gate_reset_reentry_fn(
cadence: str,
qualified_fn: Callable[[dict], bool] | None = None,
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
evaluation_horizon_sessions: int = HORIZON,
) -> Callable[[str, int, dict, Any], dict | None]:
"""Build the production post-stop gate-reset callback.
@@ -1959,11 +1964,18 @@ def _make_gate_reset_reentry_fn(
evaluation_ords: dict[str, set[int]] = {}
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():
ordinals = columns[0]
evaluation_ords[symbol] = {
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] = {}
@@ -2010,7 +2022,7 @@ def _simulate_portfolio(
*,
qualified_fn: Callable[[dict], bool] | None = None,
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,
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
cost_per_side: float = COST_PER_SIDE,
@@ -2034,6 +2046,12 @@ def _simulate_portfolio(
corr_lookback: int = 120,
corr_action: str = "skip",
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:
"""Replay the qualified setups as ONE capital-constrained book and report
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'")
if vol_target is not None and vol_target <= 0:
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])
if clamp_lo <= 0 or clamp_hi < clamp_lo:
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)
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.
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:
if not qualified_fn(c) or c.get("direction") != "long":
continue
@@ -2104,6 +2154,8 @@ def _simulate_portfolio(
continue
if end_ord is not None and entry_ord >= end_ord:
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"):
continue
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)
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:
return None
@@ -2124,20 +2181,39 @@ def _simulate_portfolio(
# fill lag). Prevents trailing flat-cash after the last resolvable entry —
# the clear-air train-window bug — for train, validation, and full-period
# books alike (including max-hold sweeps out to 90 days).
last_signal_ord = max(entries_by_ord)
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
calendar = calendar[:cut]
if hard_end_ord is None:
last_signal_ord = max(entries_by_ord)
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
calendar = calendar[:cut]
if not calendar:
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
positions: dict[str, dict] = {}
curve: list[tuple[int, float]] = []
trades: list[dict] = []
skipped_full = 0
measurement_skipped_full = 0
skipped_cooldown = 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_gap_cap = 0
cooldown_until_index: dict[str, int] = {}
@@ -2152,6 +2228,12 @@ def _simulate_portfolio(
vol_scalars: list[float] = []
overnight_slippage_pct: list[float] = []
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):
idx = index_of.get(sym, {}).get(o)
@@ -2221,6 +2303,13 @@ def _simulate_portfolio(
cost = proceeds * cost_rate
cash += proceeds - cost
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({
"symbol": sym,
"entry_ord": pos["entry_ord"],
@@ -2229,8 +2318,13 @@ def _simulate_portfolio(
"initial_stop": pos["initial_stop"],
"active_stop": pos["stop"],
"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,
"net_r": net_pnl / initial_risk_dollars
if initial_risk_dollars > 0
else 0.0,
"hold": pos["bars_held"],
"reason": reason,
"stop_refreshes": pos["stop_refreshes"],
@@ -2245,6 +2339,13 @@ def _simulate_portfolio(
cooldown_sessions = max(0, int(reentry_cooldown_sessions))
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)
for sym in list(positions):
pos = positions[sym]
@@ -2358,6 +2459,82 @@ def _simulate_portfolio(
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:
fill_candidates = sorted(
pending_delayed,
@@ -2366,7 +2543,11 @@ def _simulate_portfolio(
)
pending_delayed = []
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:
"""1.0 ok, 0.5 half-size, None = skip. Missing history → uncorrelated."""
@@ -2411,15 +2592,21 @@ def _simulate_portfolio(
corr_scale: float,
fill_bar: Any | 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"]
if sym in positions:
return
if calendar_index < cooldown_until_index.get(sym, -1):
skipped_cooldown += 1
return
if len(positions) >= max_positions:
if max_positions is not None and len(positions) >= max_positions:
skipped_full += 1
if in_measurement:
measurement_skipped_full += 1
return
risk_ps = entry - stop
if risk_ps <= 0 or entry <= 0:
@@ -2436,6 +2623,16 @@ def _simulate_portfolio(
(equity * SIM_NOTIONAL_CAP) / entry,
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:
return
entry_cost = shares * entry * cost_rate
@@ -2475,6 +2672,21 @@ def _simulate_portfolio(
"vol_scalar": scalar,
"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.
# 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).
@@ -2576,7 +2788,25 @@ def _simulate_portfolio(
# Queue today's signals for the next session's fill.
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.
for sym in list(positions):
@@ -2584,32 +2814,57 @@ def _simulate_portfolio(
final_equity = cash
curve[-1] = (calendar[-1], final_equity)
total_return_pct = (final_equity / SIM_STARTING_CAPITAL - 1.0) * 100.0
years = (calendar[-1] - calendar[0]) / 365.25
metric_start_ord = (
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 = (
((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
else None
)
peak = float("-inf")
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)
if peak > 0:
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)
sharpe = diag["sharpe"]
# Per-calendar-year returns off the equity curve — shows whether every year
# contributed or one exceptional stretch carried the result.
yearly: list[dict] = []
year_start_eq = curve[0][1]
cur_year = date.fromordinal(curve[0][0]).year
last_eq = curve[0][1]
for o, eq in curve:
year_start_eq = metric_base_equity
cur_year = date.fromordinal(metric_start_ord).year
last_eq = metric_base_equity
for o, eq in metric_curve:
y = date.fromordinal(o).year
if y != cur_year:
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)
reason_counts = {
reason: sum(1 for t in trades if t["reason"] == reason)
for reason in sorted({t["reason"] for t in trades})
reason: sum(1 for t in metric_trades if t["reason"] == reason)
for reason in sorted({t["reason"] for t in metric_trades})
}
spy_pct = None
if spy_closes:
from app.services.benchmark_service import 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
benchmark_payload: list[dict] | None = None
if include_curve:
curve_base = curve[0][1] if curve else SIM_STARTING_CAPITAL
curve_base = metric_base_equity
curve_payload = [
{
"date": date.fromordinal(o).isoformat(),
@@ -2654,12 +2914,12 @@ def _simulate_portfolio(
if curve_base > 0
else None,
}
for o, eq in curve
for o, eq in metric_curve
]
if spy_closes:
benchmark_payload = []
base_spy = None
for o, _ in curve:
for o, _ in metric_curve:
d = date.fromordinal(o)
close = spy_closes.get(d)
if close is None or close <= 0:
@@ -2678,6 +2938,8 @@ def _simulate_portfolio(
calmar = float(cagr_pct) / max_dd_pct
result = {
"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),
"fill_mode": fill_mode,
"final_equity": round(final_equity, 2),
@@ -2691,23 +2953,161 @@ def _simulate_portfolio(
"n_returns": diag["n_returns"],
"return_skew": diag["return_skew"],
"return_kurtosis": diag["return_kurtosis"],
"trades": len(trades),
"win_rate": round(wins / len(trades) * 100.0, 1) if trades else None,
"trades": len(metric_trades),
"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,
"best_trade_r": round(max(t["r"] for t in trades), 2) if trades else None,
"worst_trade_r": round(min(t["r"] for t in trades), 2) if trades else None,
"best_trade_r": (
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,
"worst_trade_pnl": round(min(pnls), 2) if pnls else None,
"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,
"skipped_book_full": skipped_full,
"spy_return_pct": round(spy_pct, 1) if spy_pct is not None else None,
"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(),
}
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:
result["vol_target"] = vol_target
result["vol_lookback"] = int(vol_lookback)
@@ -2782,7 +3182,7 @@ def _simulate_portfolio(
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
}
for trade in trades
for trade in metric_trades
]
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]
# 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(
breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20
) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date.
This is deliberately a pure divergence: it is positive only when benchmark
price holds/rises while breadth falls. Absolute low breadth belongs in the
State score, so it is not counted again here. A 20 percentage-point breadth
deterioration maps to 100.
A 20 percentage-point breadth deterioration maps to 100 when the benchmark
is flat or rising, tapering to ``DIVERGENCE_CONFIRMED_FLOOR`` of that once
the benchmark is down ``DIVERGENCE_TAPER_PCT`` or more over the window.
"""
bench = {d: c for d, c in benchmark_closes}
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 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points
deterioration = max(0.0, -breadth_chg)
score = deterioration * 5.0 if price_ret >= 0 else 0.0
out[d] = max(0.0, min(100.0, round(score, 2)))
taper = max(0.0, min(1.0, (price_ret + DIVERGENCE_TAPER_PCT) / DIVERGENCE_TAPER_PCT))
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
+85 -7
View File
@@ -30,10 +30,10 @@ import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any, Protocol, runtime_checkable
from sqlalchemy import select, text
from sqlalchemy import exists, select, text
from sqlalchemy.engine import Engine # noqa: F401 (typing only)
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
@@ -48,6 +48,7 @@ STATUS_RUNNING = "running"
STATUS_VALIDATED = "validated"
STATUS_PROMOTED = "promoted"
STATUS_NO_OP = "no_op"
STATUS_DEFERRED = "deferred"
STATUS_FAILED = "failed"
_MAX_ERROR_LEN = 4000
@@ -68,6 +69,12 @@ class ValidationResult:
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
@@ -123,19 +130,46 @@ async def _last_promoted_revision(db: AsyncSession, source: str) -> str | None:
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]) -> None:
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="error",
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)
@@ -145,11 +179,18 @@ 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 / failed), or None
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
@@ -189,7 +230,7 @@ async def run_import(
revision = await importer.detect_revision(session)
run.revision = revision
last_rev = await _last_promoted_revision(session, source)
if revision is not None and revision == last_rev:
if not force and revision is not None and revision == last_rev:
run.status = STATUS_NO_OP
run.completed_at = _now()
await session.commit()
@@ -202,9 +243,46 @@ async def run_import(
run.validation_json = json.dumps(result.summary, default=str)
if not result.ok:
run.status = STATUS_FAILED
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(
+84 -18
View File
@@ -28,6 +28,10 @@ DRAWDOWN_LOOKBACK = 252
HORIZON_DAYS = 20
WARN_PERCENTILE = 80.0
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:
@@ -149,30 +153,72 @@ def _warning_series(
breadth_divergence: dict[date, float],
dates: list[date],
config: dict,
) -> dict[date, float]:
"""Technical Warning score used historically (fundamentals have no PIT history)."""
oas_series: rms.Series | None = None,
) -> 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"]
smh_full = prices.get(tickers["leaders"][0], [])
spy_full = prices.get(tickers["market"], [])
out: dict[date, float] = {}
backing: dict[date, int] = {}
for session in dates:
divergence = breadth_divergence.get(session)
relative = rms.p4_relative_strength(
sensors = rms.warning_sensor_scores(
breadth_divergence.get(session),
rms._closes_asof(smh_full, session),
rms._closes_asof(spy_full, session),
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
)
values: list[tuple[float, float]] = []
if divergence is not None:
values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"]))
if relative is not None:
values.append((relative, rms.WARNING_WEIGHTS["relative_strength"]))
if values:
out[session] = round(
sum(value * weight for value, weight in values)
/ sum(weight for _, weight in values),
2,
)
return out
score = rms.score_warning_sensors(sensors)
if score is not None:
out[session] = round(score, 2)
backing[session] = sum(1 for value in sensors.values() if value is not None)
return out, backing
def _reliability(
dates: list[date],
split: int,
backing: dict[date, int],
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(
@@ -195,7 +241,13 @@ async def run_event_study(
db, config["breadth_basket"], window=200, min_tickers=20
)
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)))
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
)
reliability = _reliability(dates, split, backing, len(all_events), len(holdout_events))
basket_asof = date.fromisoformat(config["basket_asof"])
retrospective = dates[split] < basket_asof
evaluation = "exploratory" if retrospective else "holdout"
@@ -224,7 +278,14 @@ async def run_event_study(
f"{evaluation.capitalize()} chronological test: warning episodes preceded "
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
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")
@@ -243,6 +304,7 @@ async def run_event_study(
"train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1),
"credit_sensor_from": credit_from,
"basket_hash": rms._basket_hash(config["breadth_basket"]),
"basket_asof": config["basket_asof"],
},
@@ -255,6 +317,7 @@ async def run_event_study(
"holdout_sessions": holdout_sessions,
},
"metrics": metrics,
"reliability": reliability,
"events": per_event,
"recent_breadth": [
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
@@ -266,8 +329,11 @@ async def run_event_study(
"event": "regime_event_study_complete",
"evaluation": evaluation,
"events": metrics["events"],
"events_detected": reliability["events_detected"],
"warned": metrics["events_warned"],
"false_alarms_per_year": metrics["false_alarms_per_year"],
"underpowered": reliability["underpowered"],
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
}))
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
)
+11
View File
@@ -155,6 +155,17 @@ def _build_valuation(derived, subject_price, peer_derived, peer_price_by_cik, tw
"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),
@@ -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)
)
+98 -12
View File
@@ -8,8 +8,10 @@ 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 row with
the newest `accepted_at` wins.
- **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.
@@ -21,6 +23,7 @@ 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}
@@ -38,6 +41,20 @@ _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
@@ -60,8 +77,15 @@ 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
@@ -85,6 +109,15 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
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)
@@ -102,7 +135,14 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
"net_debt_to_ebitda": _leverage_series(selected, discrete, tape),
"share_count_change_yoy": _share_change_series(selected, tape),
}
_guard_split_sensitive_metrics(result.metrics)
# 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
@@ -112,17 +152,57 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
# -- period selection --------------------------------------------------------
def _select_latest_per_period(snapshots: Iterable[Any]) -> dict[tuple[int, str], Any]:
best: 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
key = (fy, fp)
cur = best.get(key)
if cur is None or _accepted(row) > _accepted(cur):
best[key] = row
return best
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):
@@ -255,18 +335,21 @@ def _share_change_series(selected, tape) -> MetricSeries:
return _series(pts)
def _guard_split_sensitive_metrics(metrics: dict[str, MetricSeries]) -> None:
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
return False
suspect_periods = {
point.period_end
@@ -275,18 +358,21 @@ def _guard_split_sensitive_metrics(metrics: dict[str, MetricSeries]) -> None:
and abs(point.value) >= SPLIT_SUSPECT_SHARE_CHANGE_PCT
}
if not suspect_periods:
return
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:
+15 -115
View File
@@ -14,22 +14,17 @@ import json
import math
import os
import statistics
from collections import defaultdict
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from sqlalchemy import func, select, text
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
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 import fundamentals_derivation as deriv
from app.services import fundamentals_candidate_service as candidate_service
REPORT_VERSION = 1
APPROVAL_STATUS = "pending_explicit_approval"
@@ -94,33 +89,18 @@ async def build_report(
)
await connection.execute(text("SET TRANSACTION READ ONLY"))
tickers = list((await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars())
ticker_ids = [ticker.id for ticker in tickers]
ciks = sorted({ticker.cik for ticker in tickers if ticker.cik})
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)
derived_by_cik = await _derived_by_cik(db, ciks)
closes_by_ticker = await _latest_closes(db, ticker_ids)
surprise_by_ticker = await _latest_surprises(db, ticker_ids, today)
source_runs = await _source_runs(db)
rows: list[dict[str, Any]] = []
for ticker in tickers:
legacy = legacy_by_ticker.get(ticker.id)
derived = derived_by_cik.get(ticker.cik) if ticker.cik else None
close = closes_by_ticker.get(ticker.id)
candidate_pe = (
_pe(close[0], derived.ttm_diluted_eps)
if close is not None and derived is not None
else None
)
growth_series = (
derived.metrics.get("revenue_growth_yoy") if derived is not None else None
)
candidate = {
"pe_ratio": candidate_pe,
"revenue_growth": growth_series.value if growth_series else None,
"earnings_surprise": surprise_by_ticker.get(ticker.id),
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,
@@ -128,17 +108,17 @@ async def build_report(
"earnings_surprise": legacy.earnings_surprise if legacy else None,
}
fields = {
key: _field_comparison(key, legacy_values[key], candidate[key])
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)
candidate_score = fundamental_score(**candidate_values)
rows.append(
{
"symbol": ticker.symbol,
"cik": ticker.cik,
"symbol": candidate.symbol,
"cik": candidate.cik,
"legacy_fetched_at": _iso(legacy.fetched_at) if legacy else None,
"price_date": _iso(close[1]) if close else None,
"price_date": _iso(candidate.price_date),
"fields": fields,
"scores": {
"legacy_fundamental": _round(legacy_score),
@@ -311,74 +291,6 @@ async def _legacy_values(
return {row.ticker_id: row for row in rows}
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]]:
if not ticker_ids:
return {}
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 _latest_surprises(
db: AsyncSession, ticker_ids: list[int], today: date
) -> dict[int, float]:
if not ticker_ids:
return {}
rows = (
await db.execute(
select(EarningsEvent)
.where(
EarningsEvent.ticker_id.in_(ticker_ids),
EarningsEvent.announce_date < today,
)
.order_by(EarningsEvent.ticker_id, EarningsEvent.announce_date.desc())
)
).scalars()
out: dict[int, float] = {}
for row in rows:
if row.ticker_id in out:
continue
surprise = _surprise(row.eps_estimate, row.eps_actual)
if surprise is not None:
out[row.ticker_id] = surprise
return out
async def _source_runs(db: AsyncSession) -> dict[str, dict[str, Any] | None]:
sources = ("sec_facts", "dolt_earnings")
rows = (
@@ -526,18 +438,6 @@ def _rank_change(legacy: int | None, candidate: int | None) -> int | None:
return legacy - candidate if legacy is not None and candidate is not None else None
def _surprise(estimate: float | None, actual: float | None) -> float | None:
if not _finite(estimate) or not _finite(actual) or estimate == 0:
return None
return (actual - estimate) / abs(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 price / ttm_eps
def _delta(legacy: float | None, candidate: float | None) -> float | None:
if not _finite(legacy) or not _finite(candidate):
return 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})
+29 -3
View File
@@ -100,6 +100,8 @@ async def fetch_and_ingest(
symbol: str,
start_date: date | None = None,
end_date: date | None = None,
*,
refresh_sr: bool = True,
) -> IngestionResult:
"""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:
start_date = backfill_start
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:
start_date = backfill_start
@@ -239,7 +246,7 @@ async def fetch_and_ingest(
ticker.symbol,
ingested_count,
)
if ingested_count > 0:
if ingested_count > 0 and refresh_sr:
await _refresh_structural_sr(db, ticker.symbol)
return IngestionResult(
symbol=ticker.symbol,
@@ -249,9 +256,28 @@ async def fetch_and_ingest(
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)
# 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(
symbol=ticker.symbol,
records_ingested=ingested_count,
+40 -1
View File
@@ -352,6 +352,7 @@ def _to_dict(
current_price: float | None,
benchmark_closes: dict[date, float] | None = None,
trailing: tuple[float, float | None] | None = None,
holding_sessions: tuple[int, int] | None = None,
) -> dict:
# For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price
@@ -395,6 +396,8 @@ def _to_dict(
"fill_mode": trade.fill_mode,
"trailing_stop": trailing[0] 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
# policy is active).
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]] = {}
if policy["mode"] == "trailing":
trail_frac = policy["trailing_pct"] / 100.0
@@ -483,7 +515,14 @@ async def list_trades(
trailing_info[t.id] = (level, dist)
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
]
+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
two deliberately separate outputs:
* State: current structural stress (price, breadth, credit, volatility).
* Warning: deterioration/divergence that may precede State (breadth, relative
strength, and sourced fundamental observations).
* Warning: deterioration/divergence that may precede State (breadth divergence,
relative strength, credit impulse).
Daily snapshots are the point-in-time record. The first v2 run rewrites the
latest ``REBUILD_SESSIONS`` trading sessions once; ordinary runs thereafter only
upsert the latest trading date. Fundamental observations are never replayed
before their effective date.
Both scores are quantitative and daily. The sourced hyperscaler capex and
earnings-reaction observations are a qualitative *overlay* in v3 rather than
weighted sensors: at a combined 20 points they could not reach the event
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
@@ -41,19 +48,51 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
KEY_CONFIG = "regime_monitor_config"
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
MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7
QUADRANT_STATE_DIVIDER = 60.0
QUADRANT_WARNING_DIVIDER = 60.0
# Bands are per axis: the two scores have genuinely different realized ranges,
# 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
HY_OAS_MILD = 3.5
HY_OAS_ELEVATED = 5.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 = {
"price": 40.0,
@@ -61,11 +100,15 @@ STATE_WEIGHTS = {
"credit": 20.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 = {
"breadth_divergence": 50.0,
"breadth_divergence": 45.0,
"relative_strength": 30.0,
"capex": 12.0,
"earnings_reaction": 8.0,
"credit_impulse": 25.0,
}
# 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")
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}
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)
def band_for(score: float) -> str:
if score < 30:
def _interpolate(x: float, anchors: tuple[tuple[float, float], ...]) -> float:
"""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"
if score < 60:
if score < elevated:
return "watch"
if score < 80:
if score < breaking:
return "elevated"
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)
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:
return None
peak = max(closes[-252:])
if peak <= 0:
return None
dd_pct = (peak - closes[-1]) / peak * 100.0
return _clamp(dd_pct * 5.0)
return (peak - closes[-1]) / peak * 100.0
def p3_drawdown(smh: list[float], qqq: list[float]) -> float | None:
vals = [v for v in (_drawdown(smh), _drawdown(qqq)) if v is not None]
return max(vals) if vals else None
def _drawdown(closes: list[float]) -> float | None:
dd_pct = drawdown_pct(closes)
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:
@@ -220,18 +287,68 @@ def _oas_absolute_score(value: float) -> float:
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:
return None
latest = oas_values[-1]
absolute = _oas_absolute_score(latest)
if len(oas_values) < 30:
return round(absolute, 2)
less = sum(1 for v in oas_values if v < latest)
equal = sum(1 for v in oas_values if v == latest)
percentile = (less + 0.5 * equal) / len(oas_values) * 100.0
relative = _clamp((percentile - 50.0) / 45.0 * 100.0)
return round(absolute * 0.7 + relative * 0.3, 2)
return round(_oas_absolute_score(oas_values[-1]), 2)
def w3_credit_impulse(
oas_values: list[float], lookback: int = W3_OAS_LOOKBACK
) -> float | None:
"""HY OAS rate of change: widening only, relative so it works at any level.
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:
@@ -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())
available_weight = sum(
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
return {
"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),
"minimum_coverage": MIN_COVERAGE,
"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
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:
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]
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:
candidate = d + timedelta(days=1)
while candidate.weekday() >= 5:
@@ -340,19 +477,31 @@ def _fundamental_effective_date(overrides: dict) -> date | 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)
if effective is None or as_of < effective:
return None, None, {"effective_date": effective.isoformat() if effective else None, "age_days": None}
age = (as_of - effective).days
stale = age > int(config.get("fundamental_staleness_days", 80))
f1 = overrides.get("f1_score")
f3 = overrides.get("f3_score")
return (
None if stale or f1 is None else _clamp(float(f1)),
None if stale or f3 is None else _clamp(float(f3)),
{"effective_date": effective.isoformat(), "age_days": age, "stale": stale},
)
pending = effective is None or as_of < effective
age = None if pending else (as_of - effective).days
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
return {
"available": not pending and not stale,
"pending": pending,
"stale": stale,
"effective_date": effective.isoformat() if effective else None,
"age_days": age,
"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:
@@ -393,12 +542,14 @@ def _compute_index(
vix_item = _item_asof(vix_series, as_of)
vix_score = p5_volatility(vix_item[1] if vix_item else None)
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)
divergence = _value_asof(divergence_series, as_of)
relative_strength = p4_relative_strength(smh, spy)
f1, f3, fundamental_meta = _fundamental_scores_asof(overrides, config, as_of)
sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
relative_strength = sensors["relative_strength"]
credit_impulse = sensors["credit_impulse"]
overlay = fundamental_overlay(overrides, config, as_of)
state_pillars = [
{
@@ -444,21 +595,22 @@ def _compute_index(
"sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)],
},
{
"id": "capex",
"label": "Hyperscaler capex revisions",
"score": round(f1, 1) if f1 is not None else None,
"sensors": [_sensor("F1", "Capex guidance cuts", f1)],
},
{
"id": "earnings_reaction",
"label": "Good news, stock down",
"score": round(f3, 1) if f3 is not None else None,
"sensors": [_sensor("F3", "Abnormal earnings reaction", f3)],
"id": "credit_impulse",
"label": "Credit impulse",
"score": round(credit_impulse, 1) if credit_impulse is not None else None,
"sensors": [
_sensor(
"W3",
f"HY OAS {W3_OAS_LOOKBACK}-session widening",
credit_impulse,
oas=oas_item[1] if oas_item else None,
)
],
},
]
state = _score_pillars(state_pillars, STATE_WEIGHTS)
warning = _score_pillars(warning_pillars, WARNING_WEIGHTS)
state = _score_pillars(state_pillars, STATE_WEIGHTS, STATE_BANDS)
warning = _score_pillars(warning_pillars, WARNING_WEIGHTS, WARNING_BANDS)
price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of)
dated_sources = {
@@ -481,6 +633,7 @@ def _compute_index(
"date": as_of.isoformat(),
"state": state,
"warning": warning,
"fundamental_overlay": overlay,
"quadrant_config": {
"state_divider": QUADRANT_STATE_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_date": breadth_item[0].isoformat() if breadth_item else None,
"fundamentals_fetched_at": overrides.get("fetched_at"),
"fundamentals_effective_date": fundamental_meta.get("effective_date"),
"fundamentals_age_days": fundamental_meta.get("age_days"),
"fundamentals_effective_date": overlay.get("effective_date"),
"fundamentals_age_days": overlay.get("age_days"),
},
"data_quality": {
"minimum_coverage": MIN_COVERAGE,
"oldest_market_input_age_days": max(source_ages.values()) if source_ages else None,
"stale_inputs": 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)
except (TypeError, ValueError):
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
capex = _normalise_capex_states(stored.get("capex"), names)
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),
))
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:
return False, existing_v2
row.total_score = float(state_score or 0.0)
@@ -754,7 +915,7 @@ async def _upsert_snapshot(
return True, result
def _parse_v2(raw: str) -> dict | None:
def _parse_snapshot(raw: str) -> dict | None:
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
@@ -762,12 +923,12 @@ def _parse_v2(raw: str) -> dict | 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(
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000)
)
for row in result.scalars().all():
parsed = _parse_v2(row.breakdown_json)
parsed = _parse_snapshot(row.breakdown_json)
if parsed is not None:
return row, parsed
return None
@@ -791,8 +952,10 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
latest_date = leader_series[-1][0]
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(
"BAMLH0A0HYM2", end - timedelta(days=int(365.25 * 13)), end
"BAMLH0A0HYM2", end - timedelta(days=HY_OAS_WINDOW_DAYS), end
)
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)
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)
if rebuilding:
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]]
@@ -860,7 +1023,7 @@ async def _result_at_or_before(
.limit(1000)
)
for raw in result.scalars().all():
parsed = _parse_v2(raw)
parsed = _parse_snapshot(raw)
parsed_hash = ((parsed or {}).get("basket") or {}).get("hash")
if parsed is not None and (basket_hash is None or parsed_hash == basket_hash):
return parsed
@@ -877,7 +1040,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
async def get_regime_monitor(db: AsyncSession) -> dict:
latest = await _latest_v2_row(db)
latest = await _latest_snapshot_row(db)
if latest is None:
return {"available": False, "reason": "v2 not computed yet"}
row, result = latest
@@ -902,6 +1065,15 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
quality["snapshot_age_days"] = snapshot_age
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
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
return result
@@ -915,7 +1087,7 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
)
out: list[dict] = []
for row in result.scalars().all():
data = _parse_v2(row.breakdown_json)
data = _parse_snapshot(row.breakdown_json)
if data is None:
continue
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.trade_setup import TradeSetup
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.qualification import setup_qualifies
from app.services.sr_service import detect_gate_target_ladder
@@ -526,6 +527,7 @@ async def scan_ticker(
primary_min_rr: float | None = None,
gate_levels_override: list[Any] | None = None,
scan_run_id: str | None = None,
fundamentals_eligible: bool | None = None,
) -> list[TradeSetup]:
"""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)
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:
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()]
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
# 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.
@@ -765,6 +801,12 @@ async def scan_all_tickers(
for index, (ticker_id, symbol) in enumerate(ticker_rows):
if progress_callback is not None:
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;
# without this recalculate the score path would see yesterday's zones.
# 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"),
primary_min_rr=PRIMARY_TARGET_MIN_RR,
scan_run_id=scan_run_id,
fundamentals_eligible=True,
)
all_setups.extend(setups)
if activation is not None:
@@ -882,6 +925,26 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
excluded_ticker_ids: set[int] = set()
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:
# Manual book only. The shadow book holds the *top-ranked* names by
# construction, so letting its positions hide setups would leave the
+52 -5
View File
@@ -8,7 +8,9 @@ the daily filing index — behind one client that honors SEC's fair-access polic
- 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.
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
@@ -53,11 +55,44 @@ class SecForbiddenError(SecError):
class SecNotFoundError(SecError):
"""SEC returned 404 — the resource does not exist (e.g. no index for a day).
"""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
(403, exhausted retries, 5xx, timeout) must propagate so a fetch failure is
never mistaken for an empty result."""
(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:
@@ -155,6 +190,8 @@ class SecClient:
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"
@@ -252,7 +289,17 @@ class SecClient:
try:
text = await self.get_text(url)
except SecNotFoundError:
logger.info("no daily index for %s (404)", day)
# 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)
+198 -7
View File
@@ -8,6 +8,9 @@ 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.
@@ -15,7 +18,10 @@ The load-bearing rules (design Decision 2 + review):
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.
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).
@@ -29,7 +35,7 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import Any, NamedTuple
logger = logging.getLogger(__name__)
@@ -37,14 +43,35 @@ 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}
_YTD_TOLERANCE_DAYS = 20 # covers 52/53-week fiscal calendars
# 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"],
@@ -62,7 +89,27 @@ _DURATION_USD = {
"DepreciationAndAmortization",
],
}
_EPS_CONCEPTS = ["EarningsPerShareDiluted"] # unit USD/shares
# 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
@@ -104,6 +151,7 @@ class SnapshotRow:
total_debt: float | None = None
shares_outstanding: float | None = None
shares_outstanding_date: date | None = None
weighted_avg_diluted_shares: float | None = None
@dataclass
@@ -127,14 +175,23 @@ 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:
@@ -143,7 +200,7 @@ def parse_snapshots(
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)
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
@@ -190,12 +247,18 @@ def _index_by_accession(companyfacts: dict[str, Any]) -> dict[str, list[Fact]]:
def _parse_one(
cik: str, accn: str, facts: list[Fact], meta: FilingMeta
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 = _fiscal_context(facts, meta.report_date)
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"
@@ -227,9 +290,109 @@ def _parse_one(
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
@@ -352,6 +515,34 @@ def _select_shares(
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
+623 -31
View File
@@ -18,25 +18,50 @@ Guardrails (design + reviews):
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 defaultdict
from dataclasses import dataclass, field
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 select
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
@@ -51,13 +76,24 @@ _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",
"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.
@@ -72,9 +108,17 @@ class StagedFundamentals:
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)
missing_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
@@ -93,12 +137,24 @@ class SecFundamentalsImporter:
*,
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
@@ -111,7 +167,11 @@ class SecFundamentalsImporter:
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")
if last_processed is None:
# 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:
@@ -120,9 +180,19 @@ class SecFundamentalsImporter:
client, last_processed, self._latest_index_date
)
content = sec_universe.index_content_hash(self._index_rows)
return sec_universe.compose_revision(
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"
@@ -130,10 +200,32 @@ class SecFundamentalsImporter:
staged = StagedFundamentals(resolved=resolved, backfill=self._backfill)
cik_to_tids = resolved.cik_to_ticker_ids
filed_by_cik: dict[int, list[str]] = defaultdict(list)
# 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["accession"])
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:
@@ -144,10 +236,15 @@ class SecFundamentalsImporter:
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)
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
@@ -164,7 +261,9 @@ class SecFundamentalsImporter:
staged.discrepancies.append({"accession": row.accession, "fields": fields})
return staged
async def _stage_issuer(self, client, cik, is_backfill, filed_by_cik, staged) -> None:
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:
@@ -175,28 +274,90 @@ class SecFundamentalsImporter:
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 accn in filed_by_cik.get(cik, []):
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 in xbrl_meta and accn in present:
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:
# XBRL (or unknown) filing not yet in Company Facts → the products
# have lagged; fail+retry rather than record nothing for it.
staged.missing_xbrl.append({"cik": cik10(cik), "accession": accn})
# 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),
))
result = parser.parse_snapshots(cf, xbrl_meta, accns)
# 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:
if result.rows or recovered_rows:
staged.issuers_with_rows += 1
# SIC proposal for this issuer's tickers (read-only; applied in promote).
@@ -205,14 +366,66 @@ class SecFundamentalsImporter:
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.
if staged.missing_xbrl:
# 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(staged.missing_xbrl)} tracked XBRL filing(s) not yet in "
"Company Facts (index/facts lag) — retry"
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:
@@ -241,7 +454,24 @@ class SecFundamentalsImporter:
"skipped_filings": len(staged.skipped_filings),
"field_issues": len(staged.field_issues),
"skipped_non_xbrl": len(staged.skipped_non_xbrl),
"missing_xbrl": len(staged.missing_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)
@@ -253,46 +483,280 @@ class SecFundamentalsImporter:
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:
continue # immutable — keep the original row
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_discrepancy",
code="snapshot_reparse" if self.reparse else "snapshot_discrepancy",
message=(
f"{len(staged.discrepancies)} stored accession(s) reconstructed "
f"differently; kept immutable: {accns}"
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,
"existing_unchanged": len(staged.existing_accessions),
"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(
@@ -316,9 +780,25 @@ class SecFundamentalsImporter:
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 and r["cik"] in tracked:
rows.append(r)
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
@@ -335,6 +815,30 @@ class SecFundamentalsImporter:
).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 {}
@@ -361,6 +865,73 @@ def _companyfacts_structure_error(cf: Any) -> str | None:
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."""
@@ -395,5 +966,26 @@ def _row_values(row: SnapshotRow, run_id: int) -> dict[str, Any]:
def _diff_fields(row: SnapshotRow, old: FundamentalSnapshot) -> list[str]:
"""Source fields where a re-parsed row differs from the stored (immutable) row."""
return [col for col in _COMPARE_COLS if getattr(row, col) != getattr(old, col)]
"""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)
+41 -1
View File
@@ -16,6 +16,7 @@ changes on the framework's failure commit). The proposals are applied only in
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from typing import Iterable
@@ -23,11 +24,21 @@ 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:
@@ -43,6 +54,7 @@ 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()
@@ -50,7 +62,7 @@ async def resolve_ciks(db, client: SecClient) -> ResolvedUniverse:
if not symbol:
continue
sym = normalise_symbol(symbol)
cik = ticker_to_cik.get(sym)
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
@@ -65,6 +77,34 @@ async def resolve_ciks(db, client: SecClient) -> ResolvedUniverse:
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]]:
+49 -2
View File
@@ -426,8 +426,10 @@ workstream B — Alpaca remains the price source throughout.
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.
The read-only report job and Admin summary/download are implemented; production
observation and explicit cutover approval remain pending.
**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):**
@@ -479,6 +481,51 @@ workstream B — Alpaca remains the price source throughout.
- 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.
+6 -1
View File
@@ -226,7 +226,12 @@ primary period (safe — a filing's own context is correct for its current perio
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**. New config: `sec_user_agent`, `sec_request_spacing_seconds`,
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).
+94 -9
View File
@@ -1,18 +1,25 @@
# Fundamentals production deployment
This is the one-time production setup for the Dolt earnings and SEC fundamentals
imports. Both imports remain shadow inputs until the separate A5 scoring-cutover
approval. Do not add OS cron entries: the application scheduler owns both jobs.
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 (shadow)` runs daily at 04:00 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
@@ -75,13 +82,14 @@ 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 (shadow)**. The first run performs the
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.
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
@@ -154,19 +162,96 @@ 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
- Disable the failing shadow job in Admin → Jobs. This stops scheduled imports
without changing existing data or the legacy scoring path.
- 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 A5 while either shadow feed is unhealthy or the parity gate
has not received explicit approval.
- 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 |
| 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) |
| 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 |
| 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 |
| 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** |
| 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 |
@@ -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 |
| **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 |
| **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
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.
+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.
+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** |
| 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.
---
@@ -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.**
+13
View File
@@ -4,6 +4,7 @@ import type {
AdminUser,
AlertConfig,
AlertTestResult,
FundamentalsCutoverConfig,
PipelineReadiness,
RecommendationConfig,
ScheduleConfig,
@@ -56,6 +57,18 @@ export function updateSetting(key: string, value: string) {
.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() {
return apiClient
.get<RecommendationConfig>('admin/settings/recommendations')
@@ -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>
);
}
@@ -25,7 +25,7 @@ function formatAgo(iso: string | null | undefined): string {
function lastRunColor(status: string | null | undefined): string {
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';
}
@@ -127,7 +127,7 @@ export function JobControls() {
className={`text-[11px] font-medium ${
job.running
? 'text-blue-300'
: job.runtime_status === 'rate_limited'
: job.runtime_status === 'rate_limited' || job.runtime_status === 'deferred'
? 'text-amber-300'
: job.runtime_status === 'error'
? 'text-red-300'
@@ -140,6 +140,8 @@ export function JobControls() {
? 'Running'
: job.runtime_status === 'rate_limited'
? 'Paused (rate-limited)'
: job.runtime_status === 'deferred'
? 'Deferred (retrying)'
: job.runtime_status === 'error'
? 'Last run error'
: job.enabled
@@ -29,20 +29,20 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
},
{
key: 'schedule_dolt_earnings_cron',
label: 'Dolt earnings (shadow)',
hint: 'Pull and import earnings dates/results daily at 02:30 ET. Live scoring remains untouched before A5.',
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 (shadow)',
hint: 'Import tracked-universe SEC facts daily at 04:00 ET. Unchanged revisions become no-op runs.',
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 shadow imports.',
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the bulk imports.',
mono: true,
},
{
@@ -66,7 +66,7 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
{
key: 'schedule_fundamentals_cron',
label: 'Legacy fundamentals (weekly)',
hint: 'Existing provider chain retained until the A5 parity approval and A6 removal.',
hint: 'Fallback provider chain. Automatically skipped while the SEC + Dolt cutover is active.',
mono: true,
},
];
@@ -3,6 +3,8 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types';
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting();
@@ -32,10 +34,11 @@ export function SettingsForm() {
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 (!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 (
<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">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{setting.key === 'registration' ? (
+13 -10
View File
@@ -378,16 +378,15 @@ export function TradeChart({
// it wanders left as more post-entry bars arrive.
const WINDOW = 21;
const MID = 10;
let start: number;
let entryIdx: number;
if (postCount <= MID + 1) {
start = Math.max(0, entryAbs - MID);
entryIdx = entryAbs - start;
} else {
const start = postCount <= MID + 1
? Math.max(0, entryAbs - MID)
// Enough history: keep the latest WINDOW bars; entry falls where it falls.
start = Math.max(0, bars.length - WINDOW);
entryIdx = entryAbs - start;
}
: Math.max(0, bars.length - WINDOW);
// 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 series = windowBars.map((b) => b.close);
if (series.length < 2) return null;
@@ -601,7 +600,11 @@ export function TradeChart({
{entryIdx === lastIdx && (
<circle cx={px(entryIdx)} cy={py(series[entryIdx])} r="2" fill="var(--ink-3)" />
)}
<circle cx={px(entryIdx)} cy={py(entry)} r="3.5" fill="var(--ink-2)" stroke="var(--surface)" strokeWidth="1.5" />
{/* 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(lastIdx)} cy={py(series[lastIdx])} r="4" fill={nowCol} stroke="var(--surface)" strokeWidth="2" />
</svg>
);
@@ -22,6 +22,63 @@ function pnlColor(v: number): string {
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 }) {
const isLong = direction === 'long';
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. */
function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, onClose, closing }: {
function TradeDetail({ trade, exitMode, atrMultiplier, trailingPct, onClose, closing }: {
trade: PaperTrade;
exitLabel: string | null;
exitMode: 'time' | 'trailing' | 'atr_trailing' | 'target';
atrMultiplier: number;
trailingPct: number;
@@ -66,30 +135,28 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
staleTime: 5 * 60_000,
});
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'
? 'entry · now · stop · trail · gate'
: 'entry · now · stop · gate';
return (
<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">
<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) : '—'}`
} />
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 md:grid-cols-4 xl:grid-cols-2">
<Detail
label="P&L"
value={p ? `${money(p.pnl)} · ${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
valueClass={p ? pnlColor(p.pnl) : 'text-gray-500'}
/>
<Detail
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'}
/>
<Detail label="entry → now" value={
`${formatPrice(trade.entry_price)}${trade.current_price != null ? formatPrice(trade.current_price) : '—'}`
} />
<Detail
label={trailMoved ? 'trail' : 'stop'}
value={
@@ -105,27 +172,36 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
}
/>
<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={
<>
{formatPrice(trade.target)}
{exitMode !== 'target' && (
<span className="ml-1.5 text-[10px] text-gray-500">screening only</span>
)}
opened {opened}
{holdText && <span className={maxHoldColor(trade)}> · {holdText}</span>}
</>
}
/>
<Detail label="exit rule" value={exitLabel ?? 'target/stop'} />
<div className="flex items-end">
<button
onClick={onClose}
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"
>
Sell at market
</button>
</div>
</dl>
<Fact label="screening target" value={formatPrice(trade.target)} />
{exitRuleText && <Fact label="exit" value={exitRuleText} />}
<button
onClick={onClose}
disabled={closing}
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
</button>
</div>
{ohlcv.data && (
<div>
<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 exitLabel = policy
? 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'
? `trailing ${Math.round(trailingPct)}%`
: policy.mode === 'time'
? `${policy.hold_days}d hold`
? `${policy.hold_days}-session hold`
: 'target/stop'
: null;
const hasMaxHold = exitMode === 'atr_trailing' || exitMode === 'time';
const rows = trades ?? [];
@@ -245,7 +322,10 @@ export function OpenTradesPanel() {
<span className="num hidden text-xs text-gray-400 sm:block">
{formatPrice(t.entry_price)} {t.current_price != null ? formatPrice(t.current_price) : '—'}
</span>
<RBar r={p?.r ?? null} max={rMax} />
<div className={`min-w-0 ${hasMaxHold ? 'space-y-1.5' : ''}`}>
<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'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</span>
@@ -256,7 +336,6 @@ export function OpenTradesPanel() {
{open && (
<TradeDetail
trade={t}
exitLabel={exitLabel}
exitMode={exitMode}
atrMultiplier={atrMultiplier}
trailingPct={trailingPct}
+1
View File
@@ -38,6 +38,7 @@ const ind = (median: number, favorable_percentile: number) =>
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 = {
+30
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() {
return useQuery({
queryKey: ['admin', 'recommendation-settings'],
+46 -1
View File
@@ -187,6 +187,10 @@ export interface ActivationConfig {
exclude_neutral: boolean;
}
export interface FundamentalsCutoverConfig {
enabled: boolean;
}
// Cron schedule for morning / near-close / after-close / intraday + fundamentals
export interface ScheduleConfig {
schedule_timezone: string;
@@ -233,6 +237,8 @@ export interface PaperTrade {
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null;
trailing_distance_pct: number | null;
sessions_held: number | null;
sessions_remaining: number | null;
}
export interface ExitPolicy {
@@ -476,6 +482,9 @@ export interface RegimePillar {
export interface RegimeReading {
score: number | 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;
minimum_coverage: number;
available_pillars: string[];
@@ -483,6 +492,23 @@ export interface RegimeReading {
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 {
date: string;
state: number | null;
@@ -499,6 +525,10 @@ export interface RegimeMonitor {
date?: string;
state?: 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?: {
vix: number | null;
vix_date: string | null;
@@ -530,7 +560,7 @@ export interface RegimeMonitor {
}
export interface RegimeFundamentals {
methodology: 'v2';
methodology: 'v3';
f1_score: number | null;
f3_score: number | null;
locked: boolean;
@@ -576,6 +606,18 @@ export interface EventStudyReport {
warn_threshold: number;
basket_hash: 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?: {
start: string;
@@ -787,6 +829,9 @@ export interface FundamentalResponse {
metrics: MetricItem[] | null;
valuation: Valuation | null;
reads: FundamentalsReads | null;
setup_eligible: boolean;
setup_block_code: string | null;
setup_block_reason: string | null;
}
// Indicators
+2
View File
@@ -6,6 +6,7 @@ import { SentimentProviderSettings } from '../components/admin/SentimentProvider
import { DataCleanup } from '../components/admin/DataCleanup';
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 { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
@@ -36,6 +37,7 @@ export default function AdminPage() {
{activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && (
<div className="space-y-4">
<FundamentalsCutoverSettings />
<ActivationSettings />
<ExitPolicySettings />
<PerformanceSettings />
+116 -6
View File
@@ -21,6 +21,7 @@ import type {
GoodNewsReaction,
RegimeBand,
RegimeConfig,
RegimeFundamentalOverlay,
RegimeFundamentals,
RegimeFundamentalsUpdate,
RegimeReading,
@@ -64,6 +65,8 @@ function ScoreGauge({
const complete = reading?.band != null;
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
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 (
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3">
@@ -98,8 +101,15 @@ function ScoreGauge({
style={{ left: `${position}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
{/* Thresholds come from the reading: the two axes no longer share them. */}
<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>
</>
)}
@@ -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 }) {
return (
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
@@ -190,6 +271,32 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
</table>
</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">
The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
pre-freeze basket history remains exploratory.
@@ -235,8 +342,10 @@ function FundamentalsEditor({
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
const cutting = knownCapex.filter((state) => state === 'cutting').length;
const derivedF1 = knownCapex.length >= 3 ? Math.round((cutting / knownCapex.length) * 1000) / 10 : null;
// Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
// 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;
return (
<div className="space-y-4">
@@ -266,7 +375,7 @@ function FundamentalsEditor({
</label>
))}
</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>
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
<span>
@@ -369,9 +478,10 @@ export default function RegimePage() {
label="Warning · deterioration & divergence"
reading={data.warning}
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>
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<p className="text-xs text-gray-600">
Data quality · oldest market input:{' '}
{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`;
}
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 {
label: string;
available: boolean;
timestamp?: string | null;
timestampLabel?: string | null;
selector: FetchSelector; // what a refresh of this row fetches
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>
{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 ? (
<span className="text-[10px] text-gray-600">no data</span>
) : null}
@@ -171,10 +205,16 @@ export default function TickerDetailPage() {
const dataStatus: DataStatusItem[] = useMemo(() => [
{
label: 'OHLCV',
// Market age of the latest bar (session date), not DB insert time —
// created_at stays frozen when the provider returns no new sessions.
// Keep the market session date distinct from the last successful bar
// write; treating YYYY-MM-DD as an instant makes today's session look old.
available: !!ohlcv.data && ohlcv.data.length > 0,
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,
paid: true,
},
@@ -319,6 +359,15 @@ export default function TickerDetailPage() {
busy={ingestion.isPending}
/>
</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="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
<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:
sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
_period_percentiles,
)
POLICY_NAMES = (
"immediate",
"next_session",
@@ -107,85 +112,6 @@ def _default_output_path() -> Path:
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:
"""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:
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
CACHE_VERSION = "research-matrix-v1-daily-prod"
@@ -104,66 +109,6 @@ def _parse_args() -> argparse.Namespace:
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:
for row in arm.get("windows") or []:
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:
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"
# 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()}"
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:
parser = argparse.ArgumentParser(
description=__doc__,
+21 -4
View File
@@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from sqlalchemy import delete
from app.database import Base
from app.providers.protocol import OHLCVData
@@ -32,14 +34,29 @@ _test_session_factory = async_sessionmaker(
)
_schema_created = False
@pytest.fixture(autouse=True)
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:
await conn.run_sync(Base.metadata.create_all)
if not _schema_created:
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
async with _test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
+17
View File
@@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ValidationError
from app.services.admin_service import (
get_activation_config,
get_fundamentals_cutover_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):
with pytest.raises(ValidationError):
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
}
+67 -2
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio
import os
import tempfile
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import func, select
@@ -29,6 +29,7 @@ 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,
@@ -67,9 +68,16 @@ class FakeImporter:
source = "sec_facts"
def __init__(self, revision, *, ok=True, n_rows=3, raise_in="none"):
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
@@ -94,6 +102,9 @@ class FakeImporter:
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):
@@ -200,6 +211,60 @@ async def test_failed_validation_leaves_data_untouched(engine):
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
+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
@@ -6,7 +6,9 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import (
MIN_EVENTS_FOR_CONFIDENCE,
_percentile,
_reliability,
alarm_episodes,
detect_events,
evaluate_alarms,
@@ -23,6 +25,40 @@ def test_detect_events_uses_rising_edge_and_cooldown():
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():
values = [float(value) for value in range(0, 101, 10)]
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
def test_breadth_from_fixed_closes_and_pure_divergence():
def test_breadth_from_fixed_closes_and_tapered_divergence():
dates = _days(10)
closes_by_symbol = {
"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)
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)]))
no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
assert no_divergence[dates[-1]] == 0
confirmed = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
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"]
+143
View File
@@ -29,6 +29,7 @@ class Snap:
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"]
@@ -188,3 +189,145 @@ def test_amendment_selection_newest_accepted_wins():
# 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)}"
)
@@ -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
+78 -4
View File
@@ -6,6 +6,8 @@ from datetime import date, timedelta
import pytest
from app.models.ohlcv import OHLCVRecord
from app.models.settings import IngestionProgress
from app.models.ticker import Ticker
from app.providers.protocol import OHLCVData
from app.services import ingestion_service as svc
@@ -18,9 +20,12 @@ async def session():
yield s
async def _add_ticker(session, symbol: str) -> None:
session.add(Ticker(symbol=symbol))
async def _add_ticker(session, symbol: str) -> Ticker:
ticker = Ticker(symbol=symbol)
session.add(ticker)
await session.commit()
await session.refresh(ticker)
return ticker
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
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):
# Covered ticker, just no new bars in the window → complete, not no_data.
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")
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.records_ingested == 0
assert result.records_ingested == 1
assert result.last_date is not None
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 == []
+110
View File
@@ -56,6 +56,116 @@ async def test_create_and_list_open(session):
assert row["symbol"] == "AAA"
assert row["status"] == "open"
assert row["current_price"] == 110.0 # marked to the latest close
assert row["sessions_held"] == 0
assert row["sessions_remaining"] == 30
async def test_list_open_counts_post_entry_sessions_for_max_hold(session):
await svc.set_exit_policy(session, mode="atr_trailing", hold_days=5)
ticker_id = await _seed(session, "COUNT", close=110.0)
trade = await svc.create_trade(
session,
1,
symbol="COUNT",
direction="long",
entry_price=100.0,
shares=10,
stop_loss=95.0,
target=120.0,
)
today = _today()
trade.opened_at = datetime.combine(
today - timedelta(days=5), datetime.min.time(), tzinfo=timezone.utc
)
session.add_all([
OHLCVRecord(
ticker_id=ticker_id,
date=today - timedelta(days=4),
open=101,
high=102,
low=100,
close=101,
volume=1,
),
OHLCVRecord(
ticker_id=ticker_id,
date=today - timedelta(days=2),
open=102,
high=103,
low=101,
close=102,
volume=1,
),
])
await session.commit()
row = (await svc.list_trades(session, 1, status="open"))[0]
# Two added bars plus today's seeded bar; skipped calendar dates do not count.
assert row["sessions_held"] == 3
assert row["sessions_remaining"] == 2
async def test_list_open_exposes_past_max_hold_after_policy_is_shortened(session):
await svc.set_exit_policy(session, mode="time", hold_days=2)
ticker_id = await _seed(session, "OVERDUE", close=110.0)
trade = await svc.create_trade(
session,
1,
symbol="OVERDUE",
direction="long",
entry_price=100.0,
shares=10,
stop_loss=95.0,
target=120.0,
)
today = _today()
trade.opened_at = datetime.combine(
today - timedelta(days=5), datetime.min.time(), tzinfo=timezone.utc
)
session.add_all([
OHLCVRecord(
ticker_id=ticker_id,
date=today - timedelta(days=4),
open=101,
high=102,
low=100,
close=101,
volume=1,
),
OHLCVRecord(
ticker_id=ticker_id,
date=today - timedelta(days=2),
open=102,
high=103,
low=101,
close=102,
volume=1,
),
])
await session.commit()
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["sessions_held"] == 3
assert row["sessions_remaining"] == -1
async def test_list_open_omits_countdown_without_max_hold_policy(session):
await svc.set_exit_policy(session, mode="trailing")
await _seed(session, "NOHOLD", close=110.0)
await svc.create_trade(
session,
1,
symbol="NOHOLD",
direction="long",
entry_price=100.0,
shares=10,
stop_loss=95.0,
target=120.0,
)
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["sessions_held"] is None
assert row["sessions_remaining"] is None
async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):
@@ -0,0 +1,905 @@
from __future__ import annotations
import asyncio
import pickle
import sqlite3
from datetime import date, timedelta
import pytest
from app.services import backtest_service as bt
from scripts.portfolio_capacity_research import (
ANCHOR_YEARS,
RISK_FLOOR_ARMS,
aggregate_results,
bootstrap_median_interval,
build_cells,
build_cohort_manifest,
iqr,
summarize_simulation,
validate_cohort_manifest,
)
from scripts.run_portfolio_construction_matrix import (
CACHE_VERSION,
STUDIES,
_assert_clean_worktree,
_build_candidate_cache,
_checkpoint_state,
_construction_candidate_view,
_construction_universe_errors,
_json_hash,
_load_snapshot,
_markdown,
_operational_summary,
_risk_floor_markdown,
_worker_init,
_worker_run_cell,
_write_cell_checkpoint,
)
def _prices(ords: list[int], close: float = 100.0) -> tuple:
closes = [close] * len(ords)
return (
ords,
list(closes),
[value + 1.0 for value in closes],
[value - 1.0 for value in closes],
list(closes),
[1_000_000] * len(ords),
)
def _candidate(
symbol: str,
day: date,
*,
entry: float = 100.0,
stop: float = 80.0,
rank: float = 90.0,
) -> dict:
return {
'qualified': True,
'direction': 'long',
'symbol': symbol,
'date': day.isoformat(),
'entry': entry,
'stop': stop,
'target': entry + 100.0,
'momentum_percentile': rank,
'activation_momentum_percentile': rank,
'residual_high_vol_blend_80_20': rank,
}
def _business_days(start: date, end: date) -> list[date]:
days: list[date] = []
current = start
while current <= end:
if current.weekday() < 5:
days.append(current)
current += timedelta(days=1)
return days
def test_new_simulator_option_defaults_match_explicit_defaults():
start = date(2025, 1, 6)
ords = [start.toordinal() + offset for offset in range(8)]
prices = {'AAA': _prices(ords)}
candidates = [_candidate('AAA', start)]
legacy = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
3,
include_trades=True,
)
explicit = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
3,
max_positions=10,
min_initial_risk_fraction=None,
weekly_top_n_rebalance=False,
measurement_start_date=None,
hard_end_date=None,
include_capacity_diagnostics=False,
include_trades=True,
)
assert legacy == explicit
def test_load_snapshot_accepts_pre_sec_ticker_schema(tmp_path, monkeypatch):
snapshot = tmp_path / 'legacy-research.sqlite'
with sqlite3.connect(snapshot) as connection:
connection.executescript(
'''
CREATE TABLE tickers (
id INTEGER PRIMARY KEY,
symbol VARCHAR(10) NOT NULL UNIQUE,
name VARCHAR(120),
created_at DATETIME NOT NULL
);
CREATE TABLE ohlcv_records (
id INTEGER PRIMARY KEY,
ticker_id INTEGER NOT NULL,
date DATE NOT NULL,
open FLOAT NOT NULL,
high FLOAT NOT NULL,
low FLOAT NOT NULL,
close FLOAT NOT NULL,
volume BIGINT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE TABLE research_rank_only (
symbol VARCHAR(10) PRIMARY KEY
);
INSERT INTO tickers VALUES
(1, 'LEGACY', 'Legacy Co', '2024-01-01 00:00:00'),
(2, 'RANK', 'Rank Only Co', '2024-01-01 00:00:00');
INSERT INTO ohlcv_records VALUES
(1, 1, '2024-01-02', 100, 102, 99, 101, 1000000,
'2024-01-02 00:00:00'),
(2, 2, '2024-01-02', 50, 51, 49, 50, 500000,
'2024-01-02 00:00:00');
INSERT INTO research_rank_only VALUES ('RANK');
'''
)
async def recommendation_config(_db):
return {}
async def activation_config(_db):
return {'min_momentum_percentile': 80.0}
async def exit_policy(_db):
return {'mode': 'atr_trailing', 'hold_days': 30, 'atr_multiplier': 3.0}
async def benchmark_closes(_db, *, days, refresh):
assert days is None
assert refresh is False
return {date(2024, 1, 2): 100.0}
monkeypatch.setattr(
'app.services.recommendation_service.get_recommendation_config',
recommendation_config,
)
monkeypatch.setattr(
'app.services.admin_service.get_activation_config',
activation_config,
)
monkeypatch.setattr(
'app.services.paper_trade_service.get_exit_policy',
exit_policy,
)
monkeypatch.setattr(
'app.services.backtest_service._load_benchmark_closes_for_backtest',
benchmark_closes,
)
loaded = asyncio.run(_load_snapshot(snapshot, quiet=True))
assert loaded['symbols'] == ['LEGACY', 'RANK']
assert loaded['construction_symbols'] == {'LEGACY'}
assert loaded['prices']['LEGACY'] == (
[date(2024, 1, 2).toordinal()],
[100.0],
[102.0],
[99.0],
[101.0],
[1_000_000],
)
assert loaded['prices']['RANK'][4] == [50.0]
assert loaded['construction_universe_manifest'][
'construction_ticker_rows'
] == 1
assert loaded['construction_universe_manifest']['rank_only_ticker_rows'] == 1
with sqlite3.connect(snapshot) as connection:
columns = {
row[1] for row in connection.execute('PRAGMA table_info(tickers)')
}
assert {'cik', 'sic', 'sic_description'}.isdisjoint(columns)
def test_construction_view_filters_rank_only_rows_without_rebuilding_cache():
manifest = {
'ranking_ticker_rows': 506,
'ranking_symbols_with_prices': 506,
'construction_ticker_rows': 505,
'construction_symbols_with_prices': 505,
'rank_only_ticker_rows': 1,
'rank_only_symbols_with_prices': 1,
'rank_only_unknown_symbols': 0,
}
cached = {
'key': {'version': 'existing-broad-cache'},
'qualified_candidates': [
{'symbol': 'PROD', 'date': '2025-01-02'},
{'symbol': 'RANK', 'date': '2025-01-02'},
],
'qualified_long_count': 2,
'daily_rank_map': {
('RANK', '2025-01-02'): {'strategy_rank': 99.0},
},
}
view = _construction_candidate_view(
cached,
{
'construction_symbols': {'PROD'},
'construction_universe_manifest': manifest,
},
)
assert [row['symbol'] for row in view['qualified_candidates']] == ['PROD']
assert view['raw_full_universe_qualified_long_count'] == 2
assert view['filtered_rank_only_qualified_long_count'] == 1
assert view['qualified_long_count'] == 1
assert ('RANK', '2025-01-02') in view['daily_rank_map']
assert len(cached['qualified_candidates']) == 2
def test_existing_broad_candidate_cache_key_remains_reusable(tmp_path, monkeypatch):
snapshot = tmp_path / 'research.sqlite'
snapshot.write_bytes(b'snapshot-placeholder')
cache_path = tmp_path / 'broad-cache.pkl'
snapshot_data = {
'recommendation_config': {'rr': 3.0},
'activation': {'min_momentum_percentile': 80.0},
'runtime_config': {'ranking_key': 'test'},
'universe_manifest': {
'ticker_rows': 4655,
'symbols_with_prices': 4654,
'symbols_sha256': 'symbols',
},
}
key = {
'version': CACHE_VERSION,
'snapshot': str(snapshot.resolve()),
'snapshot_sha256': 'snapshot-hash',
'cadence': 'daily',
'outcome_horizon_sessions': 0,
'recommendation_config_hash': _json_hash(
snapshot_data['recommendation_config']
),
'activation_hash': _json_hash(snapshot_data['activation']),
'runtime_config': snapshot_data['runtime_config'],
'universe_manifest': snapshot_data['universe_manifest'],
}
cached = {'key': key, 'qualified_candidates': [{'symbol': 'PROD'}]}
cache_path.write_bytes(pickle.dumps(cached))
monkeypatch.setattr(
bt,
'_replay_candidates_for_period',
lambda *_args: pytest.fail('existing cache should avoid replay'),
)
loaded = _build_candidate_cache(
snapshot_data,
snapshot=snapshot,
snapshot_sha256='snapshot-hash',
cache_path=cache_path,
workers=1,
quiet=True,
)
assert loaded == cached
def test_construction_universe_guard_rejects_leaked_broad_book():
valid = {
'ranking_ticker_rows': 4655,
'construction_ticker_rows': 506,
'construction_symbols_with_prices': 506,
'rank_only_ticker_rows': 4149,
'rank_only_unknown_symbols': 0,
}
assert _construction_universe_errors(valid) == []
leaked = {
**valid,
'construction_ticker_rows': 4655,
'construction_symbols_with_prices': 4654,
'rank_only_ticker_rows': 0,
}
errors = _construction_universe_errors(leaked)
assert any('450-600' in error for error in errors)
def test_unbounded_count_and_effective_risk_floor():
start = date(2025, 1, 6)
ords = [start.toordinal() + offset for offset in range(4)]
symbols = [f'S{index}' for index in range(25)]
prices = {symbol: _prices(ords) for symbol in symbols}
candidates = [
_candidate(symbol, start, stop=80.0, rank=100.0 - index)
for index, symbol in enumerate(symbols)
]
capped = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
30,
max_positions=1,
hard_end_date=start + timedelta(days=4),
measurement_start_date=start,
include_capacity_diagnostics=True,
)
unbounded = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
30,
max_positions=None,
min_initial_risk_fraction=0.005,
hard_end_date=start + timedelta(days=4),
measurement_start_date=start,
include_capacity_diagnostics=True,
)
assert capped is not None and unbounded is not None
assert capped['peak_positions'] == 1
assert capped['measurement_skipped_book_full'] == 24
assert unbounded['peak_positions'] > 1
assert unbounded['measurement_skipped_book_full'] == 0
assert unbounded['skipped_min_initial_risk'] > 0
assert unbounded['peak_positions'] == unbounded['trades']
def test_measurement_window_carries_state_but_excludes_pre_anchor_trade_ev():
start = date(2025, 1, 6)
anchor = start + timedelta(days=2)
hard_end = start + timedelta(days=7)
ords = [
start.toordinal() + offset
for offset in range((hard_end - start).days)
]
prices = {
'AAA': _prices(ords, 100.0),
'BBB': _prices(ords, 100.0),
}
candidates = [
_candidate('AAA', start),
_candidate('BBB', anchor + timedelta(days=1)),
]
sim = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
30,
start_date=start,
end_date=hard_end,
measurement_start_date=anchor,
hard_end_date=hard_end,
include_curve=True,
include_trades=True,
)
assert sim is not None
assert sim['simulation_start_date'] == start.isoformat()
assert sim['start_date'] == anchor.isoformat()
assert sim['measurement_start_positions'] == 1
assert sim['trades'] == 1
assert [trade['symbol'] for trade in sim['trade_details']] == ['BBB']
assert sim['equity_curve'][0]['date'] == anchor.isoformat()
def test_weekly_top10_uses_current_rank_for_both_sides_not_entry_rank():
monday = date(2025, 1, 6)
friday = date(2025, 1, 10)
sessions = _business_days(monday, friday)
ords = [session.toordinal() for session in sessions]
prices = {
'AAA': _prices(ords),
'BBB': _prices(ords),
}
candidates = [
_candidate('AAA', monday, rank=99.0),
_candidate('BBB', friday, rank=10.0),
]
rank_map = {
('AAA', friday.isoformat()): {'strategy_rank': 10.0},
('BBB', friday.isoformat()): {'strategy_rank': 90.0},
}
sim = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
30,
max_positions=1,
weekly_top_n_rebalance=True,
daily_rank_map=rank_map,
measurement_start_date=monday,
hard_end_date=friday + timedelta(days=1),
include_trades=True,
include_capacity_diagnostics=True,
)
assert sim is not None
assert [trade['symbol'] for trade in sim['trade_details']] == ['AAA', 'BBB']
assert sim['trade_details'][0]['reason'] == 'weekly_rebalance'
event = sim['weekly_rebalance_events'][0]
assert event['exited_symbols'] == ['AAA']
assert event['selected_entrant_symbols'] == ['BBB']
def test_weekly_top10_incumbent_wins_exact_current_rank_tie():
monday = date(2025, 1, 6)
friday = date(2025, 1, 10)
sessions = _business_days(monday, friday)
ords = [session.toordinal() for session in sessions]
prices = {
'AAA': _prices(ords),
'BBB': _prices(ords),
}
candidates = [
_candidate('AAA', monday, rank=10.0),
_candidate('BBB', friday, rank=99.0),
]
rank_map = {
('AAA', friday.isoformat()): {'strategy_rank': 80.0},
('BBB', friday.isoformat()): {'strategy_rank': 80.0},
}
sim = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
30,
max_positions=1,
weekly_top_n_rebalance=True,
daily_rank_map=rank_map,
measurement_start_date=monday,
hard_end_date=friday + timedelta(days=1),
include_trades=True,
)
assert sim is not None
assert [trade['symbol'] for trade in sim['trade_details']] == ['AAA']
assert sim['trade_details'][0]['reason'] == 'open_at_end'
assert sim['weekly_rebalance_events'][0]['replacements'] == 0
def test_weekly_rebalance_exit_bypasses_cooldown_and_churn_is_counted():
first_monday = date(2025, 1, 6)
friday = date(2025, 1, 10)
next_monday = date(2025, 1, 13)
sessions = _business_days(first_monday, next_monday)
ords = [session.toordinal() for session in sessions]
prices = {
'AAA': _prices(ords),
'BBB': (
ords,
[100.0] * len(ords),
[101.0] * len(ords),
[99.0] * (len(ords) - 1) + [70.0],
[100.0] * len(ords),
[1_000_000] * len(ords),
),
}
candidates = [
_candidate('AAA', first_monday, rank=99.0),
_candidate('BBB', friday, rank=10.0),
_candidate('AAA', next_monday, rank=99.0),
]
rank_map = {
('AAA', friday.isoformat()): {'strategy_rank': 10.0},
('BBB', friday.isoformat()): {'strategy_rank': 90.0},
}
sim = bt._simulate_portfolio(
candidates,
prices,
None,
'hold',
30,
max_positions=1,
reentry_cooldown_sessions=5,
weekly_top_n_rebalance=True,
daily_rank_map=rank_map,
measurement_start_date=first_monday,
hard_end_date=next_monday + timedelta(days=1),
include_trades=True,
)
assert sim is not None
assert [trade['symbol'] for trade in sim['trade_details']] == [
'AAA',
'BBB',
'AAA',
]
assert sim['trade_details'][0]['reason'] == 'weekly_rebalance'
assert sim['rebalance_reentries_within_5_sessions'] == 1
assert sim['skipped_cooldown'] == 0
def test_cohort_manifest_realizes_seven_frozen_clusters():
sessions = _business_days(date(2016, 1, 4), date(2026, 7, 17))
manifest = build_cohort_manifest(sessions)
assert validate_cohort_manifest(manifest) == []
assert manifest['empty_cluster_count'] == 7
assert manifest['warm_cluster_count'] == 7
assert set(map(int, manifest['empty_cluster_counts'])) == set(ANCHOR_YEARS)
assert all(
int(count) >= 12 for count in manifest['warm_seed_counts'].values()
)
cells = build_cells(manifest)
assert len(cells) == (
len(manifest['empty_book']) + len(manifest['warm_book'])
) * 4 * 2
floor_cells = build_cells(manifest, arms=RISK_FLOOR_ARMS)
assert len(floor_cells) == (
len(manifest['empty_book']) + len(manifest['warm_book'])
) * 2 * 2
assert {row['arm_id'] for row in floor_cells} == {
'cap10_incumbent',
'cap10_min_risk_005',
}
def test_risk_floor_study_changes_only_the_effective_risk_floor():
control, treatment = RISK_FLOOR_ARMS
assert control['max_positions'] == treatment['max_positions'] == 10
assert (
control['weekly_top_n_rebalance']
== treatment['weekly_top_n_rebalance']
is False
)
assert control['min_initial_risk_fraction'] is None
assert treatment['min_initial_risk_fraction'] == 0.005
assert STUDIES['risk-floor-ab']['arms'] == RISK_FLOOR_ARMS
assert STUDIES['capacity-bracket']['arms'] != RISK_FLOOR_ARMS
def test_zero_outcome_horizon_extends_rank_replay_to_last_session(monkeypatch):
monkeypatch.setattr(bt, '_window_setups', lambda *_args, **_kwargs: [])
count = bt.MIN_LOOKBACK + bt.HORIZON
start = date(2025, 1, 1)
ords = [start.toordinal() + offset for offset in range(count)]
columns = _prices(ords)
legacy = bt._replay_candidates_for_period(
'AAA',
columns,
{},
{},
None,
date.min,
'daily',
True,
True,
)
zero_horizon = bt._replay_candidates_for_period(
'AAA',
columns,
{},
{},
None,
date.min,
'daily',
True,
True,
0,
)
assert len(zero_horizon) == len(legacy) + bt.HORIZON
assert zero_horizon[-1]['date'] == date.fromordinal(ords[-1]).isoformat()
def test_gain_to_pain_uses_all_monthly_returns_and_net_r():
sim = {
'measurement_start_equity': 100.0,
'trade_details': [
{
'net_r': 1.0,
'pnl': 10.0,
'shares': 1.0,
'entry': 100.0,
'fill': 110.0,
'transaction_cost': 0.0,
},
{
'net_r': -0.5,
'pnl': -5.0,
'shares': 1.0,
'entry': 100.0,
'fill': 95.0,
'transaction_cost': 0.0,
},
],
'equity_curve': [
{'date': '2025-01-31', 'equity': 110.0},
{'date': '2025-02-28', 'equity': 99.0},
],
'trades': 2,
'skipped_book_full': 0,
}
summary = summarize_simulation(sim)
assert summary['ev_net_r'] == pytest.approx(0.25)
assert summary['profit_factor'] == pytest.approx(2.0)
# Monthly returns are +10% and -10%; all-return numerator is zero.
assert summary['gain_to_pain'] == pytest.approx(0.0)
def test_simple_cluster_bootstrap_is_deterministic_and_not_a_gate():
first = bootstrap_median_interval(
[1, 2, 3, 4, 5, 6, 7],
seed_parts=('determinism',),
replicates=500,
)
second = bootstrap_median_interval(
[1, 2, 3, 4, 5, 6, 7],
seed_parts=('determinism',),
replicates=500,
)
assert first == second
assert first['point'] == 4
assert first['p05'] <= first['point'] <= first['p95']
def test_iqr_materializes_generator_before_both_quantiles():
assert iqr(value for value in (0.0, 1.0, 2.0, 3.0)) == pytest.approx(1.5)
def test_aggregate_reports_paired_years_and_separate_warm_iqrs():
cells: list[dict] = []
for cost in (0.1, 0.2):
for cluster in ANCHOR_YEARS:
for seed in range(3):
path_id = f'warm-{cluster}-{seed}'
for arm_id, shift in (
('cap10_incumbent', 0.0),
('cash_unbounded', 0.2),
('cap10_weekly_top10', 0.1),
('cap15_incumbent', 0.05),
):
cells.append({
'arm_id': arm_id,
'protocol': 'warm_book',
'path_id': path_id,
'cluster': cluster,
'cost_per_side_pct': cost,
'metrics': {
'ev_net_r': seed + shift,
'calmar': 1.0 + seed * 0.1 + shift,
'profit_factor': 1.5 + shift,
'gain_to_pain': 2.0 + shift,
'sortino': 1.0 + shift,
'cagr_pct': 10.0 + shift,
'max_drawdown_pct': 5.0,
'total_return_pct': 10.0 + shift,
'sharpe': 1.0 + shift,
},
})
for arm_id, shift in (
('cap10_incumbent', 0.0),
('cash_unbounded', 0.2),
('cap10_weekly_top10', 0.1),
('cap15_incumbent', 0.05),
):
cells.append({
'arm_id': arm_id,
'protocol': 'empty_book',
'path_id': f'empty-{cluster}',
'cluster': cluster,
'cost_per_side_pct': cost,
'metrics': {
'ev_net_r': 1.0 + shift,
'calmar': 2.0 + shift,
'profit_factor': 1.5 + shift,
'gain_to_pain': 2.0 + shift,
'sortino': 1.0 + shift,
'cagr_pct': 10.0 + shift,
'max_drawdown_pct': 5.0,
'total_return_pct': 10.0 + shift,
'sharpe': 1.0 + shift,
},
})
report = aggregate_results(cells)
cash_empty = next(
row
for row in report['paired_per_year']
if row['arm_id'] == 'cash_unbounded'
and row['protocol'] == 'empty_book'
and row['cost_per_side_pct'] == 0.1
)
assert cash_empty['headline']['ev_net_r']['paired_delta_median'] == pytest.approx(
0.2
)
cash_paths = next(
row
for row in report['paired_path_distributions']
if row['arm_id'] == 'cash_unbounded'
and row['protocol'] == 'empty_book'
and row['cost_per_side_pct'] == 0.1
)
assert cash_paths['metrics']['ev_net_r']['paired_delta_mean'] == pytest.approx(
0.2
)
assert cash_paths['metrics']['ev_net_r']['positive_fraction'] == 1.0
assert cash_paths['metrics']['ev_net_r']['identical_fraction'] == 0.0
cash_warm = next(
row
for row in report['warm_seed_dispersion']
if row['arm_id'] == 'cash_unbounded'
and row['cost_per_side_pct'] == 0.1
)
assert set(cash_warm['headline']) == {'ev_net_r', 'calmar'}
assert 'D' not in cash_warm
assert cash_warm['headline']['ev_net_r']['median_iqr_ratio'] == pytest.approx(
1.0
)
assert cash_warm['headline']['calmar']['median_iqr_ratio'] == pytest.approx(
1.0
)
assert cash_warm['headline']['ev_net_r']['bootstrap_90']['n'] == 7
markdown = _markdown({
'generated_at': '2026-08-05T00:00:00Z',
'analysis': report,
'operational_summary': _operational_summary(cells),
'validation': {
'construction_universe_manifest': {
'construction_symbols_with_prices': 506,
'rank_only_symbols_with_prices': 4148,
'ranking_symbols_with_prices': 4654,
},
'candidate_rank_coverage': {
'construction_qualified_longs': 5000,
'filtered_rank_only_qualified_longs': 137000,
},
},
})
assert 'ΔGain-to-Pain' in markdown
assert '0.10% per fill' in markdown
assert '0.20% per fill' in markdown
assert 'Tradable setup symbols with prices: 506.' in markdown
assert 'Rank-only qualified rows removed: 137000.' in markdown
assert 'formal promotion gate' in markdown
focused_cells = [
row
for row in cells
if row['arm_id'] == 'cap10_incumbent'
] + [
{
**row,
'arm_id': 'cap10_min_risk_005',
}
for row in cells
if row['arm_id'] == 'cash_unbounded'
]
focused_analysis = aggregate_results(
focused_cells,
arms=RISK_FLOOR_ARMS,
include_warm_dispersion=False,
)
assert focused_analysis['warm_seed_dispersion'] == []
focused_markdown = _risk_floor_markdown({
'generated_at': '2026-08-05T00:00:00Z',
'arms': list(RISK_FLOOR_ARMS),
'protocols': ['empty_book', 'warm_book'],
'costs_per_side_pct': [0.1, 0.2],
'analysis': focused_analysis,
'operational_summary': _operational_summary(
focused_cells,
arms=RISK_FLOOR_ARMS,
),
})
assert '# Effective initial-risk floor A/B' in focused_markdown
assert 'Mean dEV' in focused_markdown
assert 'Identical' in focused_markdown
assert 'Mean dGtP' in focused_markdown
assert 'Mean dCalmar/MAR' in focused_markdown
assert 'Floor rejects' in focused_markdown
assert 'not independent evidence' in focused_markdown
def test_synthetic_worker_matrix_covers_four_arms_protocols_and_costs(monkeypatch):
monkeypatch.setenv('BACKTEST_SNAPSHOT_OFFLINE', '0')
monkeypatch.setenv('BACKTEST_ALLOW_SPAWN', '0')
start = date(2025, 1, 6)
sessions = _business_days(start, date(2025, 1, 17))
ords = [session.toordinal() for session in sessions]
symbols = [f'S{index}' for index in range(12)]
prices = {symbol: _prices(ords) for symbol in symbols}
candidates = [
_candidate(symbol, start, rank=99.0 - index)
for index, symbol in enumerate(symbols[:11])
]
friday = date(2025, 1, 10)
candidates.append(_candidate('S11', friday, rank=99.0))
rank_map = {
(symbol, friday.isoformat()): {
'strategy_rank': 100.0 if symbol == 'S11' else float(index)
}
for index, symbol in enumerate(symbols)
}
_worker_init({
'qualified_candidates': candidates,
'daily_rank_map': rank_map,
'prices': prices,
'benchmark_closes': None,
'ranking_key': 'residual_high_vol_blend_80_20',
'exit_policy': 'hold',
'hold_days': 30,
'risk_per_trade': 0.01,
'atr_trail_multiplier': 3.0,
})
rows = []
for protocol, measurement_start in (
('empty_book', start),
('warm_book', date(2025, 1, 8)),
):
for cost in (0.1, 0.2):
for arm_id in (
'cap10_incumbent',
'cash_unbounded',
'cap10_weekly_top10',
'cap15_incumbent',
):
rows.append(_worker_run_cell({
'cell_id': f'{arm_id}|{protocol}|{cost}',
'arm_id': arm_id,
'protocol': protocol,
'path_id': f'{protocol}-synthetic',
'cluster': 2025,
'simulation_start': start.isoformat(),
'measurement_start': measurement_start.isoformat(),
'hard_end_exclusive': date(2025, 1, 14).isoformat(),
'cost_per_side_pct': cost,
}))
assert len(rows) == 16
assert {row['arm_id'] for row in rows} == {
'cap10_incumbent',
'cash_unbounded',
'cap10_weekly_top10',
'cap15_incumbent',
}
assert {row['protocol'] for row in rows} == {'empty_book', 'warm_book'}
assert {row['cost_per_side_pct'] for row in rows} == {0.1, 0.2}
assert all('ev_net_r' in row['metrics'] for row in rows)
def test_checkpoint_resume_rejects_fingerprint_mismatch(tmp_path):
checkpoint = tmp_path / 'checkpoint'
completed = _checkpoint_state(checkpoint, 'fingerprint-a', resume=False)
assert completed == {}
_write_cell_checkpoint(
checkpoint,
{'cell_id': 'one', 'metrics': {'ev_net_r': 1.0}},
)
resumed = _checkpoint_state(checkpoint, 'fingerprint-a', resume=True)
assert set(resumed) == {'one'}
with pytest.raises(SystemExit, match='fingerprint mismatch'):
_checkpoint_state(checkpoint, 'fingerprint-b', resume=True)
def test_dirty_worktree_guard(monkeypatch):
monkeypatch.setattr(
'scripts.run_portfolio_construction_matrix._git_output',
lambda *_args: ' M changed.py',
)
with pytest.raises(SystemExit, match='dirty worktree'):
_assert_clean_worktree()
+227 -40
View File
@@ -1,4 +1,4 @@
"""Pure-function tests for the v2 Regime Monitor contract."""
"""Pure-function tests for the v3 Regime Monitor contract."""
from __future__ import annotations
@@ -12,23 +12,30 @@ from sqlalchemy import select
from app.models.regime_snapshot import RegimeSnapshot
from app.routers import market as market_router
from app.services import regime_monitor_service as rms
from app.services import breadth_service, regime_monitor_service as rms
from app.services.regime_monitor_service import (
DEFAULT_CONFIG,
HY_OAS_ELEVATED,
HY_OAS_MILD,
HY_OAS_STRESSED,
STATE_BANDS,
WARNING_BANDS,
WARNING_WEIGHTS,
_compute_index,
_fundamental_scores_asof,
_score_pillars,
band_for,
breadth_level_score,
drawdown_pct,
f2_credit_spreads,
fundamental_overlay,
p1_trend_break,
p2_death_cross,
p3_drawdown,
p4_relative_strength,
p5_volatility,
score_warning_sensors,
w3_credit_impulse,
warning_sensor_scores,
)
@@ -39,11 +46,15 @@ def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[dat
]
def test_band_for_keeps_documented_boundaries():
assert band_for(10) == "stable"
assert band_for(30) == "watch"
assert band_for(60) == "elevated"
assert band_for(80) == "breaking"
def test_band_for_is_per_axis():
assert band_for(10, STATE_BANDS) == "stable"
assert band_for(20, STATE_BANDS) == "watch"
assert band_for(50, STATE_BANDS) == "elevated"
assert band_for(80, STATE_BANDS) == "breaking"
# Warning's realized range is far narrower, so it gets its own thresholds.
assert band_for(45, STATE_BANDS) == "watch"
assert band_for(45, WARNING_BANDS) == "elevated"
assert band_for(60, WARNING_BANDS) == "breaking"
def test_price_sensors_are_stress_only():
@@ -56,8 +67,97 @@ def test_price_sensors_are_stress_only():
assert (p2_death_cross(bearish, bearish) or 0) > 0
assert p2_death_cross(healthy, healthy) == 0
closes = [100.0] * 252 + [80.0]
assert p3_drawdown(closes, [100.0] * 253) == 100.0
def test_drawdown_sensor_keeps_headroom_past_a_twenty_percent_fall():
"""v2 pegged at 100 on a 20% drawdown, losing all resolution deeper in."""
flat = [100.0] * 253
down_20 = [100.0] * 252 + [80.0]
down_30 = [100.0] * 252 + [70.0]
down_45 = [100.0] * 252 + [55.0]
assert drawdown_pct(down_20) == pytest.approx(20.0)
leader_only_20 = p3_drawdown(down_20, flat)
leader_only_30 = p3_drawdown(down_30, flat)
assert leader_only_20 < leader_only_30 < 100.0
# Full scale needs both legs at the deepest anchor, not one at 20%.
assert p3_drawdown(down_45, down_45) == 100.0
assert p3_drawdown(flat, flat) == 0.0
def test_drawdown_blends_leader_and_confirm_instead_of_taking_the_max():
"""max() let the more volatile leader own the whole price pillar."""
flat = [100.0] * 253
down = [100.0] * 252 + [72.0]
both = p3_drawdown(down, down)
leader_only = p3_drawdown(down, flat)
assert leader_only == pytest.approx(both * 2.0 / 3.0)
def test_credit_impulse_scores_widening_only():
assert w3_credit_impulse([3.0] * 40) == 0.0
# Tightening is not stress.
assert w3_credit_impulse([4.0] * 21 + [3.0]) == 0.0
# +35% over the lookback is full scale; half of it is half the score.
assert w3_credit_impulse([3.0] * 21 + [3.0 * 1.35]) == pytest.approx(100.0)
assert w3_credit_impulse([3.0] * 21 + [3.0 * 1.175]) == pytest.approx(50.0)
# Fires while the OAS *level* is still far below the 3.5 mild anchor. This
# is the pairing that lets the level stay purely anchored: dynamics live on
# the Warning axis rather than being smuggled into State as a percentile.
assert f2_credit_spreads([2.0] * 21 + [2.7]) == 0.0
assert (w3_credit_impulse([2.0] * 21 + [2.7]) or 0) > 0
assert w3_credit_impulse([3.0] * 5) is None
def test_snapshot_records_upstream_history_spans():
"""Guards the silent-truncation failure mode that caused this change."""
end = date(2026, 6, 26)
rising = [100.0 + index * 0.2 for index in range(700)]
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
oas = [(end - timedelta(days=index), 4.0) for index in reversed(range(100))]
result = _compute_index(
prices, [(end, 20.0)], oas, {"f1_score": None, "f3_score": None},
copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25},
)
assert result["data_quality"]["credit_history_days"] == 99
assert result["data_quality"]["vix_history_days"] == 0
def test_divergence_still_registers_when_price_confirms_the_breadth_loss():
"""v2's hard price gate zeroed this sensor during every decline.
On 2026-07-24 the basket shed 10 points of participation in 20 sessions
while SMH fell 11.9%, and Warning printed exactly 0 as a result.
"""
days = [date(2026, 1, 1) + timedelta(days=index) for index in range(21)]
breadth = {day: 70.0 for day in days[:1]} | {day: 70.0 - index for index, day in enumerate(days)}
holding = [(day, 100.0) for day in days]
falling = [(day, 100.0 - index * 0.9) for index, day in enumerate(days)]
masked = breadth_service.compute_divergence_series(breadth, holding)[days[-1]]
confirmed = breadth_service.compute_divergence_series(breadth, falling)[days[-1]]
assert masked > confirmed > 0
assert confirmed == pytest.approx(masked * breadth_service.DIVERGENCE_CONFIRMED_FLOOR)
def test_warning_score_renormalises_over_available_sensors():
full = {"breadth_divergence": 40.0, "relative_strength": 0.0, "credit_impulse": 20.0}
assert score_warning_sensors(full) == pytest.approx(
(40 * 45 + 0 * 30 + 20 * 25) / 100
)
partial = {"breadth_divergence": 40.0, "relative_strength": None, "credit_impulse": None}
assert score_warning_sensors(partial) == 40.0
assert score_warning_sensors(dict.fromkeys(full, None)) is None
def test_warning_sensor_scores_covers_every_weighted_pillar():
"""Guards the study/monitor shared definition against silent drift."""
sensors = warning_sensor_scores(10.0, [100.0] * 70, [100.0] * 70, [3.0] * 40)
assert set(sensors) == set(WARNING_WEIGHTS)
def test_relative_strength_flat_or_better_is_zero():
@@ -77,12 +177,24 @@ def test_volatility_and_breadth_zero_points():
assert breadth_level_score(None) is None
def test_credit_uses_named_anchors_and_constant_series_is_not_extreme():
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 35.0
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 70.0
rising = [3.0 + index * 0.01 for index in range(100)]
assert (f2_credit_spreads(rising) or 0) > f2_credit_spreads([3.0] * 100)
def test_credit_level_is_anchored_and_ignores_the_reference_window():
"""The percentile leg is gone: the anchors already encode the long run.
It ranked the level against whatever history the upstream series happened to
serve, and that silently shrank from 10 years to 3 in April 2026 -- three
uniformly tight years, against which an unremarkable spread scored as an
extreme. Identical inputs must now score identically regardless of window.
"""
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0.0
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 50.0
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 100.0
assert f2_credit_spreads([]) is None
# A level at the "mild" anchor is zero stress even when it tops its window.
tight_window = [2.6] * 400 + [HY_OAS_MILD]
assert f2_credit_spreads(tight_window) == 0.0
# Only the latest observation matters; history cannot move the reading.
assert f2_credit_spreads([9.0] * 400 + [3.0]) == f2_credit_spreads([2.6] * 400 + [3.0])
def test_score_pillars_gates_band_below_75_percent_coverage():
@@ -98,31 +210,75 @@ def test_score_pillars_gates_band_below_75_percent_coverage():
assert result["band"] is None
def test_fundamentals_never_replay_before_effective_date_and_expire():
def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
overrides = {
"f1_score": 0.0,
"f3_score": 100.0,
"capex": {"GOOGL": "raising"},
"good_news_stock_down": "yes",
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 1))[:2] == (None, None)
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 2))[:2] == (0.0, 100.0)
assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None)
pending = fundamental_overlay(overrides, config, date(2026, 6, 1))
assert pending["pending"] is True
assert pending["available"] is False
assert pending["capex"] is None
# The effective date is still reported so a pending refresh is visible.
assert pending["effective_date"] == "2026-06-02"
live = fundamental_overlay(overrides, config, date(2026, 6, 2))
assert live["available"] is True
assert live["good_news_stock_down"] == "yes"
assert live["earnings_stress"] == 100.0
expired = fundamental_overlay(overrides, config, date(2026, 8, 22))
assert expired["stale"] is True
assert expired["available"] is False
def test_capex_score_is_derived_from_company_categories():
def test_fundamentals_do_not_move_the_warning_score():
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
It no longer feeds Warning at all, so Warning is identical either way and
the observation is reported beside the score instead of buried in it.
"""
end = date(2026, 6, 26)
rising = [100.0 + index * 0.2 for index in range(700)]
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
args = (prices, [(end, 20.0)], [(end - timedelta(days=i), 4.0) for i in reversed(range(100))])
tail = (copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25})
quiet = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail)
screaming = _compute_index(
*args,
{
"f1_score": 100.0,
"f3_score": 100.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
"good_news_stock_down": "yes",
"effective_date": "2026-06-01",
},
*tail,
)
assert quiet["warning"]["score"] == screaming["warning"]["score"]
assert {p["id"] for p in quiet["warning"]["pillars"]} == set(WARNING_WEIGHTS)
assert screaming["fundamental_overlay"]["available"] is True
assert screaming["fundamental_overlay"]["capex_stress"] == 100.0
def test_capex_score_separates_holding_from_raising():
"""v2 mapped raising and holding both to 0, so a boom read identical to a
deceleration and the sensor carried no information."""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
assert rms._score_capex_states(dict.fromkeys(names, "raising"), names) == 0.0
assert rms._score_capex_states(dict.fromkeys(names, "holding"), names) == 50.0
assert rms._score_capex_states(dict.fromkeys(names, "cutting"), names) == 100.0
assert rms._score_capex_states(
dict.fromkeys(names, "holding"), names
) == 0.0
assert rms._score_capex_states(
{names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}, names
) == 25.0
assert rms._score_capex_states(
{names[0]: "cutting", names[1]: "holding", names[2]: "holding", names[3]: "unknown"},
names,
) == 33.3
{names[0]: "raising", **dict.fromkeys(names[1:], "holding")}, names
) == 37.5
assert rms._score_capex_states(
{names[0]: "cutting", names[1]: "holding", names[2]: "unknown", names[3]: "unknown"},
names,
@@ -135,7 +291,7 @@ def test_fundamental_api_rejects_numeric_ordinal_overrides():
@pytest.mark.asyncio
async def test_legacy_numeric_fundamentals_do_not_leak_into_v2(monkeypatch):
async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch):
async def fake_value(_db, _key):
return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"})
@@ -143,16 +299,47 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v2(monkeypatch):
result = await rms.get_fundamental_overrides(object())
assert result["methodology"] == "v2"
assert result["methodology"] == "v3"
assert result["f1_score"] is None
assert result["f3_score"] is None
assert result["good_news_stock_down"] == "mixed"
@pytest.mark.asyncio
async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
"""A snapshot reseed must not throw away a hand/LLM-collected observation.
The categorical format is unchanged, so the stored capex map is still valid;
only the capex scale moved, and f1 is recomputed from the categories.
"""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
async def fake_value(_db, _key):
return json.dumps({
"methodology": "v2",
"f1_score": 0.0, # stale v2 scale, must be recomputed
"f3_score": 100.0,
"capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")},
"good_news_stock_down": "yes",
"source": "gemini",
"fetched_at": "2026-07-24T14:25:47+00:00",
"effective_date": "2026-07-27",
})
monkeypatch.setattr(rms.settings_store, "get_value", fake_value)
result = await rms.get_fundamental_overrides(object())
assert result["source"] == "gemini"
assert result["good_news_stock_down"] == "yes"
assert result["effective_date"] == "2026-07-27"
assert result["f1_score"] == 37.5 # recomputed on the v3 scale, not the stored 0.0
@pytest.mark.asyncio
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
stored = {
"methodology": "v2",
"methodology": "v3",
"f1_score": 100.0,
"f3_score": 0.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
@@ -185,7 +372,7 @@ async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
current = {
"methodology": "v2",
"methodology": "v3",
"f1_score": None,
"f3_score": None,
"capex": dict.fromkeys(names, "unknown"),
@@ -212,7 +399,7 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
object(), capex=capex, good_news_stock_down="mixed"
)
assert result["f1_score"] == 25.0
assert result["f1_score"] == 62.5 # one cutting (100) + three holding (50)
assert result["f3_score"] is None
assert result["good_news_stock_down"] == "mixed"
assert result["source"] == "manual"
@@ -222,10 +409,10 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
@pytest.mark.asyncio
async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session):
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
snapshot_date = date(2026, 6, 26)
first = {
"methodology": "v2",
"methodology": "v3",
"date": snapshot_date.isoformat(),
"state": {"score": 10.0, "band": "stable"},
"warning": {"score": 20.0, "band": "stable"},
@@ -281,7 +468,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
return {}, {}
async def fake_latest(_db):
return object(), {"methodology": "v2"}
return object(), {"methodology": "v3"}
async def fake_upsert(_db, result, *, rewrite_existing_v2):
rewrites.append(rewrite_existing_v2)
@@ -296,7 +483,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
monkeypatch.setattr(rms, "_fetch_prices", fake_prices)
monkeypatch.setattr(rms, "_fetch_fred_series", fake_fred)
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
monkeypatch.setattr(rms, "_latest_v2_row", fake_latest)
monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert)
result = await rms.update_regime_monitor(FakeDB())
@@ -364,6 +551,6 @@ def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
assert price["score"] == max(sensor_scores)
assert result["methodology"] == "v2"
assert result["methodology"] == "v3"
assert "combined" not in result
assert result["basket"]["members_available"] == 25
+21 -11
View File
@@ -1,36 +1,46 @@
"""Tests for v2 State/Warning quadrant hysteresis and basket reseeding keys."""
"""Tests for v3 State/Warning quadrant hysteresis and basket reseeding keys.
v3 dividers are per axis (State 50, Warning 40) because the two scores have
different realized ranges -- Warning never exceeded 64.9 in the 408 calibration
sessions, so a shared 60 left the whole upper half of that axis unreachable.
"""
from app.services.alert_service import (
QUAD_X_DIV,
QUAD_Y_DIV,
_classify_quadrant,
_parse_quadrant_log_key,
_quadrant_log_key,
)
def test_fresh_classification_uses_60_60_boundaries():
def test_fresh_classification_uses_per_axis_boundaries():
assert (QUAD_X_DIV, QUAD_Y_DIV) == (50.0, 40.0)
assert _classify_quadrant(20, 90, None) == "1"
assert _classify_quadrant(70, 90, None) == "2"
assert _classify_quadrant(20, 30, None) == "3"
assert _classify_quadrant(70, 30, None) == "4"
# A Warning of 45 is above its own divider but below State's.
assert _classify_quadrant(45, 45, None) == "1"
def test_warning_axis_hysteresis():
assert _classify_quadrant(20, 62, prev="3") == "3"
assert _classify_quadrant(20, 66, prev="3") == "1"
assert _classify_quadrant(20, 58, prev="1") == "1"
assert _classify_quadrant(20, 54, prev="1") == "3"
assert _classify_quadrant(20, 42, prev="3") == "3"
assert _classify_quadrant(20, 46, prev="3") == "1"
assert _classify_quadrant(20, 38, prev="1") == "1"
assert _classify_quadrant(20, 34, prev="1") == "3"
def test_state_axis_hysteresis():
assert _classify_quadrant(63, 30, prev="3") == "3"
assert _classify_quadrant(66, 30, prev="3") == "4"
assert _classify_quadrant(57, 30, prev="4") == "4"
assert _classify_quadrant(54, 30, prev="4") == "3"
assert _classify_quadrant(53, 30, prev="3") == "3"
assert _classify_quadrant(56, 30, prev="3") == "4"
assert _classify_quadrant(47, 30, prev="4") == "4"
assert _classify_quadrant(44, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip():
for quadrant in ("1", "2", "3", "4"):
assert _classify_quadrant(60, 60, prev=quadrant) == quadrant
assert _classify_quadrant(QUAD_X_DIV, QUAD_Y_DIV, prev=quadrant) == quadrant
def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():
@@ -1,285 +0,0 @@
"""Regression: scanner must not headline the most distant (max raw R:R) level.
Historical bug: provisional candidate pick used max R:R / quality only. Production
headline is probability-based primary after enhance_trade_setup near levels
with real reach-probability beat far lotteries.
**Validates: Requirements 1.1, 1.3, 1.4, 2.1, 2.3, 2.4**
"""
from __future__ import annotations
from datetime import date, timedelta
import pytest
from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.services.rr_scanner_service import scan_ticker
# ---------------------------------------------------------------------------
# Session fixture that allows scan_ticker to commit
# ---------------------------------------------------------------------------
# The default db_session fixture wraps in session.begin() which conflicts
# with scan_ticker's internal commit(). We use a plain session instead.
@pytest.fixture
async def scan_session() -> AsyncSession:
"""Provide a DB session compatible with scan_ticker (which commits)."""
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ohlcv_bars(
ticker_id: int,
num_bars: int = 20,
base_close: float = 100.0,
) -> list[OHLCVRecord]:
"""Generate realistic OHLCV bars with small daily variation.
Produces bars where close base_close, with enough range for ATR
computation (needs >= 15 bars). The ATR will be roughly 2.0.
"""
bars: list[OHLCVRecord] = []
start = date(2024, 1, 1)
for i in range(num_bars):
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
bars.append(OHLCVRecord(
ticker_id=ticker_id,
date=start + timedelta(days=i),
open=close - 0.3,
high=close + 1.0,
low=close - 1.0,
close=close,
volume=100_000,
))
return bars
# ---------------------------------------------------------------------------
# Deterministic test: strong-near vs weak-far (long setup)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession):
"""With a strong nearby resistance and a weak distant resistance, the
probability primary should be the nearby level NOT the far lottery.
"""
ticker = Ticker(symbol="EXPLR")
scan_session.add(ticker)
await scan_session.flush()
# 20 bars closing around 100
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
scan_session.add_all(bars)
# With ATR=2.0 and multiplier=1.5, risk=3.0.
# R:R threshold=1.5 → min reward=4.5 → min target=104.5
# Strong nearby resistance: price=105, strength=90 (R:R≈1.67, quality≈0.66)
near_level = SRLevel(
ticker_id=ticker.id,
price_level=105.0,
type="resistance",
strength=90,
detection_method="volume_profile",
)
# Weak distant resistance: price=130, strength=5 (R:R=10, quality≈0.58)
far_level = SRLevel(
ticker_id=ticker.id,
price_level=130.0,
type="resistance",
strength=5,
detection_method="volume_profile",
)
scan_session.add_all([near_level, far_level])
await scan_session.flush()
setups = await scan_ticker(
scan_session,
"EXPLR",
rr_threshold=1.5,
gate_levels_override=[near_level, far_level],
)
long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup"
selected_target = long_setups[0].target
# The scanner must NOT pick the most distant level (130)
assert selected_target != pytest.approx(130.0, abs=0.01), (
"Bug: scanner picked the weak distant level (130) instead of the "
"strong nearby level (105)"
)
# Probability primary should pick the strong nearby level
assert selected_target == pytest.approx(105.0, abs=0.01)
primaries = [t for t in long_setups[0].targets if t.get("is_primary")]
assert len(primaries) == 1
assert primaries[0]["price"] == pytest.approx(105.0, abs=0.01)
# ---------------------------------------------------------------------------
# Deterministic test: strong-near vs weak-far (short setup)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_short_prefers_strong_near_over_weak_far(scan_session: AsyncSession):
"""Short-side mirror: strong nearby support should be preferred over
weak distant support.
"""
ticker = Ticker(symbol="EXPLS")
scan_session.add(ticker)
await scan_session.flush()
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
scan_session.add_all(bars)
# With ATR=2.0 and multiplier=1.5, risk=3.0.
# R:R threshold=1.5 → min reward=4.5 → min target below 95.5
# Strong nearby support: price=95, strength=85 (R:R≈1.67, quality≈0.64)
near_level = SRLevel(
ticker_id=ticker.id,
price_level=95.0,
type="support",
strength=85,
detection_method="pivot_point",
)
# Weak distant support: price=70, strength=5 (R:R=10, quality≈0.58)
far_level = SRLevel(
ticker_id=ticker.id,
price_level=70.0,
type="support",
strength=5,
detection_method="pivot_point",
)
scan_session.add_all([near_level, far_level])
await scan_session.flush()
setups = await scan_ticker(
scan_session,
"EXPLS",
rr_threshold=1.5,
gate_levels_override=[near_level, far_level],
)
short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup"
selected_target = short_setups[0].target
assert selected_target != pytest.approx(70.0, abs=0.01), (
"Bug: scanner picked the weak distant level (70) instead of the "
"strong nearby level (95)"
)
assert selected_target == pytest.approx(95.0, abs=0.01)
# ---------------------------------------------------------------------------
# Hypothesis property test: selection is NOT always the most distant level
# ---------------------------------------------------------------------------
@st.composite
def strong_near_weak_far_pair(draw: st.DrawFn) -> dict:
"""Generate a (strong-near, weak-far) resistance pair above entry=100.
Guarantees:
- near_price < far_price (both above entry)
- near_strength >> far_strength
- Both meet the R:R threshold of 1.5 given typical ATR 2 risk 3
"""
# Near level: 515 above entry (R:R ≈ 1.75.0 with risk≈3)
near_dist = draw(st.floats(min_value=5.0, max_value=15.0))
near_strength = draw(st.integers(min_value=70, max_value=100))
# Far level: 2560 above entry (R:R ≈ 8.320 with risk≈3)
far_dist = draw(st.floats(min_value=25.0, max_value=60.0))
far_strength = draw(st.integers(min_value=1, max_value=15))
return {
"near_price": 100.0 + near_dist,
"near_strength": near_strength,
"far_price": 100.0 + far_dist,
"far_strength": far_strength,
}
@pytest.mark.asyncio
@given(pair=strong_near_weak_far_pair())
@settings(
max_examples=15,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
async def test_property_scanner_does_not_always_pick_most_distant(
pair: dict,
scan_session: AsyncSession,
):
"""**Validates: Requirements 1.1, 1.3, 1.4, 2.1, 2.3, 2.4**
Property: when a strong nearby resistance exists alongside a weak distant
resistance, the scanner does NOT always select the most distant level.
On unfixed code this would fail for every example because max-R:R always
picks the farthest level.
"""
from tests.conftest import _test_engine, _test_session_factory
# Each hypothesis example needs a fresh DB state
async with _test_engine.begin() as conn:
from app.database import Base
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with _test_session_factory() as session:
ticker = Ticker(symbol="PROP")
session.add(ticker)
await session.flush()
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
session.add_all(bars)
near_level = SRLevel(
ticker_id=ticker.id,
price_level=pair["near_price"],
type="resistance",
strength=pair["near_strength"],
detection_method="volume_profile",
)
far_level = SRLevel(
ticker_id=ticker.id,
price_level=pair["far_price"],
type="resistance",
strength=pair["far_strength"],
detection_method="volume_profile",
)
session.add_all([near_level, far_level])
await session.commit()
setups = await scan_ticker(
session,
"PROP",
rr_threshold=1.5,
gate_levels_override=[near_level, far_level],
)
long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup"
selected_target = long_setups[0].target
most_distant = round(pair["far_price"], 4)
# The fixed scanner should prefer the strong nearby level, not the
# most distant weak one.
assert selected_target != pytest.approx(most_distant, abs=0.01), (
f"Bug: scanner picked the most distant level ({most_distant}) "
f"with strength={pair['far_strength']} over the nearby level "
f"({round(pair['near_price'], 4)}) with strength={pair['near_strength']}"
)
-375
View File
@@ -1,375 +0,0 @@
"""Fix-checking tests for R:R scanner probability-based primary selection.
Verify that after enhance_trade_setup the headline target is the most likely
worthwhile primary (R:R + probability floors), for both long and short setups.
The pre-enhance quality loop only seeds a provisional target.
**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
"""
from __future__ import annotations
from datetime import date, timedelta
import pytest
from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.services.rr_scanner_service import scan_ticker
def _assert_primary_is_most_likely_worthwhile(setup) -> None:
"""Headline = starred primary = max(probability, rr) among floor-clearing targets."""
targets = setup.targets
assert targets, "expected generated targets"
primaries = [t for t in targets if t.get("is_primary")]
assert len(primaries) == 1, "exactly one primary target expected"
primary = primaries[0]
assert setup.target == pytest.approx(primary["price"], abs=0.01)
# Mirrors recommendation_service._select_primary_target floors.
worthwhile = [
t for t in targets
if float(t["rr_ratio"]) >= 1.5 and float(t["probability"]) >= 20.0
]
pool = worthwhile or targets
best = max(pool, key=lambda t: (t["probability"], t["rr_ratio"]))
assert primary["price"] == pytest.approx(best["price"], abs=0.01)
# ---------------------------------------------------------------------------
# Session fixture (plain session, not wrapped in begin())
# ---------------------------------------------------------------------------
@pytest.fixture
async def scan_session() -> AsyncSession:
"""Provide a DB session compatible with scan_ticker (which commits)."""
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ohlcv_bars(
ticker_id: int,
num_bars: int = 20,
base_close: float = 100.0,
) -> list[OHLCVRecord]:
"""Generate OHLCV bars closing around base_close with ATR ≈ 2.0."""
bars: list[OHLCVRecord] = []
start = date(2024, 1, 1)
for i in range(num_bars):
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
bars.append(OHLCVRecord(
ticker_id=ticker_id,
date=start + timedelta(days=i),
open=close - 0.3,
high=close + 1.0,
low=close - 1.0,
close=close,
volume=100_000,
))
return bars
# ---------------------------------------------------------------------------
# Hypothesis strategy: multiple resistance levels above entry for longs
# ---------------------------------------------------------------------------
@st.composite
def long_candidate_levels(draw: st.DrawFn) -> list[dict]:
"""Generate 2-5 resistance levels above entry_price=100.
All levels meet the R:R threshold of 1.5 given ATR2, risk3,
so min reward=4.5, min target=104.5.
"""
num_levels = draw(st.integers(min_value=2, max_value=5))
levels = []
for _ in range(num_levels):
# Distance from entry: 5 to 50 (all above 4.5 threshold)
distance = draw(st.floats(min_value=5.0, max_value=50.0))
strength = draw(st.integers(min_value=0, max_value=100))
levels.append({
"price": 100.0 + distance,
"strength": strength,
})
return levels
@st.composite
def short_candidate_levels(draw: st.DrawFn) -> list[dict]:
"""Generate 2-5 support levels below entry_price=100.
All levels meet the R:R threshold of 1.5 given ATR2, risk3,
so min reward=4.5, max target=95.5.
"""
num_levels = draw(st.integers(min_value=2, max_value=5))
levels = []
for _ in range(num_levels):
# Distance below entry: 5 to 50 (all above 4.5 threshold)
distance = draw(st.floats(min_value=5.0, max_value=50.0))
strength = draw(st.integers(min_value=0, max_value=100))
levels.append({
"price": 100.0 - distance,
"strength": strength,
})
return levels
# ---------------------------------------------------------------------------
# Property test: long setup selects probability-based primary
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@given(levels=long_candidate_levels())
@settings(
max_examples=20,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
async def test_property_long_selects_probability_primary(
levels: list[dict],
scan_session: AsyncSession,
):
"""**Validates: Requirements 2.1, 2.3, 2.4**
Property: when multiple resistance levels meet the R:R threshold,
the headline after enhance is the probability-based primary.
"""
from tests.conftest import _test_engine, _test_session_factory
from app.database import Base
# Fresh DB state per hypothesis example
async with _test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with _test_session_factory() as session:
ticker = Ticker(symbol="FIXL")
session.add(ticker)
await session.flush()
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
session.add_all(bars)
sr_levels = []
for lv in levels:
sr_levels.append(SRLevel(
ticker_id=ticker.id,
price_level=lv["price"],
type="resistance",
strength=lv["strength"],
detection_method="volume_profile",
))
session.add_all(sr_levels)
await session.commit()
setups = await scan_ticker(
session,
"FIXL",
rr_threshold=1.5,
gate_levels_override=sr_levels,
)
long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup"
_assert_primary_is_most_likely_worthwhile(long_setups[0])
# ---------------------------------------------------------------------------
# Property test: short setup selects probability-based primary
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@given(levels=short_candidate_levels())
@settings(
max_examples=20,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
async def test_property_short_selects_probability_primary(
levels: list[dict],
scan_session: AsyncSession,
):
"""**Validates: Requirements 2.2, 2.3, 2.4**
Property: when multiple support levels meet the R:R threshold,
the headline after enhance is the probability-based primary.
"""
from tests.conftest import _test_engine, _test_session_factory
from app.database import Base
# Fresh DB state per hypothesis example
async with _test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with _test_session_factory() as session:
ticker = Ticker(symbol="FIXS")
session.add(ticker)
await session.flush()
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
session.add_all(bars)
sr_levels = []
for lv in levels:
sr_levels.append(SRLevel(
ticker_id=ticker.id,
price_level=lv["price"],
type="support",
strength=lv["strength"],
detection_method="pivot_point",
))
session.add_all(sr_levels)
await session.commit()
setups = await scan_ticker(
session,
"FIXS",
rr_threshold=1.5,
gate_levels_override=sr_levels,
)
short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup"
_assert_primary_is_most_likely_worthwhile(short_setups[0])
# ---------------------------------------------------------------------------
# Deterministic test: 3 levels with known quality scores (long)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_deterministic_long_three_levels(scan_session: AsyncSession):
"""**Validates: Requirements 2.1, 2.3, 2.4**
Concrete example with 3 resistance levels of known quality scores.
Entry=100, ATR2, risk3.
Level A: price=105, strength=90 rr=5/31.67, dist=5
quality = 0.35*(1.67/10) + 0.35*(90/100) + 0.30*(1-5/100)
= 0.35*0.167 + 0.35*0.9 + 0.30*0.95
= 0.0585 + 0.315 + 0.285 = 0.6585
Level B: price=112, strength=50 rr=12/3=4.0, dist=12
quality = 0.35*(4/10) + 0.35*(50/100) + 0.30*(1-12/100)
= 0.35*0.4 + 0.35*0.5 + 0.30*0.88
= 0.14 + 0.175 + 0.264 = 0.579
Level C: price=130, strength=10 rr=30/3=10.0, dist=30
quality = 0.35*(10/10) + 0.35*(10/100) + 0.30*(1-30/100)
= 0.35*1.0 + 0.35*0.1 + 0.30*0.7
= 0.35 + 0.035 + 0.21 = 0.595
Expected winner: Level A (quality=0.6585)
"""
ticker = Ticker(symbol="DET3L")
scan_session.add(ticker)
await scan_session.flush()
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
scan_session.add_all(bars)
level_a = SRLevel(
ticker_id=ticker.id, price_level=105.0, type="resistance",
strength=90, detection_method="volume_profile",
)
level_b = SRLevel(
ticker_id=ticker.id, price_level=112.0, type="resistance",
strength=50, detection_method="volume_profile",
)
level_c = SRLevel(
ticker_id=ticker.id, price_level=130.0, type="resistance",
strength=10, detection_method="volume_profile",
)
scan_session.add_all([level_a, level_b, level_c])
await scan_session.flush()
setups = await scan_ticker(
scan_session,
"DET3L",
rr_threshold=1.5,
gate_levels_override=[level_a, level_b, level_c],
)
long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup"
_assert_primary_is_most_likely_worthwhile(long_setups[0])
# Near/strong level A wins on reach-probability over far lottery C.
assert long_setups[0].target == pytest.approx(105.0, abs=0.01), (
f"Expected primary=105.0 (near, high reach-prob), got {long_setups[0].target}"
)
# ---------------------------------------------------------------------------
# Deterministic test: 3 levels with known quality scores (short)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_deterministic_short_three_levels(scan_session: AsyncSession):
"""**Validates: Requirements 2.2, 2.3, 2.4**
Concrete example with 3 support levels of known quality scores.
Entry=100, ATR2, risk3.
Level A: price=95, strength=85 rr=5/31.67, dist=5
quality = 0.35*(1.67/10) + 0.35*(85/100) + 0.30*(1-5/100)
= 0.0585 + 0.2975 + 0.285 = 0.641
Level B: price=88, strength=45 rr=12/3=4.0, dist=12
quality = 0.35*(4/10) + 0.35*(45/100) + 0.30*(1-12/100)
= 0.14 + 0.1575 + 0.264 = 0.5615
Level C: price=70, strength=8 rr=30/3=10.0, dist=30
quality = 0.35*(10/10) + 0.35*(8/100) + 0.30*(1-30/100)
= 0.35 + 0.028 + 0.21 = 0.588
Expected winner: Level A (quality=0.641)
"""
ticker = Ticker(symbol="DET3S")
scan_session.add(ticker)
await scan_session.flush()
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
scan_session.add_all(bars)
level_a = SRLevel(
ticker_id=ticker.id, price_level=95.0, type="support",
strength=85, detection_method="pivot_point",
)
level_b = SRLevel(
ticker_id=ticker.id, price_level=88.0, type="support",
strength=45, detection_method="pivot_point",
)
level_c = SRLevel(
ticker_id=ticker.id, price_level=70.0, type="support",
strength=8, detection_method="pivot_point",
)
scan_session.add_all([level_a, level_b, level_c])
await scan_session.flush()
setups = await scan_ticker(
scan_session,
"DET3S",
rr_threshold=1.5,
gate_levels_override=[level_a, level_b, level_c],
)
short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup"
_assert_primary_is_most_likely_worthwhile(short_setups[0])
assert short_setups[0].target == pytest.approx(95.0, abs=0.01), (
f"Expected primary=95.0 (near, high reach-prob), got {short_setups[0].target}"
)
@@ -23,6 +23,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sec_filing_gap import SecFilingGap
from app.models.settings import SystemSetting
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
@@ -513,6 +515,45 @@ async def test_get_trade_setups_excludes_stale_rows(db_session: AsyncSession):
assert stale_rows == []
@pytest.mark.asyncio
async def test_get_trade_setups_hides_active_sec_filing_gap(
db_session: AsyncSession,
):
now = datetime.now(timezone.utc)
ticker = Ticker(symbol="SECWAIT", cik="0000000042")
db_session.add(ticker)
await db_session.flush()
db_session.add_all([
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
SecFilingGap(
cik=ticker.cik,
accession="0000000042-26-000001",
form="10-Q",
index_date=date.today(),
reason="not_in_companyfacts",
first_seen_at=now,
last_attempted_at=now,
),
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=97.0,
target=109.0,
rr_ratio=3.0,
composite_score=70.0,
confidence_score=80.0,
detected_at=now,
),
])
await db_session.flush()
assert await get_trade_setups(db_session, symbol="SECWAIT") == []
@pytest.mark.asyncio
async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
db_session: AsyncSession,
+58
View File
@@ -108,3 +108,61 @@ async def test_scan_error_does_not_stop_later_tickers(session, monkeypatch):
await rr_scanner_service.scan_all_tickers(session)
assert scanned == ["AAA", "BBB"]
async def test_scan_skips_ticker_with_incomplete_sec_fundamentals(
session, monkeypatch
):
ticker = Ticker(symbol="BLOCKED", cik="0000000001")
session.add(ticker)
await session.commit()
async def _blocked(db):
return {ticker.id}
async def _unexpected_scan(*args, **kwargs):
raise AssertionError("fundamentals-incomplete ticker was scanned")
monkeypatch.setattr(
rr_scanner_service.fundamentals_quality_service,
"blocked_ticker_ids",
_blocked,
)
monkeypatch.setattr(rr_scanner_service, "scan_ticker", _unexpected_scan)
assert await rr_scanner_service.scan_all_tickers(session) == []
async def test_scan_quality_failure_blocks_closed_and_emits_event(
session, monkeypatch
):
session.add(Ticker(symbol="BLOCKED"))
await session.commit()
async def _boom(db):
raise ValueError("bad quality metadata")
async def _unexpected_scan(*args, **kwargs):
raise AssertionError("ticker was scanned without a quality decision")
events: list[dict] = []
async def _capture_event(**kwargs):
events.append(kwargs)
monkeypatch.setattr(
rr_scanner_service.fundamentals_quality_service,
"blocked_ticker_ids",
_boom,
)
monkeypatch.setattr(rr_scanner_service, "scan_ticker", _unexpected_scan)
monkeypatch.setattr(
rr_scanner_service.system_event_service,
"log_event_standalone",
_capture_event,
)
assert await rr_scanner_service.scan_all_tickers(session) == []
assert [event["code"] for event in events] == [
"fundamentals_quality_unavailable"
]
+159
View File
@@ -5,19 +5,24 @@ from types import SimpleNamespace
import pytest
from app.scheduler import (
_DAILY_PIPELINE_STEPS,
_NEAR_CLOSE_PIPELINE_STEPS,
_consume_backtest_options,
_consume_backtest_target_model,
_parse_frequency,
_resume_tickers,
_last_successful,
_run_shadow_import,
collect_fundamentals,
run_fundamentals_parity_report,
run_sec_fundamentals_import,
configure_scheduler,
get_job_runtime_snapshot,
queue_backtest_options,
queue_backtest_target_model,
scheduler,
)
from app.services.data_import import STATUS_DEFERRED
def test_manual_backtest_target_model_is_one_shot():
@@ -40,6 +45,14 @@ def test_manual_backtest_options_are_one_shot_and_default_back_to_weekly():
assert _consume_backtest_options() == ("production_gtl", "weekly")
def test_only_near_close_fetch_skips_redundant_sr_refresh():
assert dict(_DAILY_PIPELINE_STEPS)["data_collector"] == "collect_ohlcv"
assert (
dict(_NEAR_CLOSE_PIPELINE_STEPS)["data_collector"]
== "collect_ohlcv_for_scan"
)
class TestParseFrequency:
def test_hourly(self):
assert _parse_frequency("hourly") == {"hours": 1}
@@ -168,6 +181,41 @@ class _SessionContext:
return None
class TestFundamentalCollector:
@staticmethod
def _session_factory():
return _SessionContext()
async def test_skips_legacy_provider_when_cutover_is_active(self, monkeypatch):
async def enabled(db, job_name):
return True
async def cutover_enabled(db):
return True
async def unexpected_ticker_lookup(db):
raise AssertionError("legacy ticker lookup must not run after cutover")
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.is_enabled",
cutover_enabled,
)
monkeypatch.setattr(
"app.scheduler._get_fundamental_priority_tickers",
unexpected_ticker_lookup,
)
await collect_fundamentals()
runtime = get_job_runtime_snapshot("fundamental_collector")
assert runtime["status"] == "skipped"
assert runtime["processed"] == 0
assert runtime["total"] == 0
assert runtime["message"] == "SEC + Dolt fundamentals cutover is active"
class TestShadowImportJobs:
@staticmethod
def _session_factory():
@@ -213,6 +261,28 @@ class TestShadowImportJobs:
assert runtime["processed"] == 0
assert runtime["message"] == "validation failed"
async def test_deferred_run_is_visible_without_error_status(self, monkeypatch):
async def enabled(db, job_name):
return True
async def imported(importer):
return SimpleNamespace(
status=STATUS_DEFERRED,
revision="abcdef1234567890",
error_details="Company Facts publication lag; retrying",
)
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported)
await _run_shadow_import("sec_fundamentals_import", object())
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == STATUS_DEFERRED
assert runtime["processed"] == 0
assert runtime["message"] == "Company Facts publication lag; retrying"
async def test_source_lock_surfaces_skipped(self, monkeypatch):
async def enabled(db, job_name):
return True
@@ -247,6 +317,95 @@ class TestShadowImportJobs:
assert runtime["status"] == "skipped"
assert runtime["message"] == "Disabled"
async def test_sec_failure_still_runs_activated_local_refresh(self, monkeypatch):
calls = []
async def enabled(db, job_name):
return True
async def unavailable(importer):
raise RuntimeError("SEC unavailable")
async def refreshed(db):
calls.append(db)
return {
"enabled": True,
"refreshed": 511,
"score_inputs_changed": 2,
"dimension_scores_staled": 2,
"composite_scores_staled": 2,
}
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", unavailable)
monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
refreshed,
)
await run_sec_fundamentals_import()
assert len(calls) == 1
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "error"
assert runtime["message"] == "SEC unavailable"
async def test_sec_success_surfaces_activated_refresh_summary(self, monkeypatch):
async def enabled(db, job_name):
return True
async def imported(importer):
return SimpleNamespace(
status="no_op", revision="abcdef1234567890", error_details=None
)
async def refreshed(db):
return {
"enabled": True,
"refreshed": 511,
"score_inputs_changed": 2,
"dimension_scores_staled": 2,
"composite_scores_staled": 2,
}
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported)
monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
refreshed,
)
await run_sec_fundamentals_import()
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "completed"
assert runtime["message"] == (
"no_op · abcdef123456 · cache 511 · 2 score inputs changed"
)
async def test_disabled_sec_job_does_not_run_local_refresh(self, monkeypatch):
async def disabled(db, job_name):
return False
async def should_not_run(*args, **kwargs):
raise AssertionError("disabled SEC job ran work")
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
monkeypatch.setattr("app.scheduler.run_import", should_not_run)
monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
should_not_run,
)
await run_sec_fundamentals_import()
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "skipped"
assert runtime["message"] == "Disabled"
async def test_fundamentals_parity_job_surfaces_report_summary(monkeypatch):
async def enabled(db, job_name):
+63
View File
@@ -207,6 +207,69 @@ async def test_only_404_is_treated_as_missing():
assert await c.latest_index_date(today=date(2026, 7, 22)) is None
# The two shapes a real SEC 403 takes (captured live 2026-07-30). /Archives is
# S3-backed with no ListBucket grant, so an ABSENT file comes back as S3's
# AccessDenied XML; a genuine fair-access rejection is the WAF interstitial.
S3_ACCESS_DENIED = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<Error><Code>AccessDenied</Code><Message>Access Denied</Message>"
"<RequestId>5AWQBRAEX3NPAPHB</RequestId><HostId>MHFbU0a3k0ER</HostId></Error>"
)
WAF_HTML = (
"<!DOCTYPE html><html><head><title>SEC.gov | Your Request Originates from "
"an Undeclared Automated Tool</title></head><body>...</body></html>"
)
def _forbidden(body: str, content_type: str) -> httpx.Response:
return httpx.Response(
403, content=body.encode(), headers={"Content-Type": content_type}
)
async def test_archives_access_denied_is_absent_not_forbidden():
# SEC publishes no daily index on weekends, and the bucket reports the absent
# key as 403/AccessDenied. Treating that as fatal wedged the importer on the
# first Saturday of an incremental walk (2026-07-25); it must read as "missing".
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("QTR2/index.json"):
return httpx.Response(200, json={"directory": {"item": [{"name": "form.20260630.idx"}]}})
return _forbidden(S3_ACCESS_DENIED, "application/xml")
def client() -> SecClient:
return SecClient(
transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=0
)
async with client() as c:
assert await c.daily_index(date(2026, 7, 25)) == []
async with client() as c:
# QTR3 absent → the previous-quarter fallback now actually fires.
assert await c.latest_index_date(today=date(2026, 7, 22)) == date(2026, 6, 30)
async def test_archives_waf_rejection_stays_forbidden():
# A real UA/pattern rejection is served for files that DO exist — never
# downgrade it, or a blocked run would look like an empty index.
def handler(request: httpx.Request) -> httpx.Response:
return _forbidden(WAF_HTML, "text/html")
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0) as c:
with pytest.raises(SecForbiddenError):
await c.daily_index(date(2026, 7, 21))
async def test_access_denied_outside_archives_stays_forbidden():
# The downgrade is gated on the Archives prefix; data.sec.gov is not S3-backed.
def handler(request: httpx.Request) -> httpx.Response:
return _forbidden(S3_ACCESS_DENIED, "application/xml")
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0) as c:
with pytest.raises(SecForbiddenError):
await c.companyfacts(320193)
async def test_fair_access_validation_on_real_client():
# Placeholder email rejected.
with pytest.raises(SecError):
+268
View File
@@ -270,3 +270,271 @@ async def test_live_apple_parse_invariants():
# shares cover-date differs from period_end
latest = max(rows, key=lambda r: r.period_end)
assert latest.shares_outstanding_date != latest.period_end
# -- revenue concept coverage (A5 parity findings) ---------------------------
def _one_filing(concepts: dict, *, start: str, end: str, fp: str):
"""A single 10-Q whose facts are the given {concept: value} at one YTD span."""
facts = {
name: {"units": {"USD": [_dur(start, end, val, "X", fp=fp)]}}
for name, val in concepts.items()
}
companyfacts = {"cik": 19617, "facts": {"us-gaap": facts}}
filings = {
"X": FilingMeta(
date.fromisoformat(end), date(2026, 5, 1), datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q"
)
}
return parse_snapshots(companyfacts, filings, {"X"})
def test_revenue_reads_banks_total_revenue_tag():
# JPM/GS/WFC tag RevenuesNetOfInterestExpense in every 10-Q and never (or
# only annually) `Revenues` -- previously null, so revenue growth was too.
res = _one_filing(
{"RevenuesNetOfInterestExpense": 49836}, start="2026-01-01", end="2026-03-31", fp="Q1"
)
assert res.rows[0].revenue == 49836
def test_revenue_reads_including_assessed_tax_variant():
# ARE/KHC tag only the Including variant.
res = _one_filing(
{"RevenueFromContractWithCustomerIncludingAssessedTax": 671},
start="2026-01-01", end="2026-03-31", fp="Q1",
)
assert res.rows[0].revenue == 671
def test_revenue_concept_priority_is_unchanged_by_the_added_tags():
# The new entries are appended, so any issuer that already resolved keeps
# the same concept -- only issuers that resolved to nothing gain a value.
res = _one_filing(
{
"RevenueFromContractWithCustomerExcludingAssessedTax": 100,
"RevenueFromContractWithCustomerIncludingAssessedTax": 110,
"RevenuesNetOfInterestExpense": 120,
"Revenues": 130,
},
start="2026-01-01", end="2026-03-31", fp="Q1",
)
assert res.rows[0].revenue == 100
def test_four_four_five_q3_ytd_span_is_accepted():
# A 12/12/12/16-week filer's YTD-Q3 is 36 weeks = 251 days (COST 2026 Q3),
# which missed the old 20-day tolerance around 273 by ~2 and dropped Q3
# every year -- breaking the quarter chain and nulling TTM and YoY.
res = _one_filing(
{"RevenueFromContractWithCustomerExcludingAssessedTax": 207431},
start="2025-09-01", end="2026-05-10", fp="Q3",
)
assert (date(2026, 5, 10) - date(2025, 9, 1)).days == 251
assert res.rows[0].revenue == 207431
def test_eps_falls_back_to_continuing_operations_variant():
# REG tags only this variant on every filing; FCX tags it in its 10-K while
# using EarningsPerShareDiluted in its 10-Qs.
companyfacts = {
"cik": 910606,
"facts": {"us-gaap": {"IncomeLossFromContinuingOperationsPerDilutedShare": {
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 1.81, "X", fp="Q1")]}
}}},
}
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"})
assert res.rows[0].diluted_eps == 1.81
def test_eps_concept_priority_is_unchanged_by_the_added_tag():
companyfacts = {
"cik": 831259,
"facts": {"us-gaap": {
"EarningsPerShareDiluted": {
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 0.61, "X", fp="Q1")]}},
"IncomeLossFromContinuingOperationsPerDilutedShare": {
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 0.75, "X", fp="Q1")]}},
}},
}
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"})
assert res.rows[0].diluted_eps == 0.61
# -- period identity from the fiscal calendar, not SEC's fy/fp ---------------
from app.services.sec_facts_parser import _period_identity # noqa: E402
def _meta(end: str, form: str = "10-Q") -> FilingMeta:
d = date.fromisoformat(end)
return FilingMeta(d, d, datetime(d.year, d.month, d.day, tzinfo=UTC), form)
def test_a_10q_is_never_labelled_fy():
# BXP: a 10-Q for period end 2026-03-31 carried fy/fp saying "2026 FY", which
# collided with the real annual row and measured a 90-day fact against the
# 365-day FY expectation.
fy, fp = _period_identity(_meta("2026-03-31"), "1231")
assert (fy, fp) == (2026, "Q1")
def test_december_filer_years_do_not_collide():
# FRT: two 10-Ks, ending 2024-12-31 and 2025-12-31, both labelled "2024 FY".
assert _period_identity(_meta("2024-12-31", "10-K"), "1231") == (2024, "FY")
assert _period_identity(_meta("2025-12-31", "10-K"), "1231") == (2025, "FY")
def test_january_year_end_groups_its_quarters():
# CRM/CRWD/WDAY: the year ending 2026-01-31 and its own quarters must share a
# fiscal year, and must not collide with the year ending 2025-01-31.
assert _period_identity(_meta("2026-01-31", "10-K"), "0131") == (2026, "FY")
assert _period_identity(_meta("2025-01-31", "10-K"), "0131") == (2025, "FY")
assert _period_identity(_meta("2025-04-30"), "0131") == (2026, "Q1")
assert _period_identity(_meta("2025-07-31"), "0131") == (2026, "Q2")
assert _period_identity(_meta("2025-10-31"), "0131") == (2026, "Q3")
def test_mid_year_end_orders_correctly():
# STX: the year ending 2025-06-27 was labelled "2027 FY" and sorted after
# quarters that precede it.
assert _period_identity(_meta("2025-06-27", "10-K"), "0627") == (2025, "FY")
assert _period_identity(_meta("2025-10-03"), "0627") == (2026, "Q1")
assert _period_identity(_meta("2026-01-02"), "0627") == (2026, "Q2")
assert _period_identity(_meta("2026-04-03"), "0627") == (2026, "Q3")
def test_four_four_five_quarters_place_correctly():
# COST: a 12/12/12/16-week year leaves Q3 112 days from the year end, not 91.
assert _period_identity(_meta("2025-11-23"), "0830") == (2026, "Q1")
assert _period_identity(_meta("2026-02-15"), "0830") == (2026, "Q2")
assert _period_identity(_meta("2026-05-10"), "0830") == (2026, "Q3")
assert _period_identity(_meta("2026-08-30", "10-K"), "0830") == (2026, "FY")
def test_year_end_crossing_january_still_groups_one_year():
# DPZ (fiscalYearEnd 0102): the label shifts by one against Domino's own
# naming, which is fine -- a year and its quarters must simply agree.
year, _ = _period_identity(_meta("2025-12-28", "10-K"), "0102")
assert (year, "FY") == _period_identity(_meta("2025-12-28", "10-K"), "0102")
assert _period_identity(_meta("2025-03-23"), "0102") == (year, "Q1")
assert _period_identity(_meta("2025-06-15"), "0102") == (year, "Q2")
assert _period_identity(_meta("2025-09-07"), "0102") == (year, "Q3")
def test_missing_fiscal_calendar_falls_back_to_filing_context():
assert _period_identity(_meta("2026-03-31"), None) == (None, None)
# ...and parse_snapshots then uses the fy/fp path, preserving old behaviour.
res = parse_snapshots(COMPANYFACTS, FILINGS, {"B"})
assert (res.rows[0].fiscal_year, res.rows[0].fiscal_period) == (2026, "Q2")
def test_eps_falls_back_to_basic_only_when_no_diluted_variant_exists():
# PPL's 2026 Q1 tags no diluted EPS at all, only basic -- one missing period
# broke the quarter chain and nulled TTM.
companyfacts = {
"cik": 922224,
"facts": {"us-gaap": {"EarningsPerShareBasic": {
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 0.60, "X", fp="Q1")]}
}}},
}
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].diluted_eps == 0.60
def test_diluted_still_wins_over_basic_when_both_present():
companyfacts = {
"cik": 320193,
"facts": {"us-gaap": {
"EarningsPerShareDiluted": {
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 1.36, "X", fp="Q1")]}},
"EarningsPerShareBasic": {
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 1.40, "X", fp="Q1")]}},
}},
}
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].diluted_eps == 1.36
def test_weighted_average_shares_prefers_the_shortest_span():
# A 10-Q carries both the quarter's average and the YTD one. The shorter
# window sits closer to the current count, which is what market cap wants.
companyfacts = {
"cik": 1326801,
"facts": {"us-gaap": {"WeightedAverageNumberOfDilutedSharesOutstanding": {
"units": {"shares": [
_dur("2026-01-01", "2026-09-30", 2_600_000_000, "X", fp="Q3"), # YTD
_dur("2026-07-01", "2026-09-30", 2_564_000_000, "X", fp="Q3"), # quarter
]}
}}},
}
filings = {"X": FilingMeta(date(2026, 9, 30), date(2026, 11, 1),
datetime(2026, 11, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].weighted_avg_diluted_shares == 2_564_000_000
def test_weighted_average_shares_falls_back_to_the_basic_and_diluted_concept():
companyfacts = {
"cik": 1326801,
"facts": {"us-gaap": {"WeightedAverageNumberOfSharesOutstandingBasicAndDiluted": {
"units": {"shares": [_dur("2026-07-01", "2026-09-30", 500_000, "X", fp="Q3")]}
}}},
}
filings = {"X": FilingMeta(date(2026, 9, 30), date(2026, 11, 1),
datetime(2026, 11, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].weighted_avg_diluted_shares == 500_000
# -- fiscal year end resolution (submissions.fiscalYearEnd is unreliable) -----
from app.services.sec_facts_parser import resolve_fiscal_year_end # noqa: E402
def test_the_issuers_own_10k_overrides_a_wrong_declared_year_end():
# Franklin Resources declares 1231 while every 10-K ends 09-30. Trusting the
# declaration labelled its fiscal Q2 (Mar) as Q1, colliding with the real
# fiscal Q1 (Dec) and destroying the quarter chain.
filings = {
"K": _meta("2025-09-30", "10-K"),
"Q": _meta("2025-12-31"),
}
assert resolve_fiscal_year_end(filings, "1231") == "0930"
def test_declared_year_end_is_used_when_no_annual_filing_is_present():
assert resolve_fiscal_year_end({"Q": _meta("2026-03-31")}, "1231") == "1231"
assert resolve_fiscal_year_end({}, None) is None
def test_a_wrong_declared_year_end_no_longer_collides_two_periods():
"""End to end: BEN's Dec and Mar quarters must land on distinct keys."""
def _q(accn, start, end, val):
return _dur(start, end, val, accn, fp="Q1")
companyfacts = {
"cik": 38777,
"facts": {"us-gaap": {"Revenues": {"units": {"USD": [
_q("Q1", "2025-10-01", "2025-12-31", 2327), # fiscal Q1
_q("Q2", "2025-10-01", "2026-03-31", 4622), # fiscal Q2 YTD
]}}}},
}
filings = {
"K": _meta("2025-09-30", "10-K"),
"Q1": _meta("2025-12-31"),
"Q2": _meta("2026-03-31"),
}
res = parse_snapshots(companyfacts, filings, {"Q1", "Q2"}, fiscal_year_end="1231")
keys = {(r.fiscal_year, r.fiscal_period) for r in res.rows}
assert len(keys) == 2, f"periods collided on one key: {keys}"
assert keys == {(2026, "Q1"), (2026, "Q2")}
+722 -5
View File
@@ -3,6 +3,7 @@ import framework with a fake SEC client (no network)."""
from __future__ import annotations
import json
import os
import tempfile
from datetime import date, datetime, timezone
@@ -14,9 +15,20 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from app.database import Base
import app.models # noqa: F401
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.sec_filing_gap import SecFilingGap
from app.models.system_event import SystemEvent
from app.models.ticker import Ticker
from app.services.data_import import STATUS_FAILED, STATUS_PROMOTED, run_import
from app.services.sec_fundamentals_importer import SecFundamentalsImporter
from app.services.data_import import (
STATUS_DEFERRED,
STATUS_FAILED,
STATUS_PROMOTED,
run_import,
)
from app.services.sec_fundamentals_importer import (
SecFundamentalsImporter,
StagedFundamentals,
)
from app.services.sec_universe import ResolvedUniverse
@pytest.fixture
@@ -189,7 +201,7 @@ async def test_incremental_adds_only_new_filing(engine):
assert q2.fiscal_period == "Q2" and q2.revenue == 254940
async def test_consistency_gate_fails_when_facts_lag_index(engine):
async def test_consistency_gate_defers_without_alert_when_facts_lag_index(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
@@ -211,9 +223,536 @@ async def test_consistency_gate_fails_when_facts_lag_index(engine):
)
run = await run_import(_importer(incr, today=date(2026, 5, 3)), engine=engine)
assert run.status == STATUS_FAILED
assert "Company Facts" in (run.error_details or "")
assert run.status == STATUS_DEFERRED
# The gate blocks every later run until it clears, so run history still has
# to name the filing and say why it could not be resolved.
details = run.error_details or ""
assert "GHOST" in details and "not_in_companyfacts" in details
assert "2026-05-01" in details # index date the filing was seen on
summary = json.loads(run.validation_json or "{}")
assert summary["missing_xbrl_count"] == 1
assert summary["missing_xbrl"][0]["accession"] == "GHOST"
assert summary["missing_xbrl"][0]["form"] == "10-Q"
assert await _count(factory, FundamentalSnapshot) == 2 # nothing new written
assert await _count(factory, SystemEvent) == 0 # expected SEC lag does not alert
async def test_companyfacts_lag_does_not_mask_second_validation_failure():
importer = SecFundamentalsImporter(today=date(2026, 5, 3))
importer._latest_index_date = date(2026, 5, 2)
staged = StagedFundamentals(
resolved=ResolvedUniverse(),
missing_xbrl=[{
"cik": "0000320193",
"accession": "GHOST",
"form": "10-Q",
"index_date": date(2026, 5, 1),
"age_days": 2,
"reason": "not_in_companyfacts",
}],
invalid_payloads=[{
"cik": "0000789019",
"reason": "missing facts structure",
}],
)
result = await importer.validate(None, staged)
assert not result.ok
assert not result.retryable
assert len(result.messages) == 2
async def test_deferred_alert_names_aged_out_accessions_separately():
importer = SecFundamentalsImporter(today=date(2026, 5, 6))
importer._latest_index_date = date(2026, 5, 5)
staged = StagedFundamentals(
resolved=ResolvedUniverse(),
missing_xbrl=[
{
"cik": "0000320193",
"accession": "YOUNG",
"form": "10-Q",
"index_date": date(2026, 5, 5),
"age_days": 1,
"reason": "not_in_companyfacts",
},
{
"cik": "0000789019",
"accession": "AGED-OUT",
"form": "10-Q",
"index_date": date(2026, 5, 1),
"age_days": 5,
"reason": "not_in_companyfacts",
},
],
)
result = await importer.validate(None, staged)
assert result.retryable
assert len(result.messages) == 1 and "YOUNG" in result.messages[0]
assert len(result.deferred_alert_messages) == 1
assert "AGED-OUT" in result.deferred_alert_messages[0]
async def test_gate_separates_missing_submissions_from_missing_facts(engine):
"""An index row the issuer's own filing list does not carry is a different
failure from a Company-Facts lag, and must not be reported as one."""
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
incr = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)}, # submissions never lists ORPHAN
latest_index=date(2026, 5, 2),
daily={date(2026, 5, 1): [{"form": "10-Q", "cik": 320193, "accession": "ORPHAN"}]},
)
run = await run_import(_importer(incr, today=date(2026, 5, 3)), engine=engine)
assert run.status == STATUS_FAILED
assert "ORPHAN" in (run.error_details or "")
assert "not_in_submissions" in (run.error_details or "")
summary = json.loads(run.validation_json or "{}")
assert summary["missing_xbrl"][0]["reason"] == "not_in_submissions"
def _coregistrant_client(share_fact):
"""Incremental client where Q2A's facts landed in co-registrant 99999's file
instead of the filer's own — the NEE-via-FPL / DOW-via-Dow-Chemical shape."""
cf_q2 = _rev("2025-09-28", "2026-03-28", 254940, 2026, "Q2", "Q2A")
return FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={
320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1]), # filer's own: no Q2A
99999: _companyfacts([cf_q2], [share_fact], cik=99999),
},
submissions={320193: _submissions(SUB_FILINGS + [
_filing("Q2A", "10-Q", "2026-03-28", "2026-05-01", "2026-05-01T10:01:00.000Z")])},
latest_index=date(2026, 5, 2),
daily={date(2026, 5, 1): [
# one combined filing, listed by the index under both co-registrants
{"form": "10-Q", "cik": 320193, "accession": "Q2A"},
{"form": "10-Q", "cik": 99999, "accession": "Q2A"},
]},
)
async def test_recovers_facts_misfiled_under_coregistrant(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
# 14687 shares is continuous with the issuer's own history (14681 last quarter).
incr = _coregistrant_client(_shares("2026-04-17", 14687, "Q2A", 2026, "Q2"))
run = await run_import(_importer(incr, today=date(2026, 5, 3)), engine=engine)
assert run.status == STATUS_PROMOTED
summary = json.loads(run.validation_json or "{}")
assert summary["recovered_count"] == 1
assert summary["recovered_from_coregistrant"][0]["source_cik"] == "0000099999"
assert summary["missing_xbrl_count"] == 0
async with factory() as s:
q2 = (await s.execute(
select(FundamentalSnapshot).where(FundamentalSnapshot.accession == "Q2A")
)).scalar_one()
codes = (await s.execute(select(SystemEvent.code))).scalars().all()
# Stamped to the issuer that filed, NOT the co-registrant whose file it came from.
assert q2.cik == "0000320193"
assert q2.revenue == 254940 and q2.shares_outstanding == 14687
assert "coregistrant_recovery" not in codes
async def test_coregistrant_recovery_rejects_discontinuous_share_count(engine):
"""A co-registrant shell's standalone facts must never be stored as the
parent's — a token float is the signature and it has to be refused."""
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
incr = _coregistrant_client(_shares("2026-04-17", 100, "Q2A", 2026, "Q2"))
run = await run_import(_importer(incr, today=date(2026, 5, 3)), engine=engine)
assert run.status == STATUS_FAILED
assert "coregistrant_facts_rejected" in (run.error_details or "")
assert await _count(factory, FundamentalSnapshot) == 2 # nothing recovered
async def test_unresolved_filing_stops_blocking_after_retry_window(engine):
"""A filing SEC has misfiled must not wedge every later import forever."""
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
incr = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])}, # no GHOST
submissions={320193: _submissions(SUB_FILINGS + [
_filing("GHOST", "10-Q", "2026-03-28", "2026-05-01", "2026-05-01T10:01:00.000Z")])},
latest_index=date(2026, 5, 2),
daily={date(2026, 5, 1): [{"form": "10-Q", "cik": 320193, "accession": "GHOST"}]},
)
# 9 days after the index date — well past the retry window.
run = await run_import(_importer(incr, today=date(2026, 5, 10)), engine=engine)
assert run.status == STATUS_PROMOTED # promoted around it, not blocked by it
assert run.source_max_date == date(2026, 5, 2) # and the index advances
summary = json.loads(run.validation_json or "{}")
assert summary["missing_xbrl_count"] == 1 and summary["missing_xbrl_blocking"] == 0
assert await _count(factory, SecFilingGap) == 1
async with factory() as s:
events = (await s.execute(select(SystemEvent))).scalars().all()
unresolved = [e for e in events if e.code == "unresolved_filing"]
assert len(unresolved) == 1 and "GHOST" in unresolved[0].message
assert "automatic SEC retry" in unresolved[0].message
# The next scheduled run retries even though the SEC daily-index revision
# has not changed. Company Facts is a separate SEC product and may catch up
# independently, so the generic revision no-op must not suppress this work.
still_missing_run = await run_import(
_importer(incr, today=date(2026, 5, 11)),
engine=engine,
)
assert still_missing_run.status == STATUS_PROMOTED
assert still_missing_run.revision is None
assert await _count(factory, SecFilingGap) == 1
# A later normal scheduled import retries only the queued issuer. Once SEC
# publishes the accession in Company Facts, it is inserted and unblocked
# without a full-universe reparse or operator action.
cf_ghost = _rev("2025-09-28", "2026-03-28", 254940, 2026, "Q2", "GHOST")
sh_ghost = _shares("2026-04-17", 14687, "GHOST", 2026, "Q2")
healed = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={
320193: _companyfacts(
[CF_K, CF_Q1, cf_ghost],
[SH_K, SH_Q1, sh_ghost],
)
},
submissions={
320193: _submissions(SUB_FILINGS + [
_filing(
"GHOST",
"10-Q",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
)
])
},
latest_index=date(2026, 5, 2),
)
healed_run = await run_import(
_importer(healed, today=date(2026, 5, 12)),
engine=engine,
)
assert healed_run.status == STATUS_PROMOTED
assert await _count(factory, FundamentalSnapshot) == 3
assert await _count(factory, SecFilingGap) == 0
async def test_queued_gap_without_index_date_retries_without_wedging_and_escalates_once(
engine,
):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
old = datetime(2026, 4, 1, tzinfo=timezone.utc)
async with factory() as db:
db.add(SecFilingGap(
cik="0000320193",
accession="DATELESS",
form="10-Q",
index_date=None,
reason="not_in_companyfacts",
first_seen_at=old,
last_attempted_at=old,
))
await db.commit()
missing = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"DATELESS",
"10-Q",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
)
])},
latest_index=date(2026, 1, 31),
)
first = await run_import(
_importer(missing, today=date(2026, 5, 20)), engine=engine
)
second = await run_import(
_importer(missing, today=date(2026, 5, 21)), engine=engine
)
assert first.status == STATUS_PROMOTED
assert second.status == STATUS_PROMOTED
async with factory() as db:
gap = (await db.execute(select(SecFilingGap))).scalar_one()
events = (
await db.execute(
select(SystemEvent).where(SystemEvent.code == "filing_gap_aged")
)
).scalars().all()
assert gap.escalated_at is not None
assert len(events) == 1
async def test_queued_filing_reclassified_non_xbrl_is_removed(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
now = datetime.now(timezone.utc)
async with factory() as db:
db.add(SecFilingGap(
cik="0000320193",
accession="NONX",
form="10-Q/A",
index_date=date(2026, 5, 1),
reason="not_in_companyfacts",
first_seen_at=now,
last_attempted_at=now,
))
await db.commit()
client = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"NONX",
"10-Q/A",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
is_xbrl=False,
)
])},
latest_index=date(2026, 1, 31),
)
run = await run_import(_importer(client, today=date(2026, 5, 20)), engine=engine)
assert run.status == STATUS_PROMOTED
assert await _count(factory, SecFilingGap) == 0
async def test_queued_parser_skip_stays_blocked_with_actionable_reason(
engine, monkeypatch
):
from app.services.sec_facts_parser import ParseResult
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
now = datetime.now(timezone.utc)
async with factory() as db:
db.add(SecFilingGap(
cik="0000320193",
accession="BADPARSE",
form="10-Q",
index_date=date(2026, 5, 1),
reason="not_in_companyfacts",
first_seen_at=now,
last_attempted_at=now,
))
await db.commit()
bad_fact = _rev(
"2025-09-28", "2026-03-28", 254940, 2026, "Q2", "BADPARSE"
)
bad_share = _shares("2026-04-17", 14687, "BADPARSE", 2026, "Q2")
client = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={
320193: _companyfacts(
[CF_K, CF_Q1, bad_fact], [SH_K, SH_Q1, bad_share]
)
},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"BADPARSE",
"10-Q",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
)
])},
latest_index=date(2026, 1, 31),
)
def skip_parse(*args, **kwargs):
return ParseResult(skipped_filings=[{
"accession": "BADPARSE",
"reason": "unparseable",
}])
monkeypatch.setattr("app.services.sec_facts_parser.parse_snapshots", skip_parse)
run = await run_import(_importer(client, today=date(2026, 5, 20)), engine=engine)
assert run.status == STATUS_PROMOTED
async with factory() as db:
gap = (await db.execute(select(SecFilingGap))).scalar_one()
assert gap.reason == "parser_unusable"
async def test_new_parser_skip_gets_grace_then_enters_retry_queue(
engine, monkeypatch
):
from app.services.sec_facts_parser import ParseResult
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
bad_fact = _rev(
"2025-09-28", "2026-03-28", 254940, 2026, "Q2", "NEWBAD"
)
bad_share = _shares("2026-04-17", 14687, "NEWBAD", 2026, "Q2")
client = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={
320193: _companyfacts(
[CF_K, CF_Q1, bad_fact], [SH_K, SH_Q1, bad_share]
)
},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"NEWBAD",
"10-Q",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
)
])},
latest_index=date(2026, 5, 2),
daily={
date(2026, 5, 1): [{
"form": "10-Q",
"cik": 320193,
"accession": "NEWBAD",
}]
},
)
def skip_parse(*args, **kwargs):
return ParseResult(skipped_filings=[{
"accession": "NEWBAD",
"reason": "unparseable",
}])
monkeypatch.setattr("app.services.sec_facts_parser.parse_snapshots", skip_parse)
young = await run_import(
_importer(client, today=date(2026, 5, 3)), engine=engine
)
assert young.status == STATUS_DEFERRED
assert "parser_unusable" in (young.error_details or "")
assert await _count(factory, SecFilingGap) == 0
aged = await run_import(
_importer(client, today=date(2026, 5, 5)), engine=engine
)
assert aged.status == STATUS_PROMOTED
async with factory() as db:
gap = (await db.execute(select(SecFilingGap))).scalar_one()
assert gap.accession == "NEWBAD"
assert gap.reason == "parser_unusable"
async def test_validation_caps_details_but_keeps_complete_blocked_cik_set():
importer = SecFundamentalsImporter(today=date(2026, 5, 20))
importer._latest_index_date = date(2026, 5, 19)
staged = StagedFundamentals(
resolved=ResolvedUniverse(),
missing_xbrl=[
{
"cik": f"{i:010d}",
"accession": f"MISS-{i}",
"form": "10-Q",
"index_date": date(2026, 5, 1),
"age_days": 19,
"reason": "not_in_companyfacts",
}
for i in range(60)
],
no_xbrl_filings=[
{"cik": f"{i + 100:010d}", "name": f"New {i}"}
for i in range(60)
],
recovered=[
{"cik": f"{i:010d}", "accession": f"REC-{i}", "source_cik": "1"}
for i in range(60)
],
)
result = await importer.validate(None, staged)
assert len(result.summary["missing_xbrl"]) == 50
assert len(result.summary["no_xbrl_filings"]) == 50
assert len(result.summary["no_xbrl_ciks"]) == 60
assert len(result.summary["recovered_from_coregistrant"]) == 50
assert len(result.summary["setup_blocked_ciks"]) == 120
async def test_non_xbrl_amendment_skipped_not_failed(engine):
@@ -411,3 +950,181 @@ async def test_discrepancy_in_shares_is_detected_and_reported(engine):
assert k.shares_outstanding == 999.0 and k.import_run_id == 1 # immutable — not overwritten
events = (await s.execute(select(SystemEvent).where(SystemEvent.code == "snapshot_discrepancy"))).scalars().all()
assert len(events) == 1 and events[0].severity == "warning"
# --- reparse: rewriting rows a fixed parser reconstructs differently --------
# A 4-4-5 filer's YTD-Q3 span (36 weeks = 251 days). The old 20-day tolerance
# around 273 rejected it and stored revenue=None; 25 accepts it. Reparsing with
# the fixed parser is exactly the situation this mode exists for.
CF_Q3_445 = _rev("2025-09-01", "2026-05-10", 207431, 2026, "Q3", "Q3F")
SUB_445 = [_filing("Q3F", "10-Q", "2026-05-10", "2026-06-01", "2026-06-01T10:01:00.000Z")]
def _445_client():
return FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_Q3_445], [_shares("2026-05-15", 100, "Q3F", 2026, "Q3")])},
submissions={320193: _submissions(SUB_445)},
latest_index=date(2026, 6, 1),
)
async def _import_with_old_tolerance(engine, monkeypatch):
"""Seed the DB the way the pre-fix parser did: Q3 revenue rejected -> null."""
from app.services import sec_facts_parser
monkeypatch.setattr(sec_facts_parser, "_YTD_TOLERANCE_DAYS", 20)
run = await run_import(_importer(_445_client(), today=date(2026, 6, 2)), engine=engine)
monkeypatch.undo()
return run
async def test_reparse_rewrites_rows_the_fixed_parser_reads_differently(engine, monkeypatch):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
first = await _import_with_old_tolerance(engine, monkeypatch)
async with factory() as s:
stale = (await s.execute(select(FundamentalSnapshot))).scalar_one()
assert stale.revenue is None, "precondition: the old parser stored a null"
# Reparse with the current (fixed) parser. force=True because SEC has not
# changed -- the staleness is on our side, so the revision gate would no-op.
run = await run_import(
SecFundamentalsImporter(
client_factory=lambda: _445_client(), today=date(2026, 6, 2), reparse=True
),
engine=engine,
force=True,
)
assert run.status == STATUS_PROMOTED
assert '"updated": 1' in run.row_counts_json
async with factory() as s:
fixed = (await s.execute(select(FundamentalSnapshot))).scalar_one()
assert fixed.revenue == 207431 # rewritten in place
assert fixed.accession == stale.accession
assert fixed.import_run_id == run.id # rewrite is attributable
assert fixed.import_run_id != first.id
async def test_reparse_leaves_unchanged_rows_untouched(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
first = await run_import(_importer(_445_client(), today=date(2026, 6, 2)), engine=engine)
run = await run_import(
SecFundamentalsImporter(
client_factory=lambda: _445_client(), today=date(2026, 6, 2), reparse=True
),
engine=engine,
force=True,
)
assert '"updated": 0' in run.row_counts_json
async with factory() as s:
row = (await s.execute(select(FundamentalSnapshot))).scalar_one()
assert row.import_run_id == first.id # provenance preserved, no needless rewrite
async def test_without_reparse_a_differing_row_stays_immutable(engine, monkeypatch):
"""The default contract is unchanged: report the discrepancy, never mutate."""
factory = _factory(engine)
await _seed(factory, ["AAPL"])
await _import_with_old_tolerance(engine, monkeypatch)
importer = SecFundamentalsImporter(
client_factory=lambda: _445_client(), today=date(2026, 6, 2), reparse=True
)
async with _factory(engine)() as db:
await importer.detect_revision(db)
staged = await importer.stage(db)
importer.reparse = False # same staged diff, default disposition
counts = await importer.promote(db, staged, run_id=999)
await db.commit()
assert staged.discrepancies, "the diff should still be detected and reported"
assert counts["updated"] == 0
async with factory() as s:
row = (await s.execute(select(FundamentalSnapshot))).scalar_one()
assert row.revenue is None # untouched
async def test_force_bypasses_the_unchanged_revision_no_op(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
await run_import(_importer(_445_client(), today=date(2026, 6, 2)), engine=engine)
same = _importer(_445_client(), today=date(2026, 6, 2))
assert (await run_import(same, engine=engine)).status == "no_op"
forced = SecFundamentalsImporter(
client_factory=lambda: _445_client(), today=date(2026, 6, 2), reparse=True
)
assert (await run_import(forced, engine=engine, force=True)).status == STATUS_PROMOTED
# --- CIK resolution: successor registrants with no filings -----------------
async def test_issuer_with_no_xbrl_filings_is_reported_not_silent(engine):
"""XOM resolved to CIK 2115436 'ExxonMobil Holdings Corp', which has zero
filings, so it produced no snapshots and nothing said why."""
factory = _factory(engine)
await _seed(factory, ["AAPL"])
client = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K], [SH_K])},
submissions={320193: {**_submissions([]), "name": "Shell Holdings Corp"}},
latest_index=date(2026, 1, 31),
)
importer = _importer(client)
async with factory() as db:
await importer.detect_revision(db)
staged = await importer.stage(db)
result = await importer.validate(db, staged)
assert result.summary["no_xbrl_filings_count"] == 1
assert staged.no_xbrl_filings[0]["cik"] == "0000320193"
assert staged.no_xbrl_filings[0]["name"] == "Shell Holdings Corp"
async def test_cik_override_pins_a_ticker_to_the_real_filer(engine):
from app.models.settings import SystemSetting
from app.services.sec_universe import CIK_OVERRIDES_KEY, resolve_ciks
factory = _factory(engine)
await _seed(factory, ["AAPL"])
async with factory() as s:
s.add(SystemSetting(key=CIK_OVERRIDES_KEY, value='{"AAPL": 34088}'))
await s.commit()
client = FakeSecClient(
tickers={"AAPL": 320193}, # SEC points at the wrong registrant
companyfacts={}, submissions={}, latest_index=date(2026, 1, 31),
)
async with factory() as db:
resolved = await resolve_ciks(db, client)
assert resolved.symbol_to_cik["AAPL"] == 34088
assert resolved.cik_updates == [(1, "0000034088")]
async def test_malformed_cik_override_is_ignored_not_fatal(engine):
from app.models.settings import SystemSetting
from app.services.sec_universe import CIK_OVERRIDES_KEY, resolve_ciks
factory = _factory(engine)
await _seed(factory, ["AAPL"])
async with factory() as s:
s.add(SystemSetting(key=CIK_OVERRIDES_KEY, value="not json at all"))
await s.commit()
client = FakeSecClient(
tickers={"AAPL": 320193}, companyfacts={}, submissions={},
latest_index=date(2026, 1, 31),
)
async with factory() as db:
resolved = await resolve_ciks(db, client)
assert resolved.symbol_to_cik["AAPL"] == 320193 # fell back to company_tickers