Compare commits

Author SHA1 Message Date
dennisthiessenandClaude Opus 5 7fdcac3b55 docs: carry the risk-monitor wording through docs, comments and logs
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m12s
Deploy / deploy (push) Successful in 37s
Follows 5ea0785, which renamed the user-visible labels. This finishes the pass
so code, docs and operator output use one vocabulary: README (pipeline list,
route table, FRED row), the methodology doc title, .env.example and config
comments, the snapshot model / event-study / service / test docstrings, the
scheduler section headers and morning-pipeline docstring, the TopBar status
text ("bullish regime" -> "bullish trend"), and the four "Regime monitor:" log
prefixes.

Deliberately NOT changed, because "market regime" is also a standard finance
term and most occurrences are not this job: the backtest caveat "~6 months is
roughly one market regime" in backtest_service, README, BacktestPanel and every
generated reports/*.json; "a regime shift" in TrackRecordPanel; and the
capacity-bracket findings doc. Renaming those would have made the text wrong.

Also unchanged, being persisted or externally linked rather than wording: the
regime_monitor / market_regime job ids, the regime_quadrant_enabled setting key,
the /regime route, METHODOLOGY and the snapshot fields, the service/test module
filenames, and docs/research/regime-monitor-v3.md's path (referenced from commit
messages). The doc now carries a one-line note recording the old name and why
those identifiers still use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:51:49 +02:00
dennisthiessenandClaude Opus 5 5ea0785be6 refactor(ui): name the two regime jobs for what they actually do
"Market Regime" and "Regime Monitor" sat next to each other in Admin -> Jobs
(pipeline steps 4 and 5) reading as the same job. They are unrelated, and the
names had it backwards: "Market Regime" is the SPY 50/200 guard that drives the
TopBar trend dot and the counter-trend warning on setups, so it changes what a
setup shows; "Regime Monitor" is the observational AI/Tech thermometer that
explicitly feeds no trades. The more consequential job had the vaguer name.

  market_regime   "Market Regime"   -> "Market Trend (SPY)"
  regime_monitor  "Regime Monitor"  -> "AI/Tech Risk Monitor"

Display strings only. The job *ids* are persisted -- they key the pipeline step
list, cron config, runtime tracking and run history -- so they are untouched,
as is the /regime route, which keeps existing links working.

The label the admin UI renders comes from JOB_LABELS in admin_service (via
routers/jobs.py), not from the scheduler's APScheduler `name=`. Both are updated;
only the former is user-visible.

Carries the vocabulary through the rest of the surface so it does not half-land:
page title, nav ("Regime" -> "Risk"), the empty-state instruction that names the
job to run, the quadrant alert toggle, the morning-pipeline hint, and the
Telegram alert headline ("Regime quadrant change" -> "AI/Tech risk quadrant
change"). No test asserts any of these strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:48:15 +02:00
dennisthiessenandClaude Opus 5 98b41629e7 ci: lint the whole repo, and pin the rule set so it stays deterministic
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m14s
Deploy / deploy (push) Successful in 39s
Widens the lint step from `ruff check app/` to `ruff check .`, since tests/ and
scripts/ had drifted to 11 findings while unchecked (fixed in 1c6ccce).

Widening alone would have been unsafe, and the check turned up something worse
than the drift: CI installs ruff unpinned, the repo had no [tool.ruff] config,
and ruff's default rule set is not stable across releases. Local 0.15.4 reports
0 findings in app/; 0.16.2 -- what `pip install ruff` resolves to today --
reports 376. 168 of those are B008 flagging FastAPI's `Depends()` in a signature
default, which is the framework's documented idiom, not a defect. So the lint
job would have failed the deploy pipeline on untouched code at the next push,
independent of this change.

Pinning the ruff version would freeze the bug in place. Pinning the *rule set*
is the actual fix: select = ["E4", "E7", "E9", "F"] in pyproject.toml, the set
the tree was already clean under, now enforced repo-wide. The ruff version can
float freely without changing what CI enforces.

Verified both scopes against both versions with caches disabled: `ruff check .`
passes under 0.15.4 and 0.16.2. Confirmed the pin actually binds rather than
passing by luck -- a probe file using `Depends()` in a default is clean under
the committed config and reports B008 + I001 under `--isolated` 0.16.2 defaults.
Full suite still 852 passed, 1 skipped.

Adding rules is welcome; do it in pyproject.toml with the fixes in the same
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:28:26 +02:00
dennisthiessenandClaude Opus 5 1c6ccceb12 chore: make the whole tree ruff-clean, not just app/
CI only lints app/, so 11 findings had accumulated in tests/ and scripts/.

Mechanical and behaviour-neutral, but two were not auto-fixable and needed a
judgement call rather than `ruff --fix`:

- E741 in run_fip_breadth_diagnostics: `l` is the OHLCV low and is genuinely
  used, so this was a naming fix (`l` -> `lo`), not a deletion.
- F841 in the same file: `vol_ix`/`momr_ix` are assigned from a pure local
  `_index()` and never read, so removing them cannot change any output. Their
  upstream `vol_weeks`/`momr_weeks` maps *are* used further down and stay; the
  comment above `_index` was corrected to say so.

The rest are unused imports and f-strings without placeholders (literal
markdown table headers, so identical output).

Verified beyond the linter, since py_compile does not catch a removed-but-used
import: every removed symbol has zero remaining references, all scripts compile,
and the full unit suite passes (852 passed, 1 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:43:46 +02:00
dennisthiessenandClaude Opus 5 3483797e75 fix(regime): reseed stored history on a sensor change, and stop faking an observation
Two review findings on 46ace50.

[P1] Raising HY_OAS_WINDOW_DAYS to 700 only reached newly computed rows. A
routine run recomputes the latest trading date alone, and `rebuilding` was keyed
on "no v3 snapshot exists at all", which is false once the cutover has run --
so every row already written kept the credit gap the wider window exists to
close, indefinitely.

Adds SENSOR_REVISION: stamped into each snapshot, absent on pre-marker rows
(read as 1), and a stored revision below the current one triggers exactly one
reseed. Deliberately not METHODOLOGY, which would partition the history API and
discard the cached event study -- neither warranted, since the study recomputes
its Warning series from source rather than reading snapshots and so cannot be
staled by a reseed.

The reseed is bounded by REBUILD_LOOKBACK_DAYS in calendar days rather than a
session count, because the binding constraint is the OAS fetch: each replayed
row needs W3's 20-business-day lookback inside HY_OAS_WINDOW_DAYS. Replaying by
session count would have left the oldest stored rows unrepaired -- the exact
rows the fix targets. At 672 days the replay covers ~464 sessions, W3's oldest
requirement lands on the first fetched OAS day, and the ~400-session series the
cutover wrote is fully covered. A test asserts that relationship so the two
constants cannot drift back into recreating the gap.

[P2] With nothing ever collected, current_observation returned available=true
and the default placeholders -- "unknown" for every hyperscaler, "mixed" for the
reaction -- so the card announced a reading that never happened. Those are the
absence of an observation, not an observation of absence. Gated on `observed`
(non-null fetched_at, the one field every path writing real content stamps),
which blanks the content and drives a proper empty state naming where an admin
collects one. This was a regression from 46ace50; fundamental_overlay never had
it, since no observation means no effective date means pending.

Also renames the leftover v2 identifiers in the touched paths
(rewrite_existing_v2, latest_v2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:22:25 +02:00
dennisthiessenandClaude Opus 5 7dc804be2b test(dolt): anchor the real-clone smoke test to the clone, not the wall clock
The test built the importer with today=date.today() while running against a
fixed local clone with do_pull=False, so its forward horizon shrank by a day
per real day. It has now decayed past the initial-load gate -- 19d against the
21d MIN_FORWARD_HORIZON_DAYS floor -- and would have kept failing, worse each
day.

Anchors today to the clone's own calendar (max reporting date across the seeded
dot-free symbols, minus 35 days, mirroring the ~35d horizon the importer's own
comment cites) and uses that date in the forward-calendar assertion. Also
surfaces run.error_details on failure, which is how the cause was found.

Test-only. MIN_FORWARD_HORIZON_DAYS and the importer are untouched: production
pulls fresh data on every run and was never affected by this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:22:11 +02:00
dennisthiessenandClaude Opus 5 46ace501a2 refactor(regime): collapse the monitor page, fix the OAS rebuild window
The page had twelve stacked blocks, several of them different views of the
same numbers. The quadrant plot and the score-history chart drew the same two
series from the same query key, which read as two datasets; they are now one
card with a Time | Path toggle. The two pillar disclosures become one grouped
table, and three prose blocks (data quality, basket, coverage) become one
provenance chip strip. Page text is now limited to what changes how the reader
interprets today's number; the rest moved to the methodology doc.

Removes three stale-threshold bugs of one class. The quadrant fell back to v2's
60/60 dividers when quadrant_config was absent -- the real values are 50/40 and
they feed alert_service, so the chart could disagree with what actually fires.
The gauge fell back to v2's 30/60/80 band ticks, and drew a divider line that
always landed on its own "elevated" tick. The time series' reference lines were
at 30/60/80, which correspond to nothing in v3; they are now per-axis dashed
lines read from the same quadrant_config. Rendering also surfaced a live
clipping bug inherited from the old chart: margin.left -18 against YAxis
width 28 left ~10px for a 3-digit label, so every Y tick was cut off.

HY_OAS_WINDOW_DAYS was 400 *calendar* days while a rebuild replays
REBUILD_SESSIONS = 400 *trading* sessions (~579 calendar days), so the oldest
~180 days of any rebuild got no OAS at all and both credit sensors returned
None. State then lands at 80% coverage and Warning at exactly MIN_COVERAGE, so
both still publish bands -- a series that looks homogeneous while its oldest
rows were scored without credit. Widened to 700. This needs no methodology
bump: C1 reads [-1] and W3 reads [-21], both from the end, so widening only
prepends and every live score is bit-identical. Sequenced deliberately, since
acting on the open findings below bumps METHODOLOGY and fires the rebuild.

A just-collected fundamental observation was hidden until its effective date --
one day, three over a weekend -- because the live reading called the
point-in-time function, so refreshing appeared to do nothing. That was the
opposite of what the doc claimed. fundamental_overlay stays the gated record
(it runs for every replayed date during a rebuild); current_observation is the
live reading and reports the effective date instead of blanking the content.
Nothing in the overlay is scored, so showing it early cannot reach a published
number.

Documents four calculation findings. Three are not implemented, since each
changes a published score and so requires a v4 cut: State's top band is a
credit-event band (credit returns 0.0 rather than None below the 3.5 anchor, so
it is pinned at zero at weight 20 -- with everything else pegged State computes
to exactly 80.0, the breaking threshold); V1 saturates at VIX 30; and the
deliberate max(P1,P2,P3) defeats P3's anchoring because P1 is binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:53:45 +02:00
dennisthiessenandClaude Opus 5 23de8c9540 docs(dolt-plan): drop workstream B and record why
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m23s
Deploy / deploy (push) Successful in 42s
B would have replaced Alpaca historical OHLCV with the DoltHub stocks repo. It
was scoped inside a plan whose goal was killing the quota-limited free-tier APIs
— which A6 achieved without it, since Alpaca was never one of them.

Its only concrete benefit was `corporate_actions` for the KLAC-class post-filing
split (TTM EPS pre-split against a post-split price). That needs split events,
not a 4.7 GB clone, and the Alpaca SDK already in the venv exposes them. Against
that: fundamentals are 20% of the composite and P/E one of three inputs, so the
wart is small and self-correcting at the next filing; and B would have made
symbol history mutable as a routine event, which the backtest/prod parity guard
exists to catch.

The design is kept as a record, struck through rather than deleted, with the
reasoning next to it so this isn't re-derived. Also corrects the doc's status
header, the never-written migration 027 (that number went to
weighted_avg_diluted_shares), and the note that the stocks repo's license was
never reviewed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:03:32 +02:00
dennisthiessenandClaude Opus 5 c2a3b56aaa chore: drop the A6 rollback tombstones
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m23s
Deploy / deploy (push) Successful in 39s
A6 deployed cleanly and the provider keys are gone from the production `.env`,
which makes the legacy collector inert regardless of any settings row. The two
tombstones migration 029 pinned have no remaining job, and nothing in the
codebase reads either key.

Migration 030 deletes them and drops the Admin filter that hid them. Unlike
029's, its downgrade is meaningful — it restores both rows at their safe values,
since going back past this revision means going back toward code that reads them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:34:28 +02:00
dennisthiessenandClaude Opus 5 e1607ddbff fix: don't double-report a failed SEC run, and correct the rollback doc
Deploy / lint (push) Successful in 11s
Deploy / test (push) Successful in 1m24s
Deploy / deploy (push) Successful in 41s
Three follow-ups from review of the A6 commits.

The cache-summary re-finalize raised a second durable system event on a failed
run: _runtime_finish emits for `error`/`rate_limited`, and the dedup key
includes the message, so "SEC unavailable" and "SEC unavailable · cache 511 · 2
score inputs changed" landed as two unacknowledged Admin events. Adds
`emit_event` so a re-finalize that only rewords an outcome stays silent, with a
regression test asserting exactly one event.

The rollback section still claimed disabling the SEC job freezes the cache — the
opposite of what the same page says two lines earlier, and of what the code now
does. Rewritten: there is no Admin cache-off switch, restoring `fundamental_data`
alone is temporary because the next run rebuilds it from the same snapshots and
code, and a real freeze means stopping the service.

Remaining "shadow" wording: the two import jobs have never been shadow since
activation, so `_run_shadow_import` -> `_run_source_import`, its section heading,
the deployment doc's job label, and the plan doc's "production switch remains"
handoff paragraph are all brought up to date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:46:34 +02:00
dennisthiessenandClaude Opus 5 13f3636b6a fix: surface the fundamentals cache result on every SEC job outcome
The runtime message only appended the cache summary when the import itself
completed. On a deferred, failed or source-locked run Admin → Jobs showed just
the import outcome, so an operator had no signal that `fundamental_data` had
advanced — contradicting the claim now made in the docstring, the schedule hint
and the deployment doc.

The import status still varies and stays the headline; the cache summary is
appended to all of them. Adds a source-locked regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:22:28 +02:00
dennisthiessenandClaude Opus 5 3e83d63b05 chore: decommission FMP, Finnhub and Alpha Vantage (A6)
The A5 cutover has been on and observed in production, so SEC Company Facts +
DoltHub earnings are already the live source for `fundamental_data`. This
removes everything the legacy path still occupied.

Gone: the three providers and their config/env keys; the weekly
`fundamental_collector` job; the cutover toggle (SEC + Dolt is now the
unconditional path, so `off` can no longer silently freeze scoring inputs); the
A5 parity report, whose deltas became structurally zero once the candidate
builder started writing the table it compared against; and the FMP tier of
universe bootstrap.

Two behavioral notes:

- Disabling **SEC Fundamentals Import** now stops the SEC network fetch only.
  The local cache refresh moved outside the job-enable check, because candidates
  also derive from daily closes and earnings events — freezing those on an
  ingestion pause would stale scoring with no fallback left to recover from.
- `/ingestion/fetch?sources=fundamentals` still accepts the key and reports
  `skipped`; there is no per-ticker fetch any more.

Migration 029 does not blanket-delete the leftover settings rows. Migrations run
before the service restart, and pre-A6 code reads an absent `job_*_enabled` row
as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe
values (hidden in Admin) and only the inert three are deleted. Removing the
provider keys from the production `.env` is the matching rollout step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:19:28 +02:00
dennisthiessenandClaude Opus 5 f5d4b516ab docs: land capacity-study evidence and share the rank-map helper
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m21s
Deploy / deploy (push) Successful in 37s
Brings the durable artifacts of research/portfolio-capacity-rebalancing onto
main so the rationale for raising the count cap lives with the code that cites
it. The matrix runner, the research simulator hooks and the study's unit tests
are deliberately left behind; they remain at tag research/portfolio-capacity-final.

Corrects conclusions that were reached on EV per trade and are now superseded:
the findings doc's decisions 1 (keep cap 10) and 4 (run the risk-floor A/B) are
struck through and answered in a new correction section, and the research README
and phase-A matrix entries are updated to match. The frozen specification itself
is untouched -- its recorded SHA-256 f1e37783 still verifies.

effective-risk-floor-ab.md is retained but marked CLOSED/NEGATIVE: the study it
proposes is already answered by cap15 vs cash_unbounded (-0.753pp CAGR while
EV/trade rises), and its EV-based pass rule would have shipped it.

scripts/research_rankings.py replaces a fourth copy of the historical rank-map
helper; run_research_matrix, run_execution_recovery_matrix and
run_daily_reentry_matrix now share it. The shared version adds a duplicate
observation guard and a deterministic symbol tie-break the copies lacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:08:29 +02:00
dennisthiessenandClaude Opus 5 3ff0fd9f1c feat: stop the position count cap from binding (10 -> 15)
The capacity bracket study (reports/portfolio-construction-prod505-capacity-
bracket-daily-v1) showed a book whose count cap never binds earns +1.1pp CAGR
over the old 10 -- 51 of 175 paired cohorts better, 2 worse -- at unchanged
drawdown (+0.007pp) and better Calmar in 51 of the 52 cohorts that moved.

The headline EV-per-trade delta is ~0 (+0.001), which is the trap: capacity
does not change trade quality, it changes trade COUNT. Flat EV/trade means the
blocked entries were just as good as the taken ones, so refusing them cost their
whole contribution to return. Judge capacity on CAGR, never on EV per trade.

15 is headroom, not a target. cap15 peaked at 12 positions with zero full-book
skips, so cash plus SIM_NOTIONAL_CAP is the real ceiling and 15/20/None are the
same experiment.

SIM_MAX_POSITIONS and the shadow book's DEFAULT_CAPACITY move together to keep
backtest and production in parity. Historical research arms pass max_positions
explicitly, so their labels and past results are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:04:21 +02:00
88 changed files with 99817 additions and 4560 deletions
+4 -18
View File
@@ -18,16 +18,9 @@ OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini OPENAI_MODEL=gpt-4o-mini
OPENAI_SENTIMENT_BATCH_SIZE=5 OPENAI_SENTIMENT_BATCH_SIZE=5
# Fundamentals Provider — Financial Modeling Prep # Dolt bulk data — local clone of post-no-preference/earnings. Together with the
FMP_API_KEY= # SEC EDGAR block below this is the ONLY fundamentals source; there is no
# provider-API fallback.
# Fundamentals Provider — Finnhub (optional fallback)
FINNHUB_API_KEY=
# Fundamentals Provider — Alpha Vantage (optional fallback)
ALPHA_VANTAGE_API_KEY=
# Dolt bulk data — local clone of post-no-preference/earnings (workstream A).
# DOLT_BINARY: path to the dolt CLI (set the full path in dev if it's not on PATH, # DOLT_BINARY: path to the dolt CLI (set the full path in dev if it's not on PATH,
# e.g. Windows: C:\Program Files\Dolt\bin\dolt.exe). DOLT_DATA_DIR holds the # e.g. Windows: C:\Program Files\Dolt\bin\dolt.exe). DOLT_DATA_DIR holds the
# clones; in PRODUCTION it MUST be outside the deploy tree (deploy is # clones; in PRODUCTION it MUST be outside the deploy tree (deploy is
@@ -52,21 +45,14 @@ SEC_REQUEST_SPACING_SECONDS=0.2
SEC_MAX_RETRIES=4 SEC_MAX_RETRIES=4
SEC_REQUEST_TIMEOUT_SECONDS=30.0 SEC_REQUEST_TIMEOUT_SECONDS=30.0
# A5 read-only parity report archive. In production keep this outside the # AI/Tech Risk Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
# rsync deployment tree, e.g. /var/lib/signal-platform/reports/fundamentals-parity.
FUNDAMENTALS_PARITY_REPORT_DIR=reports/fundamentals-parity
# Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
# Optional: without it the volatility (V1) and credit (C1) pillars show as n/a. # Optional: without it the volatility (V1) and credit (C1) pillars show as n/a.
FRED_API_KEY= FRED_API_KEY=
# Scheduled Jobs # Scheduled Jobs
DATA_COLLECTOR_FREQUENCY=daily DATA_COLLECTOR_FREQUENCY=daily
SENTIMENT_POLL_INTERVAL_MINUTES=30 SENTIMENT_POLL_INTERVAL_MINUTES=30
FUNDAMENTAL_FETCH_FREQUENCY=daily
RR_SCAN_FREQUENCY=daily RR_SCAN_FREQUENCY=daily
FUNDAMENTAL_RATE_LIMIT_RETRIES=3
FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS=15
# Scoring Defaults # Scoring Defaults
DEFAULT_WATCHLIST_AUTO_SIZE=10 DEFAULT_WATCHLIST_AUTO_SIZE=10
+4 -1
View File
@@ -38,7 +38,10 @@ jobs:
python-version: "3.12" python-version: "3.12"
cache: "pip" cache: "pip"
- run: pip install ruff - run: pip install ruff
- run: ruff check app/ # Whole repo, not just app/: tests/ and scripts/ drifted to 11 findings
# while unchecked. Rules are pinned in pyproject.toml, so the unpinned
# ruff above cannot change what this enforces.
- run: ruff check .
test: test:
needs: lint needs: lint
+7 -13
View File
@@ -133,8 +133,8 @@ indicators.
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years. 1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only. 2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
3. **Market Regime** + **Regime Monitor** — breadth/trend and the v3 risk thermometer; feed no trades. 3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v3 risk thermometer; feed no trades.
4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan. 4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
**Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation: **Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation:
@@ -155,7 +155,7 @@ Hourly mid-session (MonFri ~10:0015:00 ET): only **OHLCV → Outcome Eval*
### Other jobs ### Other jobs
Fundamentals (weekly, early Monday ET) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs). Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET, also refreshes the fundamentals cache scoring reads) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs).
### From score to "top pick" ### From score to "top pick"
@@ -301,13 +301,13 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
| Charts | Canvas 2D candlestick chart with S/R overlays | | Charts | Canvas 2D candlestick chart with S/R overlays |
| Routing | React Router v6 (SPA) | | Routing | React Router v6 (SPA) |
| HTTP | Axios with JWT interceptor | | HTTP | Axios with JWT interceptor |
| Data providers | Alpaca (OHLCV); OpenAI / Gemini / DeepSeek / xAI (sentiment, pluggable); Fundamentals chain: FMP → Finnhub → Alpha Vantage; FRED (regime); Telegram (alerts) | | Data providers | Alpaca (OHLCV); OpenAI / Gemini / DeepSeek / xAI (sentiment, pluggable); SEC EDGAR Company Facts + DoltHub earnings (fundamentals, bulk import); FRED (regime); Telegram (alerts) |
## Features ## Features
### Backend ### Backend
- Ticker registry with full cascade delete - Ticker registry with full cascade delete
- Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint - Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint — free public sources (Wikipedia / NASDAQ Trader), then the cached snapshot, then a built-in seed list. The seeds are representative, not complete, so a *fresh* install bootstrapped while the public source is unreachable gets a partial universe; a warm instance falls through to its cache.
- OHLCV price storage with upsert and validation - OHLCV price storage with upsert and validation
- Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross - Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross
- Structural Support/Resistance detection with rejection/recency strength, ATR-adaptive merging and a hard cap; persisted for charts and alerts - Structural Support/Resistance detection with rejection/recency strength, ATR-adaptive merging and a hard cap; persisted for charts and alerts
@@ -351,7 +351,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated | | `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
| `/market` | Market — watchlist + rankings tabs | Authenticated | | `/market` | Market — watchlist + rankings tabs | Authenticated |
| `/signals` | Signals — scanner + track record tabs | Authenticated | | `/signals` | Signals — scanner + track record tabs | Authenticated |
| `/regime` | Market Regime | Authenticated | | `/regime` | AI/Tech Risk Monitor | Authenticated |
| `/ticker/:symbol` | Ticker Detail | Authenticated | | `/ticker/:symbol` | Ticker Detail | Authenticated |
| `/admin` | Admin Panel | Admin only | | `/admin` | Admin Panel | Admin only |
@@ -583,18 +583,12 @@ Configure in `.env` (copy from `.env.example`):
| `OPENAI_API_KEY` | For sentiment (OpenAI path) | — | OpenAI API key | | `OPENAI_API_KEY` | For sentiment (OpenAI path) | — | OpenAI API key |
| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name | | `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name |
| `OPENAI_SENTIMENT_BATCH_SIZE` | No | `5` | Micro-batch size for sentiment collector | | `OPENAI_SENTIMENT_BATCH_SIZE` | No | `5` | Micro-batch size for sentiment collector |
| `FMP_API_KEY` | Optional (fundamentals) | — | Financial Modeling Prep API key (first provider in chain) | | `FRED_API_KEY` | Optional (risk monitor) | — | FRED key for the AI/Tech risk monitor (VIX, credit spreads) |
| `FINNHUB_API_KEY` | Optional (fundamentals) | — | Finnhub API key (fallback provider) |
| `ALPHA_VANTAGE_API_KEY` | Optional (fundamentals) | — | Alpha Vantage API key (fallback provider) |
| `FRED_API_KEY` | Optional (regime) | — | FRED key for the regime monitor (VIX, credit spreads) |
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) | | `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts | | `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) | | `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) |
| `SENTIMENT_POLL_INTERVAL_MINUTES` | No | `30` | Sentiment polling interval | | `SENTIMENT_POLL_INTERVAL_MINUTES` | No | `30` | Sentiment polling interval |
| `FUNDAMENTAL_FETCH_FREQUENCY` | No | `weekly` | Fundamentals fetch cadence |
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule | | `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
| `FUNDAMENTAL_RATE_LIMIT_RETRIES` | No | `3` | Retries per ticker on fundamentals rate-limit |
| `FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS` | No | `15` | Base backoff seconds for fundamentals retry (exponential) |
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size | | `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups | | `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
| `DB_POOL_SIZE` | No | `5` | Database connection pool size | | `DB_POOL_SIZE` | No | `5` | Database connection pool size |
@@ -0,0 +1,96 @@
"""Retire the legacy fundamentals settings (A6)
Revision ID: 029
Revises: 028
Create Date: 2026-08-07 00:00:00.000000
A6 removed the FMP/Finnhub/Alpha Vantage providers, the weekly
``fundamental_collector`` job and the A5 parity report. Five SystemSetting rows
are left over. They are NOT all deleted, because the deploy runs migrations
before restarting the service: for a short window — and for the whole of any
rollback — pre-A6 code is still live, and it reads absent rows permissively
(cutover absent -> disabled; ``job_<name>_enabled`` absent -> enabled). Deleting
both would hand a rolled-back process a re-armed legacy collector writing over
the SEC/Dolt cache.
So the two rows that carry behavior become tombstones pinned to the safe value,
and only the inert ones are deleted. The tombstones are dropped in a later
release once the rollback window has closed; ``SettingsForm`` hides them
meanwhile.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "029"
down_revision: Union[str, None] = "028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Behavior-bearing under pre-A6 code -> pin to the safe value, keep the row.
_TOMBSTONES: dict[str, str] = {
"fundamental_data_sec_dolt_cutover_enabled": "true",
"job_fundamental_collector_enabled": "false",
}
# Inert either way: an absent cron falls back to a default for a job that no
# longer registers, and the parity report never wrote anything.
_OBSOLETE: tuple[str, ...] = (
"schedule_fundamentals_cron",
"schedule_fundamentals_parity_cron",
"job_fundamentals_parity_report_enabled",
)
_settings = sa.table(
"system_settings",
sa.column("id", sa.Integer),
sa.column("key", sa.String),
sa.column("value", sa.Text),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def upgrade() -> None:
conn = op.get_bind()
now = sa.func.now()
for key, pinned in _TOMBSTONES.items():
row = conn.execute(
sa.select(_settings.c.value).where(_settings.c.key == key)
).fetchone()
old_value = row[0] if row is not None else None
print(f"a6_tombstone {key}: {old_value!r} -> {pinned!r}", flush=True)
if row is None:
conn.execute(
sa.insert(_settings).values(key=key, value=pinned, updated_at=now)
)
elif old_value != pinned:
conn.execute(
sa.update(_settings)
.where(_settings.c.key == key)
.values(value=pinned, updated_at=now)
)
# Print the value before deleting — a bare DELETE cannot be undone from the
# migration output.
for key in _OBSOLETE:
row = conn.execute(
sa.select(_settings.c.value).where(_settings.c.key == key)
).fetchone()
if row is None:
print(f"a6_delete {key}: absent", flush=True)
continue
print(f"a6_delete {key}: {row[0]!r}", flush=True)
conn.execute(sa.delete(_settings).where(_settings.c.key == key))
def downgrade() -> None:
"""No-op.
The deleted rows configured jobs this revision's code no longer registers,
and the tombstones already hold the values pre-A6 code needs. Recreating
them would restore nothing useful; the printed values above cover recovery.
"""
@@ -0,0 +1,71 @@
"""Drop the A6 rollback tombstones
Revision ID: 030
Revises: 029
Create Date: 2026-08-07 00:00:00.000000
Migration ``029`` kept two SystemSetting rows alive as rollback tombstones,
pinned to the values a pre-A6 process needed to behave safely. A6 is deployed
and healthy, and the provider keys are gone from the production ``.env`` — which
makes the legacy collector inert regardless of any settings row — so the
tombstones have no remaining job.
Nothing in the current codebase reads either key.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "030"
down_revision: Union[str, None] = "029"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# The safe values 029 pinned. Kept here so downgrade restores real protection
# rather than leaving a rolled-back process reading absent rows permissively.
_TOMBSTONES: dict[str, str] = {
"fundamental_data_sec_dolt_cutover_enabled": "true",
"job_fundamental_collector_enabled": "false",
}
_settings = sa.table(
"system_settings",
sa.column("id", sa.Integer),
sa.column("key", sa.String),
sa.column("value", sa.Text),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def upgrade() -> None:
conn = op.get_bind()
for key in _TOMBSTONES:
row = conn.execute(
sa.select(_settings.c.value).where(_settings.c.key == key)
).fetchone()
if row is None:
print(f"a6_tombstone_drop {key}: absent", flush=True)
continue
print(f"a6_tombstone_drop {key}: {row[0]!r}", flush=True)
conn.execute(sa.delete(_settings).where(_settings.c.key == key))
def downgrade() -> None:
"""Restore the tombstones at their safe values.
Unlike 029's no-op downgrade, this one is meaningful: going back past this
revision implies going back toward code that still reads these keys.
"""
conn = op.get_bind()
now = sa.func.now()
for key, pinned in _TOMBSTONES.items():
exists = conn.execute(
sa.select(_settings.c.id).where(_settings.c.key == key)
).fetchone()
if exists is None:
conn.execute(
sa.insert(_settings).values(key=key, value=pinned, updated_at=now)
)
+1 -21
View File
@@ -28,15 +28,6 @@ class Settings(BaseSettings):
deepseek_api_key: str = "" deepseek_api_key: str = ""
xai_api_key: str = "" xai_api_key: str = ""
# Fundamentals Provider — Financial Modeling Prep
fmp_api_key: str = ""
# Fundamentals Provider — Finnhub (optional fallback)
finnhub_api_key: str = ""
# Fundamentals Provider — Alpha Vantage (optional fallback)
alpha_vantage_api_key: str = ""
# Dolt bulk-data — local clone of post-no-preference/earnings (workstream A). # Dolt bulk-data — local clone of post-no-preference/earnings (workstream A).
# dolt_binary: full path when not on PATH (dev/Windows install). dolt_data_dir # dolt_binary: full path when not on PATH (dev/Windows install). dolt_data_dir
# holds the clones; in production it MUST be outside the deploy tree (deploy is # holds the clones; in production it MUST be outside the deploy tree (deploy is
@@ -61,11 +52,7 @@ class Settings(BaseSettings):
sec_max_retries: int = 4 sec_max_retries: int = 4
sec_request_timeout_seconds: float = 30.0 sec_request_timeout_seconds: float = 30.0
# A5 read-only comparison artifacts. Production must keep this outside the # AI/Tech Risk Monitor — FRED (VIX level + HY credit spreads). Optional: without it
# rsync deployment tree so the 5-7 day review window survives deploys.
fundamentals_parity_report_dir: str = "reports/fundamentals-parity"
# Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it
# the volatility (P5) and credit-spread (F2) signals are reported as n/a. # the volatility (P5) and credit-spread (F2) signals are reported as n/a.
fred_api_key: str = "" fred_api_key: str = ""
@@ -86,15 +73,8 @@ class Settings(BaseSettings):
# the score window is 7 days). # the score window is 7 days).
sentiment_fresh_hours: int = 120 sentiment_fresh_hours: int = 120
sentiment_top_composite: int = 30 sentiment_top_composite: int = 30
fundamental_fetch_frequency: str = "weekly" # quarterly-ish data; weekly conserves API quota
rr_scan_frequency: str = "daily" # legacy label; qualifying scan is cron near-close rr_scan_frequency: str = "daily" # legacy label; qualifying scan is cron near-close
# alerts_frequency removed: alerts fire only via morning + near-close pipelines # alerts_frequency removed: alerts fire only via morning + near-close pipelines
fundamental_rate_limit_retries: int = 3
fundamental_rate_limit_backoff_seconds: int = 15
# Pause between tickers in the bulk fundamentals job. Free tiers throttle
# hard (Finnhub ~60 calls/min, ~3 calls/ticker → ~3s/ticker); without
# spacing the job bursts straight into 429s. 0 disables.
fundamental_request_spacing_seconds: float = 3.0
# Scoring Defaults # Scoring Defaults
default_watchlist_auto_size: int = 10 default_watchlist_auto_size: int = 10
+1 -1
View File
@@ -8,7 +8,7 @@ from app.database import Base
class RegimeSnapshot(Base): class RegimeSnapshot(Base):
"""Daily point-in-time snapshot of the AI/Tech Regime Monitor. """Daily point-in-time snapshot of the AI/Tech Risk Monitor.
One row per calendar date (unique). ``breakdown_json`` holds the full One row per calendar date (unique). ``breakdown_json`` holds the full
``breakdown_json`` is authoritative for v2 State, Warning, source dates, ``breakdown_json`` is authoritative for v2 State, Warning, source dates,
-174
View File
@@ -1,174 +0,0 @@
"""Financial Modeling Prep (FMP) fundamentals provider using httpx.
Uses the stable API endpoints (https://financialmodelingprep.com/stable/)
which replaced the legacy /api/v3/ endpoints deprecated in Aug 2025.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import httpx
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import FundamentalData
logger = logging.getLogger(__name__)
_FMP_STABLE_URL = "https://financialmodelingprep.com/stable"
# Resolve CA bundle for explicit httpx verify
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists():
_CA_BUNDLE_PATH: str | bool = True # use system default
else:
_CA_BUNDLE_PATH = _CA_BUNDLE
class FMPFundamentalProvider:
"""Fetches fundamental data from Financial Modeling Prep REST API."""
def __init__(self, api_key: str) -> None:
if not api_key:
raise ProviderError("FMP API key is required")
self._api_key = api_key
# Mapping from FMP endpoint name to the FundamentalData field it populates
_ENDPOINT_FIELD_MAP: dict[str, str] = {
"ratios-ttm": "pe_ratio",
"financial-growth": "revenue_growth",
"earnings": "earnings_surprise",
}
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
"""Fetch P/E, revenue growth, earnings surprise, and market cap.
Fetches from multiple stable endpoints. If a supplementary endpoint
(ratios, growth, earnings) returns 402 (paid tier), we gracefully
degrade and return partial data rather than failing entirely, and
record the affected field in ``unavailable_fields``.
"""
try:
endpoints_402: set[str] = set()
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
params = {"symbol": ticker, "apikey": self._api_key}
# Profile is the primary source — must succeed
profile = await self._fetch_json(client, "profile", params, ticker)
# Supplementary sources — degrade gracefully on 402
ratios, was_402 = await self._fetch_json_optional(client, "ratios-ttm", params, ticker)
if was_402:
endpoints_402.add("ratios-ttm")
growth, was_402 = await self._fetch_json_optional(client, "financial-growth", params, ticker)
if was_402:
endpoints_402.add("financial-growth")
earnings, was_402 = await self._fetch_json_optional(client, "earnings", params, ticker)
if was_402:
endpoints_402.add("earnings")
pe_ratio = self._safe_float(ratios.get("priceToEarningsRatioTTM"))
revenue_growth = self._safe_float(growth.get("revenueGrowth"))
market_cap = self._safe_float(profile.get("marketCap"))
earnings_surprise = self._compute_earnings_surprise(earnings)
# Build unavailable_fields from 402 endpoints
unavailable_fields: dict[str, str] = {
self._ENDPOINT_FIELD_MAP[ep]: "requires paid plan"
for ep in endpoints_402
if ep in self._ENDPOINT_FIELD_MAP
}
return FundamentalData(
ticker=ticker,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=datetime.now(timezone.utc),
unavailable_fields=unavailable_fields,
)
except (ProviderError, RateLimitError):
raise
except Exception as exc:
logger.error("FMP provider error for %s: %s", ticker, exc)
raise ProviderError(f"FMP provider error for {ticker}: {exc}") from exc
async def _fetch_json(
self,
client: httpx.AsyncClient,
endpoint: str,
params: dict,
ticker: str,
) -> dict:
"""Fetch a stable endpoint and return the first item (or empty dict)."""
url = f"{_FMP_STABLE_URL}/{endpoint}"
resp = await client.get(url, params=params)
self._check_response(resp, ticker, endpoint)
data = resp.json()
if isinstance(data, list):
return data[0] if data else {}
return data if isinstance(data, dict) else {}
async def _fetch_json_optional(
self,
client: httpx.AsyncClient,
endpoint: str,
params: dict,
ticker: str,
) -> tuple[dict, bool]:
"""Fetch a stable endpoint, returning ``({}, True)`` on 402 (paid tier).
Returns a tuple of (data_dict, was_402) so callers can track which
endpoints required a paid plan.
"""
url = f"{_FMP_STABLE_URL}/{endpoint}"
resp = await client.get(url, params=params)
if resp.status_code == 402:
logger.warning("FMP %s requires paid plan — skipping for %s", endpoint, ticker)
return {}, True
self._check_response(resp, ticker, endpoint)
data = resp.json()
if isinstance(data, list):
return (data[0] if data else {}, False)
return (data if isinstance(data, dict) else {}, False)
def _compute_earnings_surprise(self, earnings_data: dict) -> float | None:
"""Compute earnings surprise % from the most recent actual vs estimated EPS."""
actual = self._safe_float(earnings_data.get("epsActual"))
estimated = self._safe_float(earnings_data.get("epsEstimated"))
if actual is None or estimated is None or estimated == 0:
return None
return ((actual - estimated) / abs(estimated)) * 100
def _check_response(
self, resp: httpx.Response, ticker: str, endpoint: str
) -> None:
"""Raise appropriate errors for non-200 responses."""
if resp.status_code == 429:
raise RateLimitError(f"FMP rate limit hit for {ticker} ({endpoint})")
if resp.status_code == 403:
raise ProviderError(
f"FMP {endpoint} access denied for {ticker}: HTTP 403 — check API key validity and plan tier"
)
if resp.status_code != 200:
raise ProviderError(
f"FMP {endpoint} error for {ticker}: HTTP {resp.status_code}"
)
@staticmethod
def _safe_float(value: object) -> float | None:
"""Convert a value to float, returning None on failure."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
-354
View File
@@ -1,354 +0,0 @@
"""Chained fundamentals provider with fallback adapters.
Order:
1) FMP (if configured)
2) Finnhub (if configured)
3) Alpha Vantage (if configured)
"""
from __future__ import annotations
import logging
import os
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import httpx
from app.config import settings
from app.exceptions import ProviderError, RateLimitError
from app.providers.fmp import FMPFundamentalProvider
from app.providers.protocol import FundamentalData, FundamentalProvider
logger = logging.getLogger(__name__)
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists():
_CA_BUNDLE_PATH: str | bool = True
else:
_CA_BUNDLE_PATH = _CA_BUNDLE
def _safe_float(value: object) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _to_api_symbol(symbol: str) -> str:
"""Convert internal symbol format (BRK-B) to API format (BRK.B).
Finnhub and Alpha Vantage use dot-separated share class notation.
"""
return symbol.replace("-", ".")
class FinnhubFundamentalProvider:
"""Fundamentals provider backed by Finnhub free endpoints."""
def __init__(self, api_key: str) -> None:
if not api_key:
raise ProviderError("Finnhub API key is required")
self._api_key = api_key
self._base_url = "https://finnhub.io/api/v1"
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
unavailable: dict[str, str] = {}
api_symbol = _to_api_symbol(ticker)
today = date.today()
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
profile_resp = await client.get(
f"{self._base_url}/stock/profile2",
params={"symbol": api_symbol, "token": self._api_key},
)
metric_resp = await client.get(
f"{self._base_url}/stock/metric",
params={"symbol": api_symbol, "metric": "all", "token": self._api_key},
)
earnings_resp = await client.get(
f"{self._base_url}/stock/earnings",
params={"symbol": api_symbol, "limit": 1, "token": self._api_key},
)
calendar_resp = await client.get(
f"{self._base_url}/calendar/earnings",
params={
"symbol": api_symbol,
"from": today.isoformat(),
"to": (today + timedelta(days=120)).isoformat(),
"token": self._api_key,
},
)
for resp, endpoint in (
(profile_resp, "profile2"),
(metric_resp, "stock/metric"),
(earnings_resp, "stock/earnings"),
(calendar_resp, "calendar/earnings"),
):
if resp.status_code == 429:
raise RateLimitError(f"Finnhub rate limit hit for {ticker} ({endpoint})")
if resp.status_code in (401, 403):
raise ProviderError(f"Finnhub access denied for {ticker} ({endpoint}): HTTP {resp.status_code}")
if resp.status_code != 200:
raise ProviderError(f"Finnhub error for {ticker} ({endpoint}): HTTP {resp.status_code}")
profile_payload = profile_resp.json() if profile_resp.text else {}
metric_payload = metric_resp.json() if metric_resp.text else {}
earnings_payload = earnings_resp.json() if earnings_resp.text else []
metrics = metric_payload.get("metric", {}) if isinstance(metric_payload, dict) else {}
# Finnhub profile2 marketCapitalization is in millions of USD.
# Normalize to absolute dollars so cap bands / formatters match FMP & Alpha Vantage.
market_cap_millions = _safe_float((profile_payload or {}).get("marketCapitalization"))
market_cap = market_cap_millions * 1_000_000.0 if market_cap_millions is not None else None
pe_ratio = _safe_float(metrics.get("peTTM") or metrics.get("peNormalizedAnnual"))
revenue_growth = _safe_float(metrics.get("revenueGrowthTTMYoy") or metrics.get("revenueGrowth5Y"))
earnings_surprise = None
if isinstance(earnings_payload, list) and earnings_payload:
first = earnings_payload[0] if isinstance(earnings_payload[0], dict) else {}
earnings_surprise = _safe_float(first.get("surprisePercent"))
next_earnings_date = self._next_earnings(calendar_resp)
if pe_ratio is None:
unavailable["pe_ratio"] = "not available from provider payload"
if revenue_growth is None:
unavailable["revenue_growth"] = "not available from provider payload"
if earnings_surprise is None:
unavailable["earnings_surprise"] = "not available from provider payload"
if market_cap is None:
unavailable["market_cap"] = "not available from provider payload"
return FundamentalData(
ticker=ticker,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=datetime.now(timezone.utc),
next_earnings_date=next_earnings_date,
unavailable_fields=unavailable,
)
@staticmethod
def _next_earnings(resp: httpx.Response) -> date | None:
"""Earliest upcoming earnings date from Finnhub's calendar payload."""
try:
payload = resp.json() if resp.text else {}
except ValueError:
return None
entries = payload.get("earningsCalendar", []) if isinstance(payload, dict) else []
dates: list[date] = []
today = date.today()
for entry in entries if isinstance(entries, list) else []:
raw = entry.get("date") if isinstance(entry, dict) else None
if not raw:
continue
try:
parsed = date.fromisoformat(raw)
except ValueError:
continue
if parsed >= today:
dates.append(parsed)
return min(dates) if dates else None
class AlphaVantageFundamentalProvider:
"""Fundamentals provider backed by Alpha Vantage free endpoints."""
def __init__(self, api_key: str) -> None:
if not api_key:
raise ProviderError("Alpha Vantage API key is required")
self._api_key = api_key
self._base_url = "https://www.alphavantage.co/query"
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
unavailable: dict[str, str] = {}
api_symbol = _to_api_symbol(ticker)
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
overview_resp = await client.get(
self._base_url,
params={"function": "OVERVIEW", "symbol": api_symbol, "apikey": self._api_key},
)
earnings_resp = await client.get(
self._base_url,
params={"function": "EARNINGS", "symbol": api_symbol, "apikey": self._api_key},
)
income_resp = await client.get(
self._base_url,
params={"function": "INCOME_STATEMENT", "symbol": api_symbol, "apikey": self._api_key},
)
for resp, endpoint in (
(overview_resp, "OVERVIEW"),
(earnings_resp, "EARNINGS"),
(income_resp, "INCOME_STATEMENT"),
):
if resp.status_code == 429:
raise RateLimitError(f"Alpha Vantage rate limit hit for {ticker} ({endpoint})")
if resp.status_code != 200:
raise ProviderError(f"Alpha Vantage error for {ticker} ({endpoint}): HTTP {resp.status_code}")
overview = overview_resp.json() if overview_resp.text else {}
earnings = earnings_resp.json() if earnings_resp.text else {}
income = income_resp.json() if income_resp.text else {}
if isinstance(overview, dict) and overview.get("Information"):
raise ProviderError(f"Alpha Vantage unavailable for {ticker}: {overview.get('Information')}")
if isinstance(overview, dict) and overview.get("Note"):
raise RateLimitError(f"Alpha Vantage rate limit for {ticker}: {overview.get('Note')}")
pe_ratio = _safe_float((overview or {}).get("PERatio"))
market_cap = _safe_float((overview or {}).get("MarketCapitalization"))
earnings_surprise = None
quarterly = earnings.get("quarterlyEarnings", []) if isinstance(earnings, dict) else []
if isinstance(quarterly, list) and quarterly:
first = quarterly[0] if isinstance(quarterly[0], dict) else {}
earnings_surprise = _safe_float(first.get("surprisePercentage"))
revenue_growth = None
annual = income.get("annualReports", []) if isinstance(income, dict) else []
if isinstance(annual, list) and len(annual) >= 2:
curr = _safe_float((annual[0] or {}).get("totalRevenue"))
prev = _safe_float((annual[1] or {}).get("totalRevenue"))
if curr is not None and prev not in (None, 0):
revenue_growth = ((curr - prev) / abs(prev)) * 100.0
if pe_ratio is None:
unavailable["pe_ratio"] = "not available from provider payload"
if revenue_growth is None:
unavailable["revenue_growth"] = "not available from provider payload"
if earnings_surprise is None:
unavailable["earnings_surprise"] = "not available from provider payload"
if market_cap is None:
unavailable["market_cap"] = "not available from provider payload"
return FundamentalData(
ticker=ticker,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=datetime.now(timezone.utc),
unavailable_fields=unavailable,
)
_FUNDAMENTAL_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise", "market_cap")
class ChainedFundamentalProvider:
"""Merge fundamentals across providers, filling gaps from later sources.
A single provider rarely covers everything on free tiers — FMP's free plan,
for example, returns only market cap (the ratios/growth/earnings endpoints
402). Rather than stop at the first provider with *any* field, we take each
field from the first provider that supplies it, so FMP's market cap is
combined with Finnhub's P/E and earnings surprise.
"""
def __init__(self, providers: list[tuple[str, FundamentalProvider]]) -> None:
if not providers:
raise ProviderError("No fundamental providers configured")
self._providers = providers
async def fetch_fundamentals(self, ticker: str, allow_partial: bool = False) -> FundamentalData:
"""Merge fundamentals across providers.
``allow_partial`` controls behaviour when a fallback provider is *rate
limited* and we end up with missing fields. By default we raise
RateLimitError so the caller (the bulk collector) can back off and retry
the ticker once the window frees — otherwise a transient 429 on Finnhub
would be silently stored as market-cap-only. Pass ``allow_partial=True``
(manual single fetches, or the collector's final give-up attempt) to
accept whatever was gathered instead of raising.
"""
merged: dict[str, float | None] = {f: None for f in _FUNDAMENTAL_FIELDS}
field_source: dict[str, str] = {}
errors: list[str] = []
rate_limited = False
next_earnings_date = None
for provider_name, provider in self._providers:
if all(merged[f] is not None for f in _FUNDAMENTAL_FIELDS) and next_earnings_date:
break
try:
data = await provider.fetch_fundamentals(ticker)
except RateLimitError as exc:
rate_limited = True
errors.append(f"{provider_name}: RateLimitError: {exc}")
continue
except Exception as exc:
errors.append(f"{provider_name}: {type(exc).__name__}: {exc}")
continue
if next_earnings_date is None and data.next_earnings_date is not None:
next_earnings_date = data.next_earnings_date
for field in _FUNDAMENTAL_FIELDS:
if merged[field] is None:
value = getattr(data, field)
if value is not None:
merged[field] = value
field_source[field] = provider_name
missing = [f for f in _FUNDAMENTAL_FIELDS if merged[f] is None]
# A rate limit left data incomplete: signal it (unless partial is OK) so
# the collector backs off rather than persisting a degraded record.
if rate_limited and missing and not allow_partial:
attempts = "; ".join(errors[:6])
raise RateLimitError(
f"Fundamentals incomplete for {ticker} due to provider rate limits "
f"(missing {', '.join(missing)}). Attempts: {attempts}"
)
if all(merged[f] is None for f in _FUNDAMENTAL_FIELDS):
attempts = "; ".join(errors[:6]) if errors else "no usable metrics from any provider"
raise ProviderError(f"All fundamentals providers failed for {ticker}. Attempts: {attempts}")
unavailable: dict[str, str] = {
field: "not available from any configured provider"
for field in _FUNDAMENTAL_FIELDS
if merged[field] is None
}
# Record which provider supplied each field for transparency.
for field, src in field_source.items():
unavailable[f"source_{field}"] = src
return FundamentalData(
ticker=ticker,
pe_ratio=merged["pe_ratio"],
revenue_growth=merged["revenue_growth"],
earnings_surprise=merged["earnings_surprise"],
market_cap=merged["market_cap"],
fetched_at=datetime.now(timezone.utc),
next_earnings_date=next_earnings_date,
unavailable_fields=unavailable,
)
def build_fundamental_provider_chain() -> FundamentalProvider:
providers: list[tuple[str, FundamentalProvider]] = []
if settings.fmp_api_key:
providers.append(("fmp", FMPFundamentalProvider(settings.fmp_api_key)))
if settings.finnhub_api_key:
providers.append(("finnhub", FinnhubFundamentalProvider(settings.finnhub_api_key)))
if settings.alpha_vantage_api_key:
providers.append(("alpha_vantage", AlphaVantageFundamentalProvider(settings.alpha_vantage_api_key)))
if not providers:
raise ProviderError(
"No fundamentals provider configured. Set one of FMP_API_KEY, FINNHUB_API_KEY, ALPHA_VANTAGE_API_KEY"
)
logger.info("Fundamentals provider chain configured: %s", [name for name, _ in providers])
return ChainedFundamentalProvider(providers)
+2 -20
View File
@@ -44,20 +44,6 @@ class SentimentData:
recommendation: str | None = None # "buy" | "hold" | "avoid" — actionable LLM view recommendation: str | None = None # "buy" | "hold" | "avoid" — actionable LLM view
@dataclass(frozen=True, slots=True)
class FundamentalData:
"""Fundamental metrics returned by fundamental providers."""
ticker: str
pe_ratio: float | None
revenue_growth: float | None
earnings_surprise: float | None
market_cap: float | None
fetched_at: datetime
next_earnings_date: date | None = None
unavailable_fields: dict[str, str] = field(default_factory=dict)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Provider Protocols # Provider Protocols
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -81,9 +67,5 @@ class SentimentProvider(Protocol):
... ...
class FundamentalProvider(Protocol): # No fundamentals provider protocol: since A6 fundamentals come only from the
"""Protocol for fundamental data providers.""" # batch SEC/Dolt imports, never from a request-time provider call.
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
"""Fetch fundamental data for a ticker."""
...
-52
View File
@@ -13,7 +13,6 @@ from app.schemas.admin import (
AlertConfigUpdate, AlertConfigUpdate,
CreateUserRequest, CreateUserRequest,
DataCleanupRequest, DataCleanupRequest,
FundamentalsCutoverConfigUpdate,
JobTriggerRequest, JobTriggerRequest,
JobToggle, JobToggle,
RecommendationConfigUpdate, RecommendationConfigUpdate,
@@ -138,27 +137,6 @@ async def list_settings(
) )
@router.get("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
async def get_fundamentals_cutover_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.get_fundamentals_cutover_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
async def update_fundamentals_cutover_settings(
body: FundamentalsCutoverConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.update_fundamentals_cutover_config(
db, body.enabled
)
return APIEnvelope(status="success", data=config)
@router.get("/admin/settings/recommendations", response_model=APIEnvelope) @router.get("/admin/settings/recommendations", response_model=APIEnvelope)
async def get_recommendation_settings( async def get_recommendation_settings(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
@@ -475,36 +453,6 @@ async def toggle_job(
) )
@router.get("/admin/fundamentals-parity", response_model=APIEnvelope)
async def get_fundamentals_parity_report(
_admin: User = Depends(require_admin),
):
"""Latest read-only A5 source/score comparison, or null before first run."""
return APIEnvelope(
status="success", data=admin_service.get_fundamentals_parity_report()
)
@router.get("/admin/fundamentals-parity/csv", response_model=APIEnvelope)
async def get_fundamentals_parity_csv(
_admin: User = Depends(require_admin),
):
"""Latest flattened A5 report for an authenticated browser download."""
artifact = admin_service.get_fundamentals_parity_csv()
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
return APIEnvelope(status="success", data=data)
@router.get("/admin/fundamentals-parity/json", response_model=APIEnvelope)
async def get_fundamentals_parity_json(
_admin: User = Depends(require_admin),
):
"""Canonical A5 JSON artifact for an authenticated browser download."""
artifact = admin_service.get_fundamentals_parity_json()
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
return APIEnvelope(status="success", data=data)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# System events (operational warnings / errors) # System events (operational warnings / errors)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+4 -26
View File
@@ -23,7 +23,6 @@ from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.user import User from app.models.user import User
from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.services.rr_scanner_service import ( from app.services.rr_scanner_service import (
resolve_activation_ranks_for_symbol, resolve_activation_ranks_for_symbol,
scan_ticker, scan_ticker,
@@ -31,7 +30,6 @@ from app.services.rr_scanner_service import (
from app.services.sentiment_provider_service import build_sentiment_provider from app.services.sentiment_provider_service import build_sentiment_provider
from app.schemas.common import APIEnvelope from app.schemas.common import APIEnvelope
from app.services import ( from app.services import (
fundamental_service,
ingestion_service, ingestion_service,
scoring_service, scoring_service,
sentiment_service, sentiment_service,
@@ -185,33 +183,13 @@ async def fetch_symbol(
sources_out["sentiment"] = {"status": "error", "message": str(exc)} sources_out["sentiment"] = {"status": "error", "message": str(exc)}
# --- Fundamentals --- # --- Fundamentals ---
# No per-ticker fetch exists any more: fundamental_data is rebuilt for the
# whole universe by the nightly SEC + Dolt imports, from local PostgreSQL.
# The source key is still accepted so older clients get a truthful answer.
if "fundamentals" in requested: if "fundamentals" in requested:
if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key:
try:
fundamentals_provider = build_fundamental_provider_chain()
# Manual single fetch: take whatever we can get (a lone 429 on a
# fallback shouldn't fail the whole refresh).
fdata = await fundamentals_provider.fetch_fundamentals(
symbol_upper, allow_partial=True
)
await fundamental_service.store_fundamental(
db,
symbol=symbol_upper,
pe_ratio=fdata.pe_ratio,
revenue_growth=fdata.revenue_growth,
earnings_surprise=fdata.earnings_surprise,
market_cap=fdata.market_cap,
next_earnings_date=fdata.next_earnings_date,
unavailable_fields=fdata.unavailable_fields,
)
sources_out["fundamentals"] = {"status": "ok", "message": None}
except Exception as exc:
logger.error("Fundamentals fetch failed for %s: %s", symbol_upper, exc)
sources_out["fundamentals"] = {"status": "error", "message": str(exc)}
else:
sources_out["fundamentals"] = { sources_out["fundamentals"] = {
"status": "skipped", "status": "skipped",
"message": "No fundamentals provider key configured", "message": "Fundamentals refresh nightly from the SEC + Dolt imports",
} }
# --- Derived pipeline: S/R levels (free, always) --- # --- Derived pipeline: S/R levels (free, always) ---
+56 -269
View File
@@ -1,9 +1,9 @@
"""APScheduler job definitions and FastAPI lifespan integration. """APScheduler job definitions and FastAPI lifespan integration.
Defines four scheduled jobs: Defines the scheduled jobs, among them:
- Data Collector (OHLCV fetch for all tickers) - Data Collector (OHLCV fetch for all tickers)
- Sentiment Collector (sentiment for all tickers) - Sentiment Collector (sentiment for all tickers)
- Fundamental Collector (fundamentals for all tickers) - Dolt Earnings / SEC Fundamentals imports (bulk fundamentals sources)
- R:R Scanner (trade setup scan for all tickers) - R:R Scanner (trade setup scan for all tickers)
Each job processes tickers independently, logs errors as structured JSON, Each job processes tickers independently, logs errors as structured JSON,
@@ -25,22 +25,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database import async_session_factory from app.database import async_session_factory
from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.sentiment import SentimentScore from app.models.sentiment import SentimentScore
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.exceptions import ProviderError from app.exceptions import ProviderError
from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.protocol import SentimentData from app.providers.protocol import SentimentData
from app.services import ( from app.services import (
fundamental_service,
ingestion_service, ingestion_service,
pipeline_run, pipeline_run,
sentiment_service, sentiment_service,
settings_store, settings_store,
shadow_book_service, shadow_book_service,
fundamentals_parity_service,
fundamental_data_refresh_service, fundamental_data_refresh_service,
) )
from app.services.data_import import ( from app.services.data_import import (
@@ -93,7 +89,6 @@ _last_successful: dict[str, str | None] = {
"data_collector": None, "data_collector": None,
"data_backfill": None, "data_backfill": None,
"sentiment_collector": None, "sentiment_collector": None,
"fundamental_collector": None,
} }
# Jobs whose per-run progress is surfaced to Admin → Jobs. (outcome_evaluator is # Jobs whose per-run progress is surfaced to Admin → Jobs. (outcome_evaluator is
@@ -102,10 +97,8 @@ _JOB_NAMES = [
"data_collector", "data_collector",
"data_backfill", "data_backfill",
"sentiment_collector", "sentiment_collector",
"fundamental_collector",
"dolt_earnings_import", "dolt_earnings_import",
"sec_fundamentals_import", "sec_fundamentals_import",
"fundamentals_parity_report",
"rr_scanner", "rr_scanner",
"ticker_universe_sync", "ticker_universe_sync",
"alerts", "alerts",
@@ -261,7 +254,14 @@ def _runtime_finish(
processed: int, processed: int,
total: int | None, total: int | None,
message: str | None = None, message: str | None = None,
emit_event: bool = True,
) -> None: ) -> None:
"""Finalize a job's runtime row, optionally raising a durable event.
``emit_event=False`` is for a *re-finalize* that only rewords an outcome an
earlier call already reported. The dedup key includes the message, so a
reworded error would otherwise land in Admin → System Events twice.
"""
runtime = _job_runtime.get(job_name, {}) runtime = _job_runtime.get(job_name, {})
runtime.update({ runtime.update({
"running": False, "running": False,
@@ -275,7 +275,7 @@ def _runtime_finish(
}) })
_job_runtime[job_name] = runtime _job_runtime[job_name] = runtime
# Durable event for error / rate-limit finishes (badge + Admin → Jobs panel). # Durable event for error / rate-limit finishes (badge + Admin → Jobs panel).
if status in ("error", "rate_limited"): if emit_event and status in ("error", "rate_limited"):
severity = "error" if status == "error" else "warning" severity = "error" if status == "error" else "warning"
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -466,23 +466,6 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
return priority_syms + filler_syms return priority_syms + filler_syms
async def _get_fundamental_priority_tickers(db: AsyncSession) -> list[str]:
"""Return symbols prioritized for fundamentals refresh.
Priority:
1) Tickers with no fundamentals snapshot yet
2) Tickers with existing fundamentals, oldest fetched_at first
3) Alphabetical tiebreaker
"""
missing_first = case((FundamentalData.fetched_at.is_(None), 0), else_=1)
result = await db.execute(
select(Ticker.symbol)
.outerjoin(FundamentalData, FundamentalData.ticker_id == Ticker.id)
.order_by(missing_first.asc(), FundamentalData.fetched_at.asc(), Ticker.symbol.asc())
)
return list(result.scalars().all())
def _resume_tickers(symbols: list[str], job_name: str) -> list[str]: def _resume_tickers(symbols: list[str], job_name: str) -> list[str]:
"""Reorder tickers to resume after the last successful one (rate-limit resume). """Reorder tickers to resume after the last successful one (rate-limit resume).
@@ -816,149 +799,16 @@ async def collect_sentiment() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Job: Fundamental Collector # Jobs: bulk fundamentals source imports
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def collect_fundamentals() -> None: async def _run_source_import(job_name: str, importer: SourceImporter) -> bool:
"""Fetch fundamentals for all tracked tickers via FMP.
Processes each ticker independently. On rate limit, records last
successful ticker for resume.
"""
job_name = "fundamental_collector"
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name)
processed = 0
total: int | None = None
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=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:
_log_event(logging.INFO, "job_complete", job=job_name, tickers=0)
_runtime_finish(job_name, "completed", processed=0, total=0, message="No tickers")
return
total = len(symbols)
_runtime_progress(job_name, processed=0, total=total)
if not (settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key):
_log_event(logging.WARNING, "job_skipped", job=job_name, reason="no fundamentals provider keys configured")
_runtime_finish(job_name, "skipped", processed=0, total=total, message="No fundamentals provider keys configured")
return
try:
provider = build_fundamental_provider_chain()
except Exception as exc:
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
_runtime_finish(job_name, "error", processed=0, total=total, message=str(exc))
return
max_retries = max(0, settings.fundamental_rate_limit_retries)
base_backoff = max(1, settings.fundamental_rate_limit_backoff_seconds)
spacing = max(0.0, settings.fundamental_request_spacing_seconds)
async def _store(symbol: str, data) -> None:
async with async_session_factory() as db:
await fundamental_service.store_fundamental(
db,
symbol=symbol,
pe_ratio=data.pe_ratio,
revenue_growth=data.revenue_growth,
earnings_surprise=data.earnings_surprise,
market_cap=data.market_cap,
next_earnings_date=data.next_earnings_date,
unavailable_fields=data.unavailable_fields,
)
for symbol in symbols:
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
attempt = 0
while True:
try:
data = await provider.fetch_fundamentals(symbol)
await _store(symbol, data)
_last_successful[job_name] = symbol
processed += 1
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
_log_event(logging.INFO, "ticker_collected", job=job_name, ticker=symbol)
break
except Exception as exc:
msg = str(exc).lower()
if "rate" in msg or "429" in msg:
if attempt < max_retries:
wait_seconds = base_backoff * (2 ** attempt)
attempt += 1
_log_event(logging.WARNING, "rate_limited_retry", job=job_name, ticker=symbol, attempt=attempt, max_retries=max_retries, wait_seconds=wait_seconds, processed=processed)
_runtime_progress(
job_name,
processed=processed,
total=total,
current_ticker=symbol,
message=f"Rate-limited at {symbol}; retry {attempt}/{max_retries} in {wait_seconds}s",
)
await asyncio.sleep(wait_seconds)
continue
# Retries exhausted: store whatever partial data we can
# still get (e.g. FMP market cap) and move on, rather than
# aborting the whole run and leaving every later ticker
# untouched.
_log_event(logging.WARNING, "rate_limited_partial", job=job_name, ticker=symbol, processed=processed)
try:
data = await provider.fetch_fundamentals(symbol, allow_partial=True)
await _store(symbol, data)
processed += 1
except Exception as exc2:
_log_job_error(job_name, symbol, exc2)
break
_log_job_error(job_name, symbol, exc)
break
if spacing:
await asyncio.sleep(spacing)
_last_successful[job_name] = None
_log_event(logging.INFO, "job_complete", job=job_name, tickers=processed)
_runtime_finish(job_name, "completed", processed=processed, total=total, message=f"Processed {processed} tickers")
except Exception as exc:
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
_runtime_finish(job_name, "error", processed=processed, total=total, message=str(exc))
# ---------------------------------------------------------------------------
# Jobs: shadow fundamentals sources
# ---------------------------------------------------------------------------
async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
"""Run an importer and return whether its scheduled job was enabled. """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 The SEC wrapper uses the return value only to word its runtime message: its
after deferred, failed, no-op, promoted, or source-locked attempts while honoring local cache step runs after deferred, failed, no-op, promoted, source-locked
the job-level disable switch. and disabled attempts alike.
""" """
_log_event(logging.INFO, "job_start", job=job_name) _log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1) _runtime_start(job_name, total=1)
@@ -968,7 +818,7 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
if not await _is_job_enabled(db, job_name): if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled") _log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled") _runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return return False
run = await run_import(importer) run = await run_import(importer)
if run is None: if run is None:
@@ -1015,25 +865,26 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
async def run_dolt_earnings_import() -> None: async def run_dolt_earnings_import() -> None:
"""Pull and import the Dolt earnings calendar/results feed in shadow.""" """Pull and import the Dolt earnings calendar/results feed."""
await _run_shadow_import("dolt_earnings_import", DoltEarningsImporter()) await _run_source_import("dolt_earnings_import", DoltEarningsImporter())
async def run_sec_fundamentals_import() -> None: async def run_sec_fundamentals_import() -> None:
"""Import SEC facts, then run the activated local compat-cache refresh. """Import SEC facts, then refresh the local compat cache.
The refresh is deliberately separate from the network import result. Once The refresh is deliberately independent of the network import: it reads only
activated it therefore still runs from stored snapshots/earnings/prices when stored snapshots, earnings events and closes, so it runs identically when SEC
SEC is unavailable, unchanged, or another SEC import owns the source lock. is unavailable, unchanged, or owned by another import — and also when the
job's ingestion is switched off in Admin → Jobs. Disabling the job stops
SEC network access, not the cache; prices and earnings move daily even when
no filing does, and `fundamental_data` feeds scoring.
""" """
job_name = "sec_fundamentals_import" job_name = "sec_fundamentals_import"
job_enabled = await _run_shadow_import(job_name, SecFundamentalsImporter()) import_ran = await _run_source_import(job_name, SecFundamentalsImporter())
if not job_enabled:
return
try: try:
async with async_session_factory() as db: async with async_session_factory() as db:
summary = await fundamental_data_refresh_service.refresh_if_enabled(db) summary = await fundamental_data_refresh_service.refresh(db)
except asyncio.CancelledError: except asyncio.CancelledError:
_runtime_finish( _runtime_finish(
job_name, "error", processed=0, total=1, message="Cancelled" job_name, "error", processed=0, total=1, message="Cancelled"
@@ -1051,78 +902,37 @@ async def run_sec_fundamentals_import() -> None:
_runtime_finish(job_name, "error", processed=0, total=1, message=message) _runtime_finish(job_name, "error", processed=0, total=1, message=message)
return 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( _log_event(
logging.INFO, logging.INFO,
"fundamental_data_refresh_complete", "fundamental_data_refresh_complete",
job=job_name, job=job_name,
**summary, **summary,
) )
runtime = get_job_runtime_snapshot(job_name)
if runtime.get("status") == "completed":
import_message = runtime.get("message") or "import completed"
cache_message = ( cache_message = (
f"cache {summary['refreshed']} · " f"cache {summary['refreshed']} · "
f"{summary['score_inputs_changed']} score inputs changed" f"{summary['score_inputs_changed']} score inputs changed"
) )
# Every outcome carries the cache summary — including deferred, failed and
# source-locked ones. The import status is what varies; the refresh always
# happened, and Admin → Jobs is the only place an operator sees that.
#
# This only rewords what _run_source_import already finalized, so it must not
# emit a second durable event: the dedup key includes the message, and a
# failure would otherwise show up twice in Admin → System Events.
runtime = get_job_runtime_snapshot(job_name)
if import_ran:
status = str(runtime.get("status") or "completed")
import_message = runtime.get("message") or "import completed"
processed = 1 if status == "completed" else 0
else:
status, import_message, processed = "completed", "Import disabled", 1
_runtime_finish( _runtime_finish(
job_name, job_name,
"completed", status,
processed=1, processed=processed,
total=1, total=1,
message=f"{import_message} · {cache_message}", message=f"{import_message} · {cache_message}",
) emit_event=False,
async def run_fundamentals_parity_report() -> None:
"""Generate the A5 comparison bundle without mutating live fundamentals/scores."""
job_name = "fundamentals_parity_report"
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_runtime_finish(
job_name, "skipped", processed=0, total=1, message="Disabled"
)
return
report, artifacts = await fundamentals_parity_service.generate_and_store(
db, settings.fundamentals_parity_report_dir
)
summary = report["summary"]
message = (
f"{summary['universe_count']} tickers · "
f"{summary['fundamental_score_material_changes']} material score changes"
)
_runtime_finish(job_name, "completed", processed=1, total=1, message=message)
_log_event(
logging.INFO,
"job_complete",
job=job_name,
generated_at=report["generated_at"],
json_path=artifacts["json"],
csv_path=artifacts["csv"],
)
except asyncio.CancelledError:
_runtime_finish(job_name, "error", processed=0, total=1, message="Cancelled")
raise
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(
logging.ERROR,
"job_error",
job=job_name,
error_type=type(exc).__name__,
message=str(exc),
) )
@@ -1247,7 +1057,7 @@ async def dispatch_alerts_job() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Job: Market Regime # Job: Market Trend (SPY)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1306,7 +1116,7 @@ async def collect_benchmark() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Job: Regime Monitor # Job: AI/Tech Risk Monitor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1586,7 +1396,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
async def run_daily_pipeline() -> None: async def run_daily_pipeline() -> None:
"""Morning flow: OHLCV → benchmark → sentiment → market regime (no scan).""" """Morning flow: OHLCV → benchmark → sentiment → trend/risk (no scan)."""
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS) await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
@@ -1666,19 +1476,16 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_timezone": "America/New_York", "schedule_timezone": "America/New_York",
# Morning data/display refresh (no qualifying R:R scan). # Morning data/display refresh (no qualifying R:R scan).
"schedule_daily_pipeline_cron": "0 2 * * *", "schedule_daily_pipeline_cron": "0 2 * * *",
# Bulk source imports. The SEC job writes the legacy compat cache only after # Bulk source imports. The SEC job also refreshes the fundamental_data compat
# the explicit, default-off A5 cutover setting is enabled. # cache that scoring reads — locally, from stored snapshots/earnings/closes.
"schedule_dolt_earnings_cron": "30 2 * * *", "schedule_dolt_earnings_cron": "30 2 * * *",
"schedule_sec_fundamentals_cron": "0 4 * * *", "schedule_sec_fundamentals_cron": "0 4 * * *",
"schedule_fundamentals_parity_cron": "30 5 * * *",
# Fetch in-progress bars → scan → Telegram (manual MOC window). # Fetch in-progress bars → scan → Telegram (manual MOC window).
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri", "schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
# Fetch final bars → outcome eval (must not run on the partial near-close bar). # Fetch final bars → outcome eval (must not run on the partial near-close bar).
"schedule_after_close_pipeline_cron": "45 16 * * mon-fri", "schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
# Hourly mid-session price + outcome (10:0015:00 ET MonFri). # Hourly mid-session price + outcome (10:0015:00 ET MonFri).
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri", "schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
# Weekly fundamentals early Monday NY.
"schedule_fundamentals_cron": "0 1 * * mon",
} }
# job id -> schedule setting key # job id -> schedule setting key
@@ -1686,11 +1493,9 @@ _CRON_JOBS: dict[str, str] = {
"daily_pipeline": "schedule_daily_pipeline_cron", "daily_pipeline": "schedule_daily_pipeline_cron",
"dolt_earnings_import": "schedule_dolt_earnings_cron", "dolt_earnings_import": "schedule_dolt_earnings_cron",
"sec_fundamentals_import": "schedule_sec_fundamentals_cron", "sec_fundamentals_import": "schedule_sec_fundamentals_cron",
"fundamentals_parity_report": "schedule_fundamentals_parity_cron",
"near_close_pipeline": "schedule_near_close_pipeline_cron", "near_close_pipeline": "schedule_near_close_pipeline_cron",
"after_close_pipeline": "schedule_after_close_pipeline_cron", "after_close_pipeline": "schedule_after_close_pipeline_cron",
"intraday_pipeline": "schedule_intraday_pipeline_cron", "intraday_pipeline": "schedule_intraday_pipeline_cron",
"fundamental_collector": "schedule_fundamentals_cron",
} }
@@ -1756,8 +1561,12 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
(scan_rr, "rr_scanner", "R:R Scanner"), (scan_rr, "rr_scanner", "R:R Scanner"),
(run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"), (run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"),
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"), (evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
(compute_market_regime, "market_regime", "Market Regime"), # Labels only -- the ids are persisted (pipeline steps, cron config, run
(compute_regime_monitor, "regime_monitor", "Regime Monitor"), # history), so they stay. "Market Regime"/"Regime Monitor" read as the
# same job and had it backwards besides: the SPY guard is the one that
# changes what a setup shows, while the monitor is observational.
(compute_market_regime, "market_regime", "Market Trend (SPY)"),
(compute_regime_monitor, "regime_monitor", "AI/Tech Risk Monitor"),
] ]
for fn, job_id, job_name in _members: for fn, job_id, job_name in _members:
scheduler.add_job( scheduler.add_job(
@@ -1779,7 +1588,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
"schedule_dolt_earnings_cron", "schedule_dolt_earnings_cron",
), ),
id="dolt_earnings_import", id="dolt_earnings_import",
name="Dolt Earnings Import (shadow)", name="Dolt Earnings Import",
replace_existing=True, replace_existing=True,
) )
scheduler.add_job( scheduler.add_job(
@@ -1793,17 +1602,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
name="SEC Fundamentals Import", name="SEC Fundamentals Import",
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
run_fundamentals_parity_report,
_cron_trigger(
cfg["schedule_fundamentals_parity_cron"],
tz,
"schedule_fundamentals_parity_cron",
),
id="fundamentals_parity_report",
name="Fundamentals Parity Report (read-only)",
replace_existing=True,
)
scheduler.add_job( scheduler.add_job(
run_near_close_pipeline, run_near_close_pipeline,
_cron_trigger( _cron_trigger(
@@ -1831,13 +1629,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
_cron_trigger(cfg["schedule_intraday_pipeline_cron"], tz, "schedule_intraday_pipeline_cron"), _cron_trigger(cfg["schedule_intraday_pipeline_cron"], tz, "schedule_intraday_pipeline_cron"),
id="intraday_pipeline", name="Intraday Pipeline", replace_existing=True, id="intraday_pipeline", name="Intraday Pipeline", replace_existing=True,
) )
# Fundamentals — quarterly-ish data; weekly by default (conserves API quota).
# Its own early cron so the slow, rate-limited fetch finishes before the day.
scheduler.add_job(
collect_fundamentals,
_cron_trigger(cfg["schedule_fundamentals_cron"], tz, "schedule_fundamentals_cron"),
id="fundamental_collector", name="Fundamental Collector", replace_existing=True,
)
# Independent interval jobs (own cadence, no ordering dependency) # Independent interval jobs (own cadence, no ordering dependency)
scheduler.add_job( scheduler.add_job(
@@ -1879,9 +1670,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
}, },
dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]}, dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]},
sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]}, sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]},
fundamentals_parity_report={
"cron": cfg["schedule_fundamentals_parity_cron"]
},
near_close_pipeline={ near_close_pipeline={
"cron": cfg["schedule_near_close_pipeline_cron"], "cron": cfg["schedule_near_close_pipeline_cron"],
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS], "steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
@@ -1894,7 +1682,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
"cron": cfg["schedule_intraday_pipeline_cron"], "cron": cfg["schedule_intraday_pipeline_cron"],
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS], "steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS],
}, },
fundamental_collector={"cron": cfg["schedule_fundamentals_cron"]},
independent=["ticker_universe_sync", "backtest"], independent=["ticker_universe_sync", "backtest"],
manual_only=["alerts", "data_backfill", "event_study"], manual_only=["alerts", "data_backfill", "event_study"],
) )
-7
View File
@@ -73,11 +73,6 @@ class ActivationConfigUpdate(BaseModel):
exclude_neutral: bool | None = None exclude_neutral: bool | None = None
class FundamentalsCutoverConfigUpdate(BaseModel):
"""Switch the legacy fundamentals cache from quota APIs to SEC/Dolt."""
enabled: bool
class ScheduleConfigUpdate(BaseModel): class ScheduleConfigUpdate(BaseModel):
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field """Cron schedule for the pipelines + fundamentals. Crons are 5-field
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York).""" (min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
@@ -85,11 +80,9 @@ class ScheduleConfigUpdate(BaseModel):
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120) schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120)
schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120) schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120)
schedule_fundamentals_parity_cron: str | None = Field(default=None, max_length=120)
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
class PerformanceConfigUpdate(BaseModel): class PerformanceConfigUpdate(BaseModel):
+5 -57
View File
@@ -17,7 +17,7 @@ from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.models.user import User from app.models.user import User
from app.services import fundamental_data_refresh_service, settings_store from app.services import settings_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,28 +159,6 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
return setting return setting
# ---------------------------------------------------------------------------
# Fundamentals source cutover
# ---------------------------------------------------------------------------
async def get_fundamentals_cutover_config(db: AsyncSession) -> dict[str, bool]:
"""Return the explicit A5 cache-cutover switch (default off)."""
return {"enabled": await fundamental_data_refresh_service.is_enabled(db)}
async def update_fundamentals_cutover_config(
db: AsyncSession, enabled: bool
) -> dict[str, bool]:
"""Activate or pause SEC/Dolt writes to the legacy fundamentals cache."""
await settings_store.upsert_setting(
db,
fundamental_data_refresh_service.ACTIVATION_KEY,
"true" if enabled else "false",
)
await db.commit()
return await get_fundamentals_cutover_config(db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Activation thresholds # Activation thresholds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -633,10 +611,8 @@ VALID_JOB_NAMES = {
"data_backfill", "data_backfill",
"benchmark_collector", "benchmark_collector",
"sentiment_collector", "sentiment_collector",
"fundamental_collector",
"dolt_earnings_import", "dolt_earnings_import",
"sec_fundamentals_import", "sec_fundamentals_import",
"fundamentals_parity_report",
"rr_scanner", "rr_scanner",
"ticker_universe_sync", "ticker_universe_sync",
"outcome_evaluator", "outcome_evaluator",
@@ -657,16 +633,15 @@ JOB_LABELS = {
"data_backfill": "Data Backfill (deep history)", "data_backfill": "Data Backfill (deep history)",
"benchmark_collector": "Benchmark Collector", "benchmark_collector": "Benchmark Collector",
"sentiment_collector": "Sentiment Collector", "sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector", "dolt_earnings_import": "Dolt Earnings Import",
"dolt_earnings_import": "Dolt Earnings Import (shadow)",
"sec_fundamentals_import": "SEC Fundamentals Import", "sec_fundamentals_import": "SEC Fundamentals Import",
"fundamentals_parity_report": "Fundamentals Parity Report (read-only)",
"rr_scanner": "R:R Scanner", "rr_scanner": "R:R Scanner",
"ticker_universe_sync": "Ticker Universe Sync", "ticker_universe_sync": "Ticker Universe Sync",
"outcome_evaluator": "Outcome Evaluator", "outcome_evaluator": "Outcome Evaluator",
"alerts": "Alerts Dispatcher", "alerts": "Alerts Dispatcher",
"market_regime": "Market Regime", # Keys are persisted job ids and must not change; these are display only.
"regime_monitor": "Regime Monitor", "market_regime": "Market Trend (SPY)",
"regime_monitor": "AI/Tech Risk Monitor",
"event_study": "Event Study", "event_study": "Event Study",
"backtest": "Backtest", "backtest": "Backtest",
"daily_pipeline": "Morning Pipeline", "daily_pipeline": "Morning Pipeline",
@@ -799,30 +774,3 @@ async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSe
key = f"job_{job_name}_enabled" key = f"job_{job_name}_enabled"
return await update_setting(db, key, str(enabled).lower()) return await update_setting(db, key, str(enabled).lower())
def get_fundamentals_parity_report() -> dict | None:
"""Return the latest compact A5 summary, if the job has run."""
from app.config import settings
from app.services.fundamentals_parity_service import load_latest
report = load_latest(settings.fundamentals_parity_report_dir)
if report is not None:
report.pop("rows", None) # full per-ticker data is download-only
return report
def get_fundamentals_parity_csv() -> tuple[str, str] | None:
"""Return the latest A5 CSV filename and content for authenticated download."""
from app.config import settings
from app.services.fundamentals_parity_service import load_latest_csv
return load_latest_csv(settings.fundamentals_parity_report_dir)
def get_fundamentals_parity_json() -> tuple[str, str] | None:
"""Return the canonical A5 JSON artifact for authenticated download."""
from app.config import settings
from app.services.fundamentals_parity_service import load_latest_json
return load_latest_json(settings.fundamentals_parity_report_dir)
+1 -1
View File
@@ -860,7 +860,7 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
else: else:
metrics = f"State {x:.0f} · Warning {y:.0f}" metrics = f"State {x:.0f} · Warning {y:.0f}"
text = ( text = (
f"🧭 <b>Regime quadrant change</b>\n" f"🧭 <b>AI/Tech risk quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n" f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"{metrics}\n" f"{metrics}\n"
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n" f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
+8 -1
View File
@@ -1701,7 +1701,14 @@ def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) -
# the QUALIFIED setups at their detection close, best momentum first while # the QUALIFIED setups at their detection close, best momentum first while
# slots and cash allow. # slots and cash allow.
SIM_STARTING_CAPITAL = 10_000.0 SIM_STARTING_CAPITAL = 10_000.0
SIM_MAX_POSITIONS = 10 # Headroom, not a target: the count cap should never bind. The capacity study
# (reports/portfolio-construction-prod505-capacity-bracket-daily-v1) showed a book
# that never hits the count cap earns +1.1pp CAGR over the old 10 (51 cohorts of 175
# better, 2 worse) at unchanged drawdown, because the blocked entries were as good as
# the taken ones — capacity costs trade COUNT, not trade quality. The real ceiling is
# cash plus SIM_NOTIONAL_CAP, which saturates the book near 12 positions, so 15/20/None
# are the same experiment. Judge any future change here on CAGR, never on EV per trade.
SIM_MAX_POSITIONS = 15
SIM_RISK_PER_TRADE = 0.01 # fraction of equity risked per position (entry→stop) SIM_RISK_PER_TRADE = 0.01 # fraction of equity risked per position (entry→stop)
SIM_NOTIONAL_CAP = 0.20 # max fraction of equity per position (no margin) SIM_NOTIONAL_CAP = 0.20 # max fraction of equity per position (no margin)
_EULER_MASCHERONI = 0.5772156649015329 _EULER_MASCHERONI = 0.5772156649015329
+1 -1
View File
@@ -1,4 +1,4 @@
"""Compact chronological validation for the Regime Monitor warning score. """Compact chronological validation for the AI/Tech Risk Monitor warning score.
The study calls its outcome a 10% correction, uses the first 70% of sessions to The study calls its outcome a 10% correction, uses the first 70% of sessions to
freeze an 80th-percentile warning threshold, and reports alarm episodes only on freeze an 80th-percentile warning threshold, and reports alarm episodes only on
@@ -1,4 +1,7 @@
"""A5 activation: refresh the legacy fundamentals cache from local bulk data.""" """Refresh the fundamentals compat cache from local SEC/Dolt bulk data.
``fundamental_data`` is the table scoring reads. This is its only writer.
"""
from __future__ import annotations from __future__ import annotations
@@ -12,38 +15,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import insert_for_session from app.database import insert_for_session
from app.models.fundamental import FundamentalData from app.models.fundamental import FundamentalData
from app.models.score import CompositeScore, DimensionScore from app.models.score import CompositeScore, DimensionScore
from app.services import fundamentals_candidate_service, settings_store from app.services import fundamentals_candidate_service
# 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") _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( async def refresh(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -117,7 +93,6 @@ async def refresh(
await db.commit() await db.commit()
return { return {
"enabled": True,
"refreshed": len(candidates), "refreshed": len(candidates),
"score_inputs_changed": len(changed_ids), "score_inputs_changed": len(changed_ids),
"dimension_scores_staled": len(dimension_ids), "dimension_scores_staled": len(dimension_ids),
+5 -67
View File
@@ -1,22 +1,19 @@
"""Fundamental data service. """Fundamental data read access.
Stores fundamental data (P/E, revenue growth, earnings surprise, market cap) ``fundamental_data`` is the compat cache scoring reads. It is written solely by
and marks the fundamental dimension score as stale on new data. ``fundamental_data_refresh_service`` from SEC snapshots, Dolt earnings events and
stored closes; nothing fetches it per ticker.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from datetime import datetime, timezone
from sqlalchemy import select, update from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import insert_for_session
from app.exceptions import NotFoundError from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData from app.models.fundamental import FundamentalData
from app.models.score import DimensionScore
from app.models.ticker import Ticker from app.models.ticker import Ticker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,65 +29,6 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
return ticker return ticker
async def store_fundamental(
db: AsyncSession,
symbol: str,
pe_ratio: float | None = None,
revenue_growth: float | None = None,
earnings_surprise: float | None = None,
market_cap: float | None = None,
next_earnings_date=None,
unavailable_fields: dict[str, str] | None = None,
) -> FundamentalData:
"""Store or update fundamental data for a ticker.
Keeps a single latest snapshot per ticker. On new data, marks the
fundamental dimension score as stale (if one exists).
"""
ticker = await _get_ticker(db, symbol)
now = datetime.now(timezone.utc)
unavailable_fields_json = json.dumps(unavailable_fields or {})
stmt = insert_for_session(db, FundamentalData).values(
ticker_id=ticker.id,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
next_earnings_date=next_earnings_date,
fetched_at=now,
unavailable_fields_json=unavailable_fields_json,
)
stmt = 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,
},
).returning(FundamentalData)
record = (await db.execute(stmt)).scalar_one()
# Mark fundamental dimension score as stale if it exists
# TODO: Use DimensionScore service when built
await db.execute(
update(DimensionScore)
.where(
DimensionScore.ticker_id == ticker.id,
DimensionScore.dimension == "fundamental",
)
.values(is_stale=True)
)
await db.commit()
return record
async def get_fundamental( async def get_fundamental(
db: AsyncSession, db: AsyncSession,
symbol: str, symbol: str,
@@ -1,9 +1,8 @@
"""Local SEC/Dolt candidate values for the legacy fundamentals cache. """Local SEC/Dolt candidate values for the fundamentals compat cache.
This is the single read path shared by the A5 parity report and the activated This is the read path behind the ``fundamental_data`` refresh. It never contacts
``fundamental_data`` refresh. It never contacts SEC or Dolt: every input comes SEC or Dolt: every input comes from PostgreSQL, so price- and earnings-driven
from PostgreSQL, so price- and earnings-driven values can still refresh when an values can still refresh when an upstream import is unchanged or unavailable.
upstream import is unchanged or unavailable.
""" """
from __future__ import annotations from __future__ import annotations
-498
View File
@@ -1,498 +0,0 @@
"""Read-only A5 comparison of legacy and SEC/Dolt fundamental inputs.
The report deliberately does not write ``fundamental_data`` or score tables.
It reconstructs the current legacy and candidate fundamental scores, projects
their composite-score/rank effect with the active weights, and archives a
timestamped JSON + CSV bundle for explicit human approval.
"""
from __future__ import annotations
import csv
import io
import json
import math
import os
import statistics
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.data_import_run import DataImportRun
from app.models.fundamental import FundamentalData
from app.services import fundamentals_candidate_service as candidate_service
REPORT_VERSION = 1
APPROVAL_STATUS = "pending_explicit_approval"
FIELD_KEYS = ("pe_ratio", "revenue_growth", "earnings_surprise")
MIN_SCORE_METRICS = 2
# Materiality is a review aid, never an automatic cutover verdict. Definition
# changes remain visible even when a delta falls inside these bands.
FIELD_TOLERANCES = {
"pe_ratio": {"absolute": 1.0, "relative_pct": 10.0},
"revenue_growth": {"absolute": 2.0, "relative_pct": None},
"earnings_surprise": {"absolute": 2.0, "relative_pct": None},
}
DEFINITION_NOTES = {
"pe_ratio": (
"Legacy provider P/E convention versus latest close divided by "
"SEC-derived TTM diluted EPS."
),
"revenue_growth": (
"Legacy provider growth convention versus SEC-derived TTM revenue YoY."
),
"earnings_surprise": (
"Legacy provider latest surprise versus latest completed Dolt earnings "
"event with actual and estimate."
),
}
def fundamental_score(
pe_ratio: float | None,
revenue_growth: float | None,
earnings_surprise: float | None,
) -> float | None:
"""Match the production fundamental-dimension formula without persistence."""
scores: list[float] = []
if _finite(pe_ratio) and pe_ratio > 0:
scores.append(max(0.0, min(100.0, 100.0 - (pe_ratio - 15.0) * (100.0 / 30.0))))
if _finite(revenue_growth):
scores.append(max(0.0, min(100.0, 50.0 + revenue_growth * 2.5)))
if _finite(earnings_surprise):
scores.append(max(0.0, min(100.0, 50.0 + earnings_surprise * 5.0)))
return sum(scores) / len(scores) if len(scores) >= MIN_SCORE_METRICS else None
async def build_report(
db: AsyncSession,
*,
generated_at: datetime | None = None,
today: date | None = None,
) -> dict[str, Any]:
"""Build a point-in-time parity report from one database session."""
generated_at = generated_at or datetime.now(timezone.utc)
today = today or datetime.now(ZoneInfo("America/New_York")).date()
# A report must not mix rows from before and after a concurrent import
# promotion. The scheduled job provides a fresh session, so establish the
# production snapshot before its first query and have Postgres enforce the
# no-write contract as well. SQLite tests retain their normal transaction.
if db.get_bind().dialect.name == "postgresql":
connection = await db.connection(
execution_options={"isolation_level": "REPEATABLE READ"}
)
await connection.execute(text("SET TRANSACTION READ ONLY"))
candidates = await candidate_service.build_candidates(db, today=today)
ticker_ids = [candidate.ticker_id for candidate in candidates]
legacy_by_ticker = await _legacy_values(db, ticker_ids)
source_runs = await _source_runs(db)
rows: list[dict[str, Any]] = []
for candidate in candidates:
legacy = legacy_by_ticker.get(candidate.ticker_id)
candidate_values = {
"pe_ratio": candidate.pe_ratio,
"revenue_growth": candidate.revenue_growth,
"earnings_surprise": candidate.earnings_surprise,
}
legacy_values = {
"pe_ratio": legacy.pe_ratio if legacy else None,
"revenue_growth": legacy.revenue_growth if legacy else None,
"earnings_surprise": legacy.earnings_surprise if legacy else None,
}
fields = {
key: _field_comparison(key, legacy_values[key], candidate_values[key])
for key in FIELD_KEYS
}
legacy_score = fundamental_score(**legacy_values)
candidate_score = fundamental_score(**candidate_values)
rows.append(
{
"symbol": candidate.symbol,
"cik": candidate.cik,
"legacy_fetched_at": _iso(legacy.fetched_at) if legacy else None,
"price_date": _iso(candidate.price_date),
"fields": fields,
"scores": {
"legacy_fundamental": _round(legacy_score),
"candidate_fundamental": _round(candidate_score),
"fundamental_delta": _delta(legacy_score, candidate_score),
"legacy_fundamental_rank": None,
"candidate_fundamental_rank": None,
"fundamental_rank_change": None,
},
}
)
_attach_ranks(rows, "legacy_fundamental", "legacy_fundamental_rank")
_attach_ranks(rows, "candidate_fundamental", "candidate_fundamental_rank")
for row in rows:
scores = row["scores"]
scores["fundamental_rank_change"] = _rank_change(
scores["legacy_fundamental_rank"], scores["candidate_fundamental_rank"]
)
return {
"report_version": REPORT_VERSION,
"generated_at": generated_at.isoformat(),
"as_of_date": today.isoformat(),
"approval_status": APPROVAL_STATUS,
"read_only": True,
"fundamental_score_formula": (
"Equal-weighted mean of 2+ available sub-scores: P/E = "
"clamp(100-(pe-15)*(100/30)); revenue growth = "
"clamp(50+growth*2.5); earnings surprise = "
"clamp(50+surprise*5)."
),
"source_runs": source_runs,
"definition_notes": DEFINITION_NOTES,
"materiality_notes": {
"fields": FIELD_TOLERANCES,
"fundamental_score_absolute": 5.0,
"automatic_cutover": False,
},
"summary": _summary(rows),
"rows": rows,
}
def store_report(report: dict[str, Any], report_dir: str | Path) -> dict[str, str]:
"""Atomically archive JSON/CSV artifacts and update the latest manifest."""
directory = Path(report_dir).expanduser().resolve()
directory.mkdir(parents=True, exist_ok=True)
stamp = _artifact_stamp(report["generated_at"])
json_name = f"fundamentals-parity-{stamp}.json"
csv_name = f"fundamentals-parity-{stamp}.csv"
json_path = directory / json_name
csv_path = directory / csv_name
_atomic_write(json_path, json.dumps(report, indent=2, sort_keys=True) + "\n")
_atomic_write(csv_path, report_csv(report))
manifest = {
"generated_at": report["generated_at"],
"json_file": json_name,
"csv_file": csv_name,
}
_atomic_write(
directory / "latest.json",
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
)
return {
"json": str(json_path),
"csv": str(csv_path),
"manifest": str(directory / "latest.json"),
}
async def generate_and_store(
db: AsyncSession,
report_dir: str | Path,
*,
generated_at: datetime | None = None,
today: date | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
report = await build_report(db, generated_at=generated_at, today=today)
return report, store_report(report, report_dir)
def load_latest(report_dir: str | Path) -> dict[str, Any] | None:
manifest = _load_manifest(report_dir)
if manifest is None:
return None
try:
path = _manifest_artifact(report_dir, manifest, "json_file")
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
return loaded if isinstance(loaded, dict) else None
def load_latest_csv(report_dir: str | Path) -> tuple[str, str] | None:
return _load_latest_text_artifact(report_dir, "csv_file")
def load_latest_json(report_dir: str | Path) -> tuple[str, str] | None:
return _load_latest_text_artifact(report_dir, "json_file")
def _load_latest_text_artifact(
report_dir: str | Path, manifest_key: str
) -> tuple[str, str] | None:
manifest = _load_manifest(report_dir)
if manifest is None:
return None
try:
path = _manifest_artifact(report_dir, manifest, manifest_key)
return path.name, path.read_text(encoding="utf-8")
except (OSError, TypeError, ValueError):
return None
def report_csv(report: dict[str, Any]) -> str:
output = io.StringIO(newline="")
columns = [
"symbol",
"cik",
"legacy_fetched_at",
"price_date",
*(
f"{field}_{suffix}"
for field in FIELD_KEYS
for suffix in ("legacy", "candidate", "absolute_delta", "relative_delta_pct", "material")
),
"legacy_fundamental",
"candidate_fundamental",
"fundamental_delta",
"legacy_fundamental_rank",
"candidate_fundamental_rank",
"fundamental_rank_change",
]
writer = csv.DictWriter(output, fieldnames=columns)
writer.writeheader()
for row in report.get("rows", []):
flat = {
"symbol": row["symbol"],
"cik": row.get("cik"),
"legacy_fetched_at": row.get("legacy_fetched_at"),
"price_date": row.get("price_date"),
**row["scores"],
}
for field in FIELD_KEYS:
comparison = row["fields"][field]
for suffix in (
"legacy",
"candidate",
"absolute_delta",
"relative_delta_pct",
"material",
):
flat[f"{field}_{suffix}"] = comparison.get(suffix)
writer.writerow(flat)
return output.getvalue()
async def _legacy_values(
db: AsyncSession, ticker_ids: list[int]
) -> dict[int, FundamentalData]:
if not ticker_ids:
return {}
rows = (
await db.execute(
select(FundamentalData).where(FundamentalData.ticker_id.in_(ticker_ids))
)
).scalars()
return {row.ticker_id: row for row in rows}
async def _source_runs(db: AsyncSession) -> dict[str, dict[str, Any] | None]:
sources = ("sec_facts", "dolt_earnings")
rows = (
await db.execute(
select(DataImportRun)
.where(
DataImportRun.source.in_(sources),
DataImportRun.status.in_(("promoted", "no_op")),
)
.order_by(DataImportRun.id.desc())
)
).scalars()
latest: dict[str, dict[str, Any] | None] = {source: None for source in sources}
for row in rows:
if latest[row.source] is None:
latest[row.source] = {
"run_id": row.id,
"status": row.status,
"revision": row.revision,
"source_max_date": _iso(row.source_max_date),
"completed_at": _iso(row.completed_at),
}
return latest
def _field_comparison(
key: str, legacy: float | None, candidate: float | None
) -> dict[str, Any]:
legacy = float(legacy) if _finite(legacy) else None
candidate = float(candidate) if _finite(candidate) else None
absolute = _delta(legacy, candidate)
relative = (
None
if absolute is None or legacy in (None, 0)
else round(absolute / abs(legacy) * 100.0, 4)
)
tolerance = FIELD_TOLERANCES[key]
material = False
if absolute is not None:
material = abs(absolute) > tolerance["absolute"]
relative_limit = tolerance["relative_pct"]
if relative_limit is not None:
material = material and relative is not None and abs(relative) > relative_limit
return {
"legacy": _round(legacy),
"candidate": _round(candidate),
"absolute_delta": absolute,
"relative_delta_pct": relative,
"material": material,
"definition_changed": True,
}
def _attach_ranks(rows: list[dict[str, Any]], value_key: str, rank_key: str) -> None:
values = [
row["scores"][value_key]
for row in rows
if _finite(row["scores"][value_key])
]
for row in rows:
value = row["scores"][value_key]
row["scores"][rank_key] = (
1 + sum(other > value for other in values) if _finite(value) else None
)
def _summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
field_stats = {}
for key in FIELD_KEYS:
comparisons = [row["fields"][key] for row in rows]
deltas = [
abs(item["absolute_delta"])
for item in comparisons
if item["absolute_delta"] is not None
]
field_stats[key] = {
"legacy_available": sum(item["legacy"] is not None for item in comparisons),
"candidate_available": sum(
item["candidate"] is not None for item in comparisons
),
"both_available": len(deltas),
"material_differences": sum(item["material"] for item in comparisons),
"median_absolute_delta": _round(statistics.median(deltas) if deltas else None),
"p95_absolute_delta": _round(_percentile(deltas, 0.95)),
"max_absolute_delta": _round(max(deltas) if deltas else None),
}
fundamental_deltas = _score_deltas(rows, "fundamental_delta")
changed_rows = sorted(
(
{
"symbol": row["symbol"],
"fundamental_delta": row["scores"]["fundamental_delta"],
"fundamental_rank_change": row["scores"]["fundamental_rank_change"],
}
for row in rows
if row["scores"]["fundamental_delta"] is not None
),
key=lambda item: (
abs(item["fundamental_delta"] or 0),
),
reverse=True,
)[:20]
return {
"universe_count": len(rows),
"legacy_fundamental_score_available": _count_score(
rows, "legacy_fundamental"
),
"candidate_fundamental_score_available": _count_score(
rows, "candidate_fundamental"
),
"fundamental_scores_compared": len(fundamental_deltas),
"fundamental_score_material_changes": sum(
abs(delta) > 5.0 for delta in fundamental_deltas
),
"fundamental_rank_changes": _rank_change_count(
rows, "fundamental_rank_change"
),
"field_stats": field_stats,
"largest_changes": changed_rows,
}
def _score_deltas(rows: Iterable[dict[str, Any]], key: str) -> list[float]:
return [
row["scores"][key]
for row in rows
if row["scores"][key] is not None
]
def _count_score(rows: Iterable[dict[str, Any]], key: str) -> int:
return sum(row["scores"][key] is not None for row in rows)
def _rank_change_count(rows: Iterable[dict[str, Any]], key: str) -> int:
return sum(
row["scores"][key] not in (None, 0)
for row in rows
)
def _rank_change(legacy: int | None, candidate: int | None) -> int | None:
# Positive means the candidate improved its rank.
return legacy - candidate if legacy is not None and candidate is not None else None
def _delta(legacy: float | None, candidate: float | None) -> float | None:
if not _finite(legacy) or not _finite(candidate):
return None
return round(candidate - legacy, 4)
def _round(value: float | None, digits: int = 4) -> float | None:
return round(float(value), digits) if _finite(value) else None
def _percentile(values: list[float], quantile: float) -> float | None:
if not values:
return None
ordered = sorted(values)
index = max(0, math.ceil(quantile * len(ordered)) - 1)
return ordered[index]
def _finite(value: Any) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
)
def _iso(value: Any) -> str | None:
return value.isoformat() if value is not None else None
def _artifact_stamp(raw: str) -> str:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return parsed.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
def _atomic_write(path: Path, content: str) -> None:
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temp.write_text(content, encoding="utf-8", newline="")
os.replace(temp, path)
def _load_manifest(report_dir: str | Path) -> dict[str, Any] | None:
path = Path(report_dir).expanduser().resolve() / "latest.json"
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
return loaded if isinstance(loaded, dict) else None
def _manifest_artifact(
report_dir: str | Path, manifest: dict[str, Any], key: str
) -> Path:
directory = Path(report_dir).expanduser().resolve()
name = Path(str(manifest.get(key, ""))).name
if not name:
raise ValueError(f"Latest parity manifest has no {key}")
return directory / name
@@ -12,7 +12,6 @@ from app.models.data_import_run import DataImportRun
from app.models.fundamental_snapshot import FundamentalSnapshot from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.sec_filing_gap import SecFilingGap from app.models.sec_filing_gap import SecFilingGap
from app.models.ticker import Ticker 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") _SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
@@ -78,8 +77,6 @@ async def blocked_reasons_by_cik(
ciks: set[str] | None = None, ciks: set[str] | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
"""Current SEC blocker code by CIK; no historical audit scan.""" """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: if ciks is not None and not ciks:
return {} return {}
+119 -22
View File
@@ -1,4 +1,4 @@
"""AI/Tech Regime Monitor v3. """AI/Tech Risk Monitor v3.
The monitor is a risk thermometer, not a probability or trading rule. It keeps The monitor is a risk thermometer, not a probability or trading rule. It keeps
two deliberately separate outputs: two deliberately separate outputs:
@@ -52,7 +52,14 @@ METHODOLOGY = "v3"
# Snapshots are reseeded on a methodology bump, but fundamental observations are # Snapshots are reseeded on a methodology bump, but fundamental observations are
# collected by hand/LLM and carried across it when the format is compatible. # collected by hand/LLM and carried across it when the format is compatible.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"}) CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
REBUILD_SESSIONS = 400
# Bumped when a fix changes what historical rows *should* contain without
# changing the live formula, so stored history needs one reseed. Deliberately
# not METHODOLOGY: that partitions the history API and discards the cached event
# study, neither of which is warranted here -- the study recomputes its Warning
# series from source rather than reading snapshots, so a reseed cannot stale it.
# Snapshots written before this marker existed carry no key and read as 1.
SENSOR_REVISION = 2
MIN_COVERAGE = 75.0 MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7 SOURCE_MAX_LAG_DAYS = 7
@@ -81,7 +88,24 @@ HY_OAS_STRESSED = 7.0
# of stress at 3.5 -- the level these anchors call "mild". The anchors already # 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 # 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. # 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 # Calendar days, and it must cover the oldest date a rebuild replays -- not just
# W3's lookback. REBUILD_SESSIONS is 400 *trading* sessions (~579 calendar
# days), so a 400-calendar-day fetch left the oldest ~180 days of a rebuild with
# no OAS at all: C1 and W3 both returned None, State landed at 80% coverage and
# Warning at exactly MIN_COVERAGE, and *both still published bands* -- a series
# that looks homogeneous while its oldest rows were scored without credit.
# Widening only prepends older observations; C1 reads [-1] and W3 reads [-21], so
# live scores are unchanged and this needs no methodology bump. Stays under
# ICE's ~3-year cap so FRED still honours the request.
HY_OAS_WINDOW_DAYS = 700
# A rebuild replays every session inside this window. Bounded by calendar days
# rather than a session count because the binding constraint is the OAS fetch:
# each replayed row needs W3's 20-business-day lookback (~28 calendar days)
# inside HY_OAS_WINDOW_DAYS, so replaying further back would recreate the exact
# credit gap a reseed exists to close. 672 days is ~464 trading sessions, which
# comfortably covers the 400-session series the v3 cutover wrote.
REBUILD_LOOKBACK_DAYS = HY_OAS_WINDOW_DAYS - 28
W3_OAS_LOOKBACK = 20 W3_OAS_LOOKBACK = 20
W3_OAS_FULL_SCALE_PCT = 35.0 W3_OAS_FULL_SCALE_PCT = 35.0
@@ -477,17 +501,29 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
return _next_weekday(fetched) if fetched else None return _next_weekday(fetched) if fetched else None
def _overlay_timing(
overrides: dict, config: dict, as_of: date
) -> tuple[date | None, bool, int | None, bool]:
"""Shared effective-date arithmetic: (effective, pending, age_days, stale)."""
effective = _fundamental_effective_date(overrides)
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 effective, pending, age, stale
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict: def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3. """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 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 400-session rebuild replays historical dates, and stamping today's LLM read
onto 2024 snapshots would be plain lookahead in the stored record. onto 2024 snapshots would be plain lookahead in the stored record.
This is the *record*. For "what do we know right now", use
``current_observation`` -- do not add a bypass flag here, because this runs
for every replayed date during a rebuild.
""" """
effective = _fundamental_effective_date(overrides) effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
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 { return {
"available": not pending and not stale, "available": not pending and not stale,
"pending": pending, "pending": pending,
@@ -504,6 +540,43 @@ def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
} }
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
"""The observation as it stands now, for the live reading only.
Same shape as ``fundamental_overlay``, but the effective date is *reported*
rather than used to blank the content. A refresh stamps
``_next_weekday(today)``, so gating the live card hid a just-collected read
for one day -- three over a weekend -- and refreshing appeared to do
nothing. Nothing here is scored, so showing it early cannot leak into a
published number; the stored snapshot keeps the gate.
"""
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
# The default override carries "unknown"/"mixed" placeholders for every
# hyperscaler. Those are the absence of an observation, not an observation
# of absence, and must never be presented as collected. ``fetched_at`` is
# the collection timestamp and is the only field written on every path that
# produces real content (LLM refresh and manual save both stamp it).
observed = bool(overrides.get("fetched_at"))
return {
"observed": observed,
# Live availability is about usefulness, not effectiveness: a pending
# observation is the freshest thing we have -- but nothing collected is
# never available.
"available": observed and not stale,
"pending": pending,
"stale": stale,
"effective_date": effective.isoformat() if effective else None,
"age_days": age,
"capex": overrides.get("capex") if observed else None,
"good_news_stock_down": overrides.get("good_news_stock_down") if observed else None,
"capex_stress": overrides.get("f1_score") if observed else None,
"earnings_stress": overrides.get("f3_score") if observed else None,
"reasoning": overrides.get("reasoning") if observed else None,
"source": overrides.get("source"),
"fetched_at": overrides.get("fetched_at"),
}
def _basket_hash(symbols: list[str]) -> str: def _basket_hash(symbols: list[str]) -> str:
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()})) canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
@@ -630,6 +703,8 @@ def _compute_index(
return { return {
"methodology": METHODOLOGY, "methodology": METHODOLOGY,
# Not part of the history filter -- only the reseed trigger.
"sensor_revision": SENSOR_REVISION,
"date": as_of.isoformat(), "date": as_of.isoformat(),
"state": state, "state": state,
"warning": warning, "warning": warning,
@@ -843,7 +918,7 @@ async def _fetch_prices(config: dict, start: date, end: date) -> dict[str, Serie
bars = await provider.fetch_ohlcv(symbol, start, end) bars = await provider.fetch_ohlcv(symbol, start, end)
out[symbol] = sorted(((b.date, float(b.close)) for b in bars), key=lambda item: item[0]) out[symbol] = sorted(((b.date, float(b.close)) for b in bars), key=lambda item: item[0])
except Exception as exc: except Exception as exc:
logger.warning("Regime monitor: price fetch failed for %s: %s", symbol, exc) logger.warning("Risk monitor: price fetch failed for %s: %s", symbol, exc)
return out return out
@@ -866,7 +941,7 @@ async def _fetch_fred_series(series_id: str, start: date, end: date) -> Series |
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
except Exception as exc: except Exception as exc:
logger.warning("Regime monitor: FRED fetch failed for %s: %s", series_id, exc) logger.warning("Risk monitor: FRED fetch failed for %s: %s", series_id, exc)
return None return None
out: Series = [] out: Series = []
@@ -889,7 +964,7 @@ async def _upsert_snapshot(
db: AsyncSession, db: AsyncSession,
result: dict, result: dict,
*, *,
rewrite_existing_v2: bool, rewrite_existing: bool,
) -> tuple[bool, dict]: ) -> tuple[bool, dict]:
snapshot_date = date.fromisoformat(result["date"]) snapshot_date = date.fromisoformat(result["date"])
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date)) existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date))
@@ -906,15 +981,23 @@ async def _upsert_snapshot(
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
)) ))
else: else:
existing_v2 = _parse_snapshot(row.breakdown_json) existing_parsed = _parse_snapshot(row.breakdown_json)
if existing_v2 is not None and not rewrite_existing_v2: if existing_parsed is not None and not rewrite_existing:
return False, existing_v2 return False, existing_parsed
row.total_score = float(state_score or 0.0) row.total_score = float(state_score or 0.0)
row.band = state_band or "unavailable" row.band = state_band or "unavailable"
row.breakdown_json = payload row.breakdown_json = payload
return True, result return True, result
def _snapshot_revision(snapshot: dict) -> int:
"""Sensor revision of a stored snapshot; pre-marker rows read as 1."""
try:
return int(snapshot.get("sensor_revision") or 1)
except (TypeError, ValueError):
return 1
def _parse_snapshot(raw: str) -> dict | None: def _parse_snapshot(raw: str) -> dict | None:
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)
@@ -934,14 +1017,16 @@ async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict]
return None return None
async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUILD_SESSIONS) -> dict: async def update_regime_monitor(
db: AsyncSession, rebuild_lookback_days: int = REBUILD_LOOKBACK_DAYS
) -> dict:
config = await get_regime_config(db) config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db) overrides = await get_fundamental_overrides(db)
if _fundamentals_stale(overrides, config) and not overrides.get("locked"): if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
try: try:
overrides = await refresh_fundamental_overrides(db, config=config) overrides = await refresh_fundamental_overrides(db, config=config)
except Exception as exc: except Exception as exc:
logger.warning("Regime monitor: fundamentals refresh skipped: %s", exc) logger.warning("Risk monitor: fundamentals refresh skipped: %s", exc)
end = date.today() end = date.today()
prices = await _fetch_prices(config, end - timedelta(days=1200), end) prices = await _fetch_prices(config, end - timedelta(days=1200), end)
@@ -965,13 +1050,21 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
) )
divergence = breadth_service.compute_divergence_series(breadth, leader_series) divergence = breadth_service.compute_divergence_series(breadth, leader_series)
except Exception as exc: except Exception as exc:
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc) logger.warning("Risk monitor: fixed-basket breadth skipped: %s", exc)
breadth, breadth_counts, divergence = {}, {}, {} breadth, breadth_counts, divergence = {}, {}, {}
latest_v2 = await _latest_snapshot_row(db) latest_snapshot = await _latest_snapshot_row(db)
rebuilding = latest_v2 is None and bool(leader_series) # A stored series written under an older sensor revision is reseeded once.
# Without this, raising HY_OAS_WINDOW_DAYS would only ever reach newly
# computed rows: routine runs touch the latest date alone, so every older row
# would keep the credit gap indefinitely.
rebuilding = bool(leader_series) and (
latest_snapshot is None
or _snapshot_revision(latest_snapshot[1]) < SENSOR_REVISION
)
if rebuilding: if rebuilding:
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]] floor = end - timedelta(days=rebuild_lookback_days)
dates = [d for d, _ in leader_series if d >= floor] or [latest_date]
else: else:
# Routine PIT rule: only the latest trading date may be inserted/updated. # Routine PIT rule: only the latest trading date may be inserted/updated.
dates = [latest_date] dates = [latest_date]
@@ -995,7 +1088,9 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
written, latest_result = await _upsert_snapshot( written, latest_result = await _upsert_snapshot(
db, db,
computed, computed,
rewrite_existing_v2=rebuilding or snapshot_date == latest_date, # True for *every* replayed date on a reseed, or it would write one
# row and leave the rest at the old revision.
rewrite_existing=rebuilding or snapshot_date == latest_date,
) )
snapshots_written += int(written) snapshots_written += int(written)
await db.commit() await db.commit()
@@ -1042,7 +1137,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
async def get_regime_monitor(db: AsyncSession) -> dict: async def get_regime_monitor(db: AsyncSession) -> dict:
latest = await _latest_snapshot_row(db) latest = await _latest_snapshot_row(db)
if latest is None: if latest is None:
return {"available": False, "reason": "v2 not computed yet"} return {"available": False, "reason": "not computed yet"}
row, result = latest row, result = latest
basket_hash = (result.get("basket") or {}).get("hash") basket_hash = (result.get("basket") or {}).get("hash")
previous_7 = await _result_at_or_before( previous_7 = await _result_at_or_before(
@@ -1071,7 +1166,9 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
# session, because otherwise refreshing it looks like it did nothing. # session, because otherwise refreshing it looks like it did nothing.
config = await get_regime_config(db) config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db) overrides = await get_fundamental_overrides(db)
live = fundamental_overlay(overrides, config, date.today()) live = current_observation(overrides, config, date.today())
# Deliberately reads the *snapshot's* overlay, not the live one: this is how
# the reader tells "shown here" from "in the stored record".
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available")) live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
result["fundamental_context"] = live result["fundamental_context"] = live
result["available"] = True result["available"] = True
+2 -2
View File
@@ -497,8 +497,8 @@ async def _compute_fundamental_score(
"reason": "Earnings surprise data not available", "reason": "Earnings surprise data not available",
}) })
# Require at least two real metrics — a single available metric (e.g. only # Require at least two real metrics — a single available metric (e.g. an
# market cap is free on FMP) does not make a meaningful fundamental score. # issuer with only a market cap) does not make a meaningful fundamental score.
MIN_METRICS = 2 MIN_METRICS = 2
if len(scores) < MIN_METRICS: if len(scores) < MIN_METRICS:
unavailable.append({ unavailable.append({
+1 -1
View File
@@ -39,7 +39,7 @@ logger = logging.getLogger(__name__)
_WWW = "https://www.sec.gov" _WWW = "https://www.sec.gov"
_DATA = "https://data.sec.gov" _DATA = "https://data.sec.gov"
# Resolve CA bundle for explicit httpx verify (matches app/providers/fmp.py). # Resolve CA bundle for explicit httpx verify (matches app/providers/alpaca.py).
_CA = os.environ.get("SSL_CERT_FILE", "") _CA = os.environ.get("SSL_CERT_FILE", "")
_CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True _CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
+6 -4
View File
@@ -40,10 +40,12 @@ KEY_CAPACITY = "shadow_book_capacity"
KEY_RISK_PCT = "shadow_book_risk_pct" KEY_RISK_PCT = "shadow_book_risk_pct"
KEY_START_EQUITY = "shadow_book_start_equity" KEY_START_EQUITY = "shadow_book_start_equity"
# Matches the validated configuration: 10-position book, 1% fixed-fractional # Matches the validated configuration: 1% fixed-fractional risk, and a count cap
# risk. Start equity is only a sizing base — comparisons are drawn in percent # set as headroom rather than a target — see backtest_service.SIM_MAX_POSITIONS,
# and R-multiples, never in raw currency. # which this must track. NOTIONAL_CAP below saturates the book near 12 positions,
DEFAULT_CAPACITY = 10 # so the count cap should simply never bind. Start equity is only a sizing base —
# comparisons are drawn in percent and R-multiples, never in raw currency.
DEFAULT_CAPACITY = 15
DEFAULT_RISK_PCT = 1.0 DEFAULT_RISK_PCT = 1.0
DEFAULT_START_EQUITY = 100_000.0 DEFAULT_START_EQUITY = 100_000.0
+8 -124
View File
@@ -113,116 +113,6 @@ def _normalise_symbols(symbols: Iterable[str]) -> list[str]:
return sorted(deduped) return sorted(deduped)
def _extract_symbols_from_fmp_payload(payload: object) -> list[str]:
if not isinstance(payload, list):
return []
symbols: list[str] = []
for item in payload:
if not isinstance(item, dict):
continue
candidate = item.get("symbol") or item.get("ticker")
if isinstance(candidate, str):
symbols.append(candidate)
return symbols
async def _try_fmp_urls(
client: httpx.AsyncClient,
urls: list[str],
) -> tuple[list[str], list[str]]:
failures: list[str] = []
for url in urls:
endpoint = url.split("?")[0]
try:
response = await client.get(url)
except httpx.HTTPError as exc:
failures.append(f"{endpoint}: network error ({type(exc).__name__}: {exc})")
continue
if response.status_code != 200:
failures.append(f"{endpoint}: HTTP {response.status_code}")
continue
try:
payload = response.json()
except ValueError:
failures.append(f"{endpoint}: invalid JSON payload")
continue
symbols = _extract_symbols_from_fmp_payload(payload)
if symbols:
return symbols, failures
failures.append(f"{endpoint}: empty/unsupported payload")
return [], failures
async def _fetch_universe_symbols_from_fmp(universe: str) -> list[str]:
if not settings.fmp_api_key:
raise ValidationError(
"FMP API key is required for universe bootstrap (set FMP_API_KEY)"
)
api_key = settings.fmp_api_key
stable_base = "https://financialmodelingprep.com/stable"
legacy_base = "https://financialmodelingprep.com/api/v3"
stable_candidates: dict[str, list[str]] = {
"sp500": [
f"{stable_base}/sp500-constituent?apikey={api_key}",
f"{stable_base}/sp500-constituents?apikey={api_key}",
],
"nasdaq100": [
f"{stable_base}/nasdaq-100-constituent?apikey={api_key}",
f"{stable_base}/nasdaq100-constituent?apikey={api_key}",
f"{stable_base}/nasdaq-100-constituents?apikey={api_key}",
],
"nasdaq_all": [
f"{stable_base}/stock-screener?exchange=NASDAQ&isEtf=false&limit=10000&apikey={api_key}",
f"{stable_base}/available-traded/list?apikey={api_key}",
],
}
legacy_candidates: dict[str, list[str]] = {
"sp500": [
f"{legacy_base}/sp500_constituent?apikey={api_key}",
f"{legacy_base}/sp500_constituent",
],
"nasdaq100": [
f"{legacy_base}/nasdaq_constituent?apikey={api_key}",
f"{legacy_base}/nasdaq_constituent",
],
"nasdaq_all": [
f"{legacy_base}/stock-screener?exchange=NASDAQ&isEtf=false&limit=10000&apikey={api_key}",
],
}
failures: list[str] = []
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
stable_symbols, stable_failures = await _try_fmp_urls(client, stable_candidates[universe])
failures.extend(stable_failures)
if stable_symbols:
return stable_symbols
legacy_symbols, legacy_failures = await _try_fmp_urls(client, legacy_candidates[universe])
failures.extend(legacy_failures)
if legacy_symbols:
return legacy_symbols
if failures:
reason = "; ".join(failures[:6])
logger.warning("FMP universe fetch failed for %s: %s", universe, reason)
raise ProviderError(
f"Failed to fetch universe symbols from FMP for '{universe}'. Attempts: {reason}"
)
raise ProviderError(f"Failed to fetch universe symbols from FMP for '{universe}'")
async def _fetch_wiki_constituent_symbols( async def _fetch_wiki_constituent_symbols(
client: httpx.AsyncClient, client: httpx.AsyncClient,
url: str, url: str,
@@ -351,13 +241,16 @@ async def fetch_universe_symbols(
Fallback order: Fallback order:
1) Free public sources (Wikipedia/NASDAQ trader) 1) Free public sources (Wikipedia/NASDAQ trader)
2) FMP endpoints (if available) 2) Cached snapshot in SystemSetting
3) Cached snapshot in SystemSetting 3) Built-in seed symbols
4) Built-in seed symbols
Returns ``(symbols, source_label)`` so bootstrap UI can show where the Returns ``(symbols, source_label)`` so bootstrap UI can show where the
list came from (important when Wikipedia/FMP fail and a stale cache still list came from (important when the public source fails and a stale cache
lists BK instead of BNY). still lists BK instead of BNY).
The seeds are representative, not complete, so a *fresh* install whose
public source is down bootstraps a partial universe. A warm instance is
unaffected it falls through to its cached snapshot.
""" """
normalised_universe = _validate_universe(universe) normalised_universe = _validate_universe(universe)
failures: list[str] = [] failures: list[str] = []
@@ -369,15 +262,6 @@ async def fetch_universe_symbols(
await _write_cached_symbols(db, normalised_universe, cleaned_public, public_source or "public") await _write_cached_symbols(db, normalised_universe, cleaned_public, public_source or "public")
return cleaned_public, public_source or "public" return cleaned_public, public_source or "public"
try:
fmp_symbols = await _fetch_universe_symbols_from_fmp(normalised_universe)
cleaned_fmp = _normalise_symbols(fmp_symbols)
if cleaned_fmp:
await _write_cached_symbols(db, normalised_universe, cleaned_fmp, "fmp")
return cleaned_fmp, "fmp"
except (ProviderError, ValidationError) as exc:
failures.append(str(exc))
cached_symbols = await _read_cached_symbols(db, normalised_universe) cached_symbols = await _read_cached_symbols(db, normalised_universe)
if cached_symbols: if cached_symbols:
logger.warning( logger.warning(
-13
View File
@@ -15,7 +15,6 @@ MIN_FREE_GB="${DOLT_MIN_FREE_DISK_GB:-5}"
EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}" EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}"
DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}" DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}"
DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}" DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}"
FUNDAMENTALS_PARITY_REPORT_DIR="${FUNDAMENTALS_PARITY_REPORT_DIR:-/var/lib/signal-platform/reports/fundamentals-parity}"
fail() { fail() {
echo "ERROR: $*" >&2 echo "ERROR: $*" >&2
@@ -80,8 +79,6 @@ check_env() {
|| fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE" || fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE"
grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \ grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \
|| fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email" || fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email"
grep -Fqx "FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR" "$ENV_FILE" \
|| fail "set FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR in $ENV_FILE"
} }
check_all() { check_all() {
@@ -104,15 +101,6 @@ check_all() {
identity_email="$(repo_config_value user.email 2>/dev/null || true)" identity_email="$(repo_config_value user.email 2>/dev/null || true)"
[[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR" [[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR"
[[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR" [[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR"
[[ -d "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|| fail "missing parity report directory: $FUNDAMENTALS_PARITY_REPORT_DIR"
if [[ "$(id -un)" == "$APP_USER" ]]; then
[[ -w "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|| fail "parity report directory is not writable by $APP_USER"
else
runuser -u "$APP_USER" -- test -w "$FUNDAMENTALS_PARITY_REPORT_DIR" \
|| fail "parity report directory is not writable by $APP_USER"
fi
check_free_space check_free_space
check_env check_env
echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned" echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned"
@@ -139,7 +127,6 @@ fi
version_ok || fail "Dolt $DOLT_VERSION installation failed" version_ok || fail "Dolt $DOLT_VERSION installation failed"
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR" install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR"
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$FUNDAMENTALS_PARITY_REPORT_DIR"
check_free_space check_free_space
if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then
+90 -43
View File
@@ -1,15 +1,18 @@
# Dolt bulk-data integration — implementation plan # Dolt bulk-data integration — implementation plan
Status: approved 2026-07-21, revised through four review rounds; direction: KISS Status: **workstream A complete and deployed** (A0A6, last step 2026-08-07);
backend, UI value first. Hand-off document for the implementing agent; **workstream B dropped 2026-08-07** — see § Why B was dropped. Approved 2026-07-21,
self-contained. revised through five review rounds; direction: KISS backend, UI value first.
Originally a hand-off document for the implementing agent; now the design record.
Current operations live in `docs/fundamentals-deployment.md`.
## Objective ## Objective
Replace the free-tier fundamentals APIs (FMP, Finnhub, Alpha Vantage) with bulk Replace the free-tier fundamentals APIs (FMP, Finnhub, Alpha Vantage) with bulk
data: SEC Company Facts for fundamentals, the DoltHub earnings repo for the data: SEC Company Facts for fundamentals and the DoltHub earnings repo for the
earnings calendar/history, and — later, independently — the DoltHub stocks repo for earnings calendar/history. PostgreSQL stays the production system of record.
historical OHLCV. PostgreSQL stays the production system of record. (A third source — the DoltHub stocks repo for historical OHLCV — was planned as
workstream B and dropped; Alpaca remains the price source.)
**Delivery order: two independent workstreams.** **Delivery order: two independent workstreams.**
@@ -17,9 +20,9 @@ historical OHLCV. PostgreSQL stays the production system of record.
FundamentalsPanel + decommission FMP/Finnhub/Alpha Vantage. Valuation uses the FundamentalsPanel + decommission FMP/Finnhub/Alpha Vantage. Valuation uses the
existing Alpaca closes already in `ohlcv_records`. This alone achieves the goal existing Alpaca closes already in `ohlcv_records`. This alone achieves the goal
(killing the quota-limited APIs) and delivers all the UI value. (killing the quota-limited APIs) and delivers all the UI value.
- **Workstream B (later, optional until needed):** replace historical OHLCV with - **Workstream B — DROPPED 2026-08-07, see below.** Would have replaced historical
the Dolt stocks repo. The most complex machinery (4.7 GB clone, split OHLCV with the Dolt stocks repo. Its design is retained further down as a record,
adjustment, source-bar table, reconciliation) lives here and blocks nothing in A. not as a backlog item.
**Guiding principle: KISS.** Plain daily importers with staging and atomic **Guiding principle: KISS.** Plain daily importers with staging and atomic
promotion — no forensic replay, no permanent archive store, no conflict tables, no promotion — no forensic replay, no permanent archive store, no conflict tables, no
@@ -70,8 +73,9 @@ notes (retain a CC BY-SA 4.0 reference + attribution to `post-no-preference/earn
and a note of the transformations applied — e.g. in a repo `NOTICE`/attribution file and a note of the transformations applied — e.g. in a repo `NOTICE`/attribution file
and the importer module); **no public API, bulk export, or redistribution** of the and the importer module); **no public API, bulk export, or redistribution** of the
data; re-review licensing before any public or commercial access. The data; re-review licensing before any public or commercial access. The
`post-no-preference/stocks` repo (workstream B) is **not** covered here and will be `post-no-preference/stocks` repo (workstream B) is **not** covered here. B was
reviewed separately if B begins. dropped before any licensing review, so that repo has never been assessed — any
future use of it starts that review from scratch.
## Schema ## Schema
@@ -119,7 +123,9 @@ reviewed separately if B begins.
cache, repopulated by the daily SEC job — but only after the phase-A5 parity cache, repopulated by the daily SEC job — but only after the phase-A5 parity
gate. gate.
**Migration 027 (workstream B, written when B starts):** **Migration 027 (workstream B — NEVER WRITTEN; B was dropped, and `027` was
subsequently used for `fundamental_snapshots.weighted_avg_diluted_shares`). The
design below is a record only:**
- `ohlcv_source_bars` — source-truth bar table, required because `ohlcv_records` - `ohlcv_source_bars` — source-truth bar table, required because `ohlcv_records`
allows one row per (ticker_id, date) (`app/models/ohlcv.py:12`) and Alpaca allows one row per (ticker_id, date) (`app/models/ohlcv.py:12`) and Alpaca
@@ -219,7 +225,7 @@ Workstream A:
**The new API valuation object is not stored anywhere** — it is computed at **The new API valuation object is not stored anywhere** — it is computed at
request time (below). No valuation cache or table exists. request time (below). No valuation cache or table exists.
Workstream B: Workstream B (dropped — never built):
- Dolt OHLCV+splits pull/import: `0 2 * * tue-sat` ET. If source_max_date is not - Dolt OHLCV+splits pull/import: `0 2 * * tue-sat` ET. If source_max_date is not
fresh, retry hourly until ~06:00, then give up quietly. After a successful fresh, retry hourly until ~06:00, then give up quietly. After a successful
@@ -430,16 +436,55 @@ workstream B — Alpaca remains the price source throughout.
approval** — see the handoff section below. Step (c) is implemented behind the approval** — see the handoff section below. Step (c) is implemented behind the
default-off `fundamental_data_sec_dolt_cutover_enabled` SystemSetting; the default-off `fundamental_data_sec_dolt_cutover_enabled` SystemSetting; the
remaining production action is flipping that switch on and observing it. remaining production action is flipping that switch on and observing it.
- A6. Remove FMP/Finnhub/Alpha Vantage; keep monitoring + manual fallback. - A6. **DONE 2026-08-07.** FMP/Finnhub/Alpha Vantage removed, along with the
weekly `fundamental_collector` job, the A5 cutover toggle (SEC+Dolt is now the
unconditional path) and the parity report. Migration `029` tombstoned the two
behavior-bearing settings rows for the rollback window and `030` dropped them
once the deploy was confirmed healthy; the archived parity bundles stay as the
A5 evidence trail.
**Workstream B (independent, start when wanted):** **Workstream B — DROPPED 2026-08-07.** The phases below are recorded for anyone
who revisits the decision; none of them are scheduled work.
- B0. Stocks clone (~4.7 GB) provisioned; migration 027. - ~~B0. Stocks clone (~4.7 GB) provisioned; migration 027.~~
- B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca - ~~B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca
keeps owning `ohlcv_records`); historical backfill. keeps owning `ohlcv_records`); historical backfill.~~
- B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries. - ~~B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries.~~
- B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source - ~~B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source
bars + splits); morning pipeline → 03:00. bars + splits); morning pipeline → 03:00.~~
### Why B was dropped
Reviewed after A6 shipped. Four reasons, in order of weight:
1. **Its motivation no longer exists.** B was scoped inside a plan whose goal was
killing the quota-limited free-tier APIs. Alpaca was never one of them, and the
plan always said so (§ Decommissioning: "Alpaca remains the price source
throughout"). A6 achieved the goal. What remained was swapping one working
price source for another.
2. **Its only concrete benefit is reachable far more cheaply.** The prize was
`corporate_actions`, the documented fix for the KLAC-class post-filing split
(TTM EPS pre-split vs a post-split price → P/E 6.19 instead of ~13, invisible to
snapshots). That needs *split events*, not 4.7 GB of bars — and the Alpaca SDK
already in the venv exposes them via
`alpaca.data.historical.corporate_actions.CorporateActionsClient.get_corporate_actions`
with `CorporateActionsRequest` / `CorporateActionsType`. See the follow-up below.
3. **The benefit is small.** Fundamentals carry 20% of the composite, P/E is one of
three fundamental inputs, and only names that split between their last 10-Q and
today are affected — a handful at a time, self-correcting at the next filing.
4. **B would add a risk the current setup does not carry.** By design a newly
published split rewrites a symbol's entire adjusted history. A backtest↔prod
parity guard exists precisely because changed history invalidates comparisons;
B makes history mutable as a routine event. It also needs its own license
review — the A0 CC BY-SA decision covers only `post-no-preference/earnings`.
**Optional follow-up, not scheduled:** a small `corporate_actions` table populated
from Alpaca, used to null or correct P/E when a split post-dates the newest
snapshot. Roughly a day's work; captures essentially all of B's value with no
clone, no `ohlcv_source_bars`, no split-adjustment pipeline and no reconciliation
window. Worth doing only if the wart starts costing something — it has been visible
and harmless since July 2026. Note that migration numbering has moved on: head is
`030`, so any such table would be `031+`, not the `027` named below.
## Test plan ## Test plan
@@ -464,8 +509,8 @@ workstream B — Alpaca remains the price source throughout.
falls back from P/E to FCF yield for the valuation segment when P/E is null. falls back from P/E to FCF yield for the valuation segment when P/E is null.
- Peer comparison disappears below 5 peer issuers; favorable-percentile direction - Peer comparison disappears below 5 peer issuers; favorable-percentile direction
correct for both polarities. correct for both polarities.
- Workstream B: split-adjusted OHLCV matches Alpaca on representative normal / - ~~Workstream B: split-adjusted OHLCV matches Alpaca on representative normal /
split / reverse-split symbols. split / reverse-split symbols.~~ (dropped)
- UI states: positive, adverse, neutral, insufficient history, insufficient - UI states: positive, adverse, neutral, insufficient history, insufficient
peers; mobile layout; non-color accessibility. peers; mobile layout; non-color accessibility.
- Unit, integration, scheduler and frontend suites pass. - Unit, integration, scheduler and frontend suites pass.
@@ -492,30 +537,32 @@ Post-fix: candidate scores 504 of 511 vs legacy's 507 (gap = PSKY/Q new registra
FITB, all explained); revenue-growth agreement 0.0038 median abs delta where both exist. 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. Dennis reviewed the evidence 2026-07-24 and directed proceeding to cutover.
**Task 1 — A5 activation (IMPLEMENTED 2026-07-24; production switch remains).** The **Task 1 — A5 activation: DONE.** Implemented 2026-07-24, switched on and observed
post-activation local refresh of `fundamental_data` derives `pe_ratio` and in production, and made unconditional by A6 (2026-08-07) — there is no longer a
`market_cap` from newest valid snapshots × latest PostgreSQL close, `revenue_growth` switch, an Admin card, or a weekly legacy collector to skip. The local refresh of
from snapshots, `earnings_surprise`/`next_earnings_date` from `earnings_events`; mark `fundamental_data` derives `pe_ratio` and `market_cap` from newest valid snapshots ×
affected cached fundamental scores stale; must run identically when SEC is unreachable. latest PostgreSQL close, `revenue_growth` from snapshots, and
`earnings_surprise`/`next_earnings_date` from `earnings_events`; it marks affected
cached fundamental scores stale and runs identically when SEC is unreachable.
It consumes `fundamentals_derivation.derive()` outputs, NOT raw snapshot fields — It consumes `fundamentals_derivation.derive()` outputs, NOT raw snapshot fields —
that path carries the split guard (`ttm_diluted_eps` that path carries the split guard (`ttm_diluted_eps` nulls when contaminated, with
nulls when contaminated, with `ttm_diluted_eps_caveat`) and the multi-class share `ttm_diluted_eps_caveat`) and the multi-class share fallback (`shares_outstanding` +
fallback (`shares_outstanding` + `shares_outstanding_estimated`). Parity and activation `shares_outstanding_estimated`). See `docs/fundamentals-deployment.md` for current
share the same candidate builder. Activation is the explicit operations and rollback.
`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 **Task 2 — A6 decommissioning: DONE 2026-08-07.** The cutover ran on and was
FMP/Finnhub/Alpha Vantage providers, config and env keys; keep monitoring + manual observed in production, so the legacy providers, their config/env keys, the weekly
fallback. Gated by the acceptance criteria above — especially forward-calendar collector job and the parity report were all removed. Two consequences to carry:
timeliness from `dolt_earnings` (its `source_max_date` ran ~5 weeks ahead as of (1) `fundamental_data` now has no provider fallback — recovery is restore-from-backup;
2026-07-23, which passes). (2) disabling **SEC Fundamentals Import** stops the SEC fetch only, because the local
cache refresh was deliberately moved outside the job-enable check. No follow-ups
remain: migration `030` dropped the tombstone rows after the deploy was verified.
**Known caveats to carry (documented in the findings report, not bugs to fix):** **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 - 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. snapshots. Still open and still harmless. The fix, if ever wanted, is a small
`corporate_actions` table fed from Alpaca — **not** workstream B, which was
dropped; see § Why B was dropped.
- BRK-B: no share count exists anywhere in companyfacts → no market cap, correctly. - 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 - FITB: unscored (split guard + no taggable revenue) — the one name that lost its
score relative to legacy; composite renormalises. score relative to legacy; composite renormalises.
@@ -528,7 +575,7 @@ timeliness from `dolt_earnings` (its `source_max_date` ran ~5 weeks ahead as of
## Deferred (explicitly, until a concrete need appears) ## Deferred (explicitly, until a concrete need appears)
- Workstream B itself is deferred relative to A and blocks nothing in A. - Workstream B: **dropped** 2026-08-07, not deferred — see § Why B was dropped.
- Exact byte-level source replay of historical imports; permanent archive store. - Exact byte-level source replay of historical imports; permanent archive store.
- Point-in-time backtest enforcement (`accepted_at` is stored now; derivation and - Point-in-time backtest enforcement (`accepted_at` is stored now; derivation and
backtest visibility rules are built only when fundamentals enter backtest visibility rules are built only when fundamentals enter
+47 -89
View File
@@ -1,17 +1,21 @@
# Fundamentals production deployment # Fundamentals production deployment
This is the one-time production setup for the Dolt earnings and SEC fundamentals This is the one-time production setup for the Dolt earnings and SEC fundamentals
imports. The A5 scoring cutover was approved on 2026-07-24; the compat-cache write imports. Since A6 (2026-08) these are the *only* fundamentals sources — the
path is still default-off until the explicit production switch below is set. Do FMP/Finnhub/Alpha Vantage providers, the weekly legacy collector and the A5 parity
not add OS cron entries: the application scheduler owns both jobs. report are gone, and the cache write path is unconditional. Do not add OS cron
entries: the application scheduler owns both jobs.
## What the deployment adds ## What the deployment adds
- `Dolt Earnings Import (shadow)` runs daily at 02:30 America/New_York. - `Dolt Earnings Import` runs daily at 02:30 America/New_York.
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York. Its local - `SEC Fundamentals Import` runs daily at 04:00 America/New_York, then refreshes
`fundamental_data` refresh runs only when the A5 switch is enabled. `fundamental_data` — the compat cache scoring reads — from stored snapshots,
- `Fundamentals Parity Report (read-only)` runs daily at 05:30 America/New_York. earnings events and closes.
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs. - Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
**Disabling the SEC job stops its SEC network fetch only**; the local cache
refresh still runs, because prices and earnings move daily even when no filing
does.
- Cron expressions are editable in Admin → Schedule. - Cron expressions are editable in Admin → Schedule.
- Every attempt is recorded in `data_import_runs`; failures also create a system - Every attempt is recorded in `data_import_runs`; failures also create a system
event. A failed validation does not promote partial data. event. A failed validation does not promote partial data.
@@ -36,14 +40,16 @@ DOLT_EARNINGS_SUBDIR=earnings
DOLT_MIN_FREE_DISK_GB=5.0 DOLT_MIN_FREE_DISK_GB=5.0
SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com) SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com)
SEC_REQUEST_SPACING_SECONDS=0.2 SEC_REQUEST_SPACING_SECONDS=0.2
FUNDAMENTALS_PARITY_REPORT_DIR=/var/lib/signal-platform/reports/fundamentals-parity
``` ```
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
path; 810 GB gives comfortable growth headroom. The data directory must stay path; 810 GB gives comfortable growth headroom. The data directory must stay
outside `/opt/signalplatform`, because deployments use `rsync --delete` there. outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
The parity-report directory is also persistent and owned by the service user;
its small timestamped JSON/CSV bundles form the temporary A5 review trail. `FMP_API_KEY`, `FINNHUB_API_KEY` and `ALPHA_VANTAGE_API_KEY` must be **removed**
from this file. Nothing reads them any more, and leaving them installed is the
one thing that would let a rolled-back pre-A6 process resume the legacy
collector and overwrite the SEC/Dolt cache.
## One-time provisioning ## One-time provisioning
@@ -80,7 +86,7 @@ a reviewed change to `DOLT_VERSION`, followed by the same provision/check flow.
In Admin → Jobs, wait until no other job is running, then: In Admin → Jobs, wait until no other job is running, then:
1. Trigger **Dolt Earnings Import (shadow)**. Expect `completed` with import 1. Trigger **Dolt Earnings Import**. Expect `completed` with import
status `promoted`; a repeat without an upstream change should report `no_op`. status `promoted`; a repeat without an upstream change should report `no_op`.
2. Trigger **SEC Fundamentals Import**. 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 tracked-universe history backfill and can take materially longer than a daily
@@ -91,27 +97,7 @@ In Admin → Jobs, wait until no other job is running, then:
data and still handles partial/missing issuers cleanly. A ticker held by the 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. quality gate should show **New setups paused** with the specific SEC reason.
## A5 parity observation window ## Verification
After both shadow imports are healthy, trigger **Fundamentals Parity Report
(read-only)** once in Admin → Jobs. The **A5 Fundamentals Parity** card above
the jobs shows the latest coverage/delta summary and provides authenticated JSON
and CSV downloads. The canonical server-side bundles are archived at:
```text
/var/lib/signal-platform/reports/fundamentals-parity/
```
The scheduler then generates one report daily at 05:30 New York time, after the
02:30 Dolt and 04:00 SEC jobs. Review 57 consecutive reports before making the
cutover decision. A report never writes `fundamental_data`, dimension/composite
scores, rankings, qualification state, or an approval flag. Materiality bands
only highlight rows for review; A5 still requires explicit approval.
Each bundle contains legacy and candidate P/E, revenue growth, and earnings
surprise; definition notes; source revisions and price dates; recomputed legacy
and candidate fundamental scores; and per-universe fundamental-rank changes.
Definition changes remain explicit even when numeric deltas are small.
Optional database verification: Optional database verification:
@@ -162,45 +148,24 @@ Expect `OK: source lock is busy`. This is the remaining live-PostgreSQL
mutual-exclusion check; SQLite unit tests cannot exercise PostgreSQL advisory mutual-exclusion check; SQLite unit tests cannot exercise PostgreSQL advisory
locks. A second Admin trigger should independently report the job as busy. locks. A second Admin trigger should independently report the job as busy.
## A5 production activation (approved 2026-07-24) ## The fundamentals cache
The write path is controlled by the SystemSetting `fundamental_data` is the compat cache scoring reads. The SEC Fundamentals
`fundamental_data_sec_dolt_cutover_enabled`. An absent value, `false`, or any Import rebuilds it every run from data already in PostgreSQL: newest valid
value other than `true` leaves `fundamental_data` untouched. Before enabling it, snapshots x latest close for `pe_ratio` and `market_cap`, snapshots alone for
confirm the normal PostgreSQL backup containing `fundamental_data` is current. `revenue_growth`, and `earnings_events` for `earnings_surprise` and
`next_earnings_date`. It therefore also runs after an SEC network/validation
failure, a `no_op`, a source-lock skip, or with the job disabled — no network
access is involved. The job message appends the cache row count and the changed
score-input count.
In **Admin → Settings → Fundamentals data source**: A refresh marks affected fundamental and composite score caches stale. The
normal 15:30 near-close scanner recomputes them before using the rankings; until
then, reads truthfully expose the stale state.
1. Turn on **Use SEC + Dolt for scoring inputs** and accept the confirmation. Verify the refreshed rows:
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 ```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, SELECT count(*) AS rows,
max(fetched_at) AS refreshed_at, max(fetched_at) AS refreshed_at,
count(pe_ratio) AS pe_available, count(pe_ratio) AS pe_available,
@@ -213,28 +178,26 @@ SELECT dimension, is_stale, count(*)
FROM dimension_scores FROM dimension_scores
WHERE dimension = 'fundamental' WHERE dimension = 'fundamental'
GROUP BY dimension, is_stale; 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 ## Failure and rollback
- To stop the A5 cache writes without stopping SEC snapshot ingestion, turn off - **There is no provider fallback any more, and no Admin switch that freezes the
**Use SEC + Dolt for scoring inputs** in Admin → Settings. If the UI is cache.** Disabling **SEC Fundamentals Import** stops SEC network access only;
unavailable, set `fundamental_data_sec_dolt_cutover_enabled` back to `false` the 04:00 job still rebuilds `fundamental_data` from the stored snapshots,
with the SQL above (changing only the value). This prevents the next local earnings events and closes.
refresh but does not restore rows already replaced. Restore `fundamental_data` - Restoring `fundamental_data` from the PostgreSQL backup is therefore a
from the pre-cutover database backup, or—before A6—manually run the legacy *temporary* fix on its own: if the bad values come from the snapshots or from
Fundamental Collector if its provider keys and quota are still available. the derivation code, the next scheduled run reproduces them. Fix the cause —
- Disable a failing source-import job in Admin → Jobs only when ingestion itself restore or repair `fundamental_snapshots` / `earnings_events`, or revert the
must stop. Existing promoted snapshots/events remain available. parser change and re-run `scripts/reparse_fundamentals.py --apply`.
- To genuinely freeze the cache while you work, stop the service
(`sudo systemctl stop signalplatform.service`) — that stops the scheduler with
it. There is no finer-grained control, by design: a silently frozen scoring
input is worse than an obvious outage.
- Disable a failing source-import job in Admin → Jobs when SEC network access
itself must stop. Existing promoted snapshots and events remain available, and
the job's runtime message still reports the cache result.
- Inspect the job runtime, latest `data_import_runs.validation_json`, service - Inspect the job runtime, latest `data_import_runs.validation_json`, service
logs, and Admin → System Events before retrying. logs, and Admin → System Events before retrying.
- `unresolved_filing` is emitted once when a filing enters automatic retry. It - `unresolved_filing` is emitted once when a filing enters automatic retry. It
@@ -250,8 +213,3 @@ least several scheduled cycles before A6 removes the legacy providers.
- The Dolt clone is a reproducible cache and does not need a bespoke backup. - The Dolt clone is a reproducible cache and does not need a bespoke backup.
PostgreSQL (including `earnings_events`, `fundamental_snapshots`, and import PostgreSQL (including `earnings_events`, `fundamental_snapshots`, and import
audit rows) must remain covered by the normal production database backup. audit rows) must remain covered by the normal production database backup.
- Do not proceed to A6 until the activated cache has completed the observation
window and the forward earnings calendar remains timely.
- If report generation fails, inspect Admin → System Events and verify
`FUNDAMENTALS_PARITY_REPORT_DIR` exists and is writable by `deploy`. Existing
reports and all live data remain untouched.
+18 -2
View File
@@ -25,7 +25,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
| 1.5× ATR initial stop | Real exit | Cuts losers fast | | 1.5× ATR initial stop | Real exit | Cuts losers fast |
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested | | 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
| Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) | | Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) |
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice | | Max **15** concurrent positions, 1% risk per trade | Sizing | Raised from 10 (2026-08-05) so the count cap never binds: +1.075pp CAGR paired, 51 paths better / 2 worse, drawdown unchanged. Cash plus the 20% notional cap saturates the book near 12. [Findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner | | Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit | | Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
@@ -61,7 +61,7 @@ invites overfitting.
|---|---| |---|---|
| ATR trail multiple {1.54.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau | | ATR trail multiple {1.54.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau |
| Momentum lookback (6-1, 3-1, 12-7 Novy-Marx, composites) | **Keep residual 12-1** — the others have IC ≈ 0 or weaker t-stats | | Momentum lookback (6-1, 3-1, 12-7 Novy-Marx, composites) | **Keep residual 12-1** — the others have IC ≈ 0 or weaker t-stats |
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 80 × 10**monotonically worse in both directions | | Selection cutoff {70…90} × book size {10, 15, 20} | **Keep cutoff 80; book size now 15**the focused daily bracket found cap 15 worth +1.075pp CAGR (the weekly replay's contrary reading was EV-per-trade). Weekly rank replacement hurt. [Findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
| Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** | | Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** |
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe | | Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor | | Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
@@ -146,6 +146,7 @@ knobs.
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. 0.048 / t 1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest | | **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. 0.048 / t 1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships | | **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR | | **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
| **Minimum effective-risk floor** | ⛔ CLOSED NEGATIVE, not run. The floor lifts EV/trade (+0.032) and PF (+0.073) *by deleting trades* — 11.4 fewer per path, never one more — and costs **0.753pp CAGR**, 0.047 Sharpe, 0.051 Calmar | Do not run the A/B; its EV-based pass rule would have shipped it. [Withdrawn specification](effective-risk-floor-ab.md) / [findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
--- ---
@@ -197,4 +198,19 @@ qualification. The [daily re-entry matrix](post-stop-reentry.md) supports this
for the current 10-position book, but not as a universal rule for other for the current 10-position book, but not as a universal rule for other
portfolio capacities. portfolio capacities.
Capacity is closed **positive**: the count cap was raised 10 → 15 so it no longer
binds, worth **+1.075pp CAGR** paired across 175 paths (51 better, 2 worse) at
unchanged drawdown. Fifteen is headroom, not a target — cap15 peaked at 12 with
zero full-book skips, so cash plus the 20% notional cap is the real ceiling.
An earlier reading of this run concluded "keep cap 10, added only 0.0018 R/trade."
That was **EV per trade**, which is the wrong metric for a treatment that changes
trade *count*: flat EV/trade means the blocked entries were as good as the taken
ones, so refusing them cost their whole contribution to return. Weekly
current-rank replacement remains rejected (0.043 EV R, 24% churn). The 0.5%
effective-risk-floor A/B is **closed negative** without being run — it costs
0.75pp of CAGR while raising EV/trade, and its frozen pass rule would have shipped
it. See the [frozen specification](portfolio-capacity-bracket.md) and the
[capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
The next real evidence is **forward**, not backward: the live paper-trade record. The next real evidence is **forward**, not backward: the live paper-trade record.
+143
View File
@@ -0,0 +1,143 @@
# Effective initial-risk floor A/B - frozen specification
> ## ⛔ CLOSED 2026-08-05 — NEGATIVE. DO NOT RUN.
>
> This A/B was never executed because the capacity-bracket run already contains
> it. `cap15_incumbent` (peak 12, zero blocked, no floor) and `cash_unbounded`
> (peak 12, floor) have the same effective capacity and differ essentially only
> by `min_initial_risk_fraction`. Paired over 175 paths, the 0.5% floor gives
> **EV/trade +0.032 and profit factor +0.073, but CAGR 0.753pp, total return
> 0.765pp, Sharpe 0.047, Calmar 0.051**, and it removes 11.4 trades per path
> while never adding one (174 worse / 0 better).
>
> **The pass rule below is unsafe.** It promotes on paired EV, and the floor
> raises EV per trade *precisely by deleting trades* that were net positive
> contributors — so this specification would have shipped a change costing
> 0.75pp of CAGR. Any successor study must decide on CAGR/total return and treat
> EV per trade as a diagnostic.
>
> See [portfolio-capacity-bracket-findings.md](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
> Retained as a record of what was specified and why it was withdrawn.
Date frozen: 2026-08-05
Branch: research/portfolio-capacity-rebalancing (deleted; tag `research/portfolio-capacity-final`)
Runner: scripts/run_portfolio_construction_matrix.py (not on main; see tag)
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.
+12
View File
@@ -28,6 +28,18 @@ Mechanics guards confirmed before reading results: calendar truncation asserted
| **Validation** | **1.68** | **0.72** | **41.6%** | **20.9%** | **1.99** | **239** | | **Validation** | **1.68** | **0.72** | **41.6%** | **20.9%** | **1.99** | **239** |
| Full (close-fill) | 1.77 | 0.50 | 48.3% | 21.6% | 2.23 | 472 | | Full (close-fill) | 1.77 | 0.50 | 48.3% | 21.6% | 2.23 | 472 |
**Capacity correction (2026-08-05):** the full close-fill control also records
skipped_book_full = 519 versus 472 admitted trades, so the ten-slot book
refuses 52.4% of admitted+blocked qualified opportunities. The older weekly
claim that the cap never bound is stale and does not apply to this daily
gate-reset configuration. Capacity was isolated in the
[focused bracket study](portfolio-capacity-bracket.md) and **resolved: the count
cap was raised 10 → 15 so it no longer binds (+1.075pp CAGR paired, 51 paths
better / 2 worse, drawdown unchanged).** Note that the blocked *count* was a poor
guide in both directions — one path had 244 blocked entries and relieving all of
them moved CAGR by 0.1pp. See the
[findings correction](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta. Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta.
--- ---
@@ -0,0 +1,219 @@
# Portfolio-capacity bracket — findings
Date interpreted: 2026-08-05
Status: **SUPERSEDED IN PART — see [Correction](#correction-2026-08-05-ev-per-trade-was-the-wrong-lens)
at the foot of this document before acting on anything here.** Weekly replacement
is closed as a negative result and that still holds. The capacity decision below
("keep cap 10") and the recommendation to run the effective-risk-floor A/B were
both reached on EV per trade and are **reversed** by the correction: the count cap
was raised so it no longer binds, and the floor A/B is closed as negative.
> The runner (`scripts/run_portfolio_construction_matrix.py`), the research
> simulator hooks, and the study's unit tests were deliberately not merged to
> main. They live at tag `research/portfolio-capacity-final`.
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.~~ **REVERSED —
see the correction below.**
2. Reject weekly rank replacement. *(Stands.)*
3. Do not interpret the `cash_unbounded` improvement as a capacity effect.
*(Stands — and it is not a floor effect worth having either; see below.)*
4. ~~Run only the focused cap-10 effective-risk-floor A/B next.~~ **REVERSED —
that A/B is answered and negative; do not run it.**
5. Report means, inert fractions, and absolute dispersion beside medians and
ratios in future sparse-treatment studies. *(Stands, and see below — the
metric itself matters as much as the summary statistic.)*
## Correction 2026-08-05: EV per trade was the wrong lens
Everything above judged the arms on **mean paired net EV per trade**. That is the
wrong metric for any treatment that changes how many trades the book takes.
Capacity does not change trade *quality*; it changes trade *count*. A flat EV/trade
delta therefore does not mean "no benefit" — it means the blocked entries were
**just as good** as the taken ones, so refusing them cost their entire
contribution to return. Re-running the same paired comparison on CAGR inverts two
conclusions.
### Capacity: raise the cap (reverses decision 1)
`cap15_incumbent` versus `cap10_incumbent`, paired, all 175 paths, 0.10% per fill:
| Metric | Mean Δ | Worse / better |
|---|---:|---:|
| Trades | +1.00 | **0 / 76** (never fewer) |
| **CAGR pp** | **+1.075** | 2 / 51 |
| Total return pp | +1.079 | 1 / 51 |
| Max drawdown pp | +0.007 | 1 / 2 |
| Calmar | +0.062 | **1 / 51** |
| Sharpe | +0.022 | 10 / 28 |
| Net EV R/trade | +0.001 | 47 / 29 |
Restricted to the 105 paths where the cap actually bound: **+1.791pp CAGR**.
The honest tail: exactly one path was materially hurt — `empty-2023-04`, CAGR
87.2 → 81.2 (6.0pp), drawdown 13.0 → 14.3, from two extra trades. Second-worst
was 0.1pp. The best paths (+6.6/+6.7/+6.9pp) came with *identical* drawdown. Best
and worst magnitudes are symmetric at roughly ±6pp, but the frequency is 51:1.
Blocked count is not lost value in either direction: `empty-2021-05` had **244**
blocked entries under cap 10, and relieving every one of them moved CAGR by
0.1pp.
**Shipped:** `SIM_MAX_POSITIONS` and `shadow_book_service.DEFAULT_CAPACITY` raised
10 → 15. Fifteen is headroom, not a target — cap15 peaked at 12 with zero
full-book skips, so cash plus the 20% notional cap is the real ceiling and
15/20/None are the same experiment.
### Effective-risk floor: closed negative (reverses decision 4)
The floor A/B does not need running — this study already contains it.
`cap15_incumbent` (peak 12, zero blocked, no floor) and `cash_unbounded` (peak 12,
floor) have the same effective capacity and differ essentially only by
`min_initial_risk_fraction`. Paired, n=175, 0.10% per fill, floor minus no-floor:
| Metric | Mean Δ | Worse / better |
|---|---:|---:|
| Net EV R/trade | **+0.032** | 53 / 121 |
| Profit factor | **+0.073** | 46 / 128 |
| Trades | **11.4** | **174 / 0** (never adds one) |
| **CAGR pp** | **0.753** | 105 / 68 |
| Total return pp | 0.765 | 105 / 68 |
| Sharpe | 0.047 | 108 / 65 |
| Calmar | 0.051 | 103 / 71 |
| Max drawdown pp | +0.333 (worse) | — |
The same trap, mirrored: the floor raises per-trade quality *precisely by deleting
trades*, and the deleted trades were net positive contributors. The frozen
specification in [effective-risk-floor-ab.md](effective-risk-floor-ab.md) would
have passed it on paired EV and shipped a change costing 0.75pp of CAGR.
Genuinely open, low priority: 0.005 clearly over-cuts, but the sizing code's real
floor is a **$1** minimum, which is no floor at all. Whether something near 0.001
strips true dust without cutting real trades is untested, and only worth revisiting
if live broker order minimums force it.
### Start-date sensitivity is real but not a capacity artifact
Within-year spread of EV across monthly start dates is ~0.672 R and is
*identical* for `cap10` (0.672), `cap15` (0.672) and `cash_unbounded` (0.677). It
is small-sample noise — roughly 84 trades per 252-session window drawn from a
fat-tailed R distribution gives an EV standard error near 0.150.25 R — not a
queueing artifact. No construction policy reduces it.
### Rule for future studies
Choose the metric from the treatment's mechanism before reading any table. If a
treatment changes trade count, CAGR and total return are the decision metrics and
EV per trade is a diagnostic. The generated report's headline tables lead with
ΔEV net R, which is what made this error easy to make twice.
+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.
+125 -5
View File
@@ -1,6 +1,10 @@
# Regime Monitor v3 methodology # AI/Tech Risk Monitor v3 methodology
The Regime Monitor is an observational AI/Tech risk thermometer. It does not Named "Regime Monitor" until 2026-08-07; the filename, the `regime_monitor` job
id, the `/regime` route and the `METHODOLOGY`/snapshot fields keep the old word,
because those are persisted or externally linked. Only the wording changed.
The AI/Tech Risk Monitor is an observational risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups. gate entries, exits, position size, ranking, or alerts about individual setups.
v3 supersedes v2. Every parameter below was calibrated against the 408 v2 v3 supersedes v2. Every parameter below was calibrated against the 408 v2
@@ -140,13 +144,42 @@ The fundamental overlay keeps its effective date (normally the next session afte
collection) and is never replayed backward, so a rebuild cannot stamp today's collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date, and the therefore reports the overlay as `pending` until the new effective date.
live reading additionally carries `fundamental_context` so a just-collected
observation is visible immediately rather than appearing to have done nothing. Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
the gate — it runs for every replayed date during a rebuild, so it must never
grow a bypass flag. `current_observation` is the **live reading** behind
`fundamental_context`, and *reports* the effective date instead of blanking the
content.
Until 2026-08-07 the live reading called the gated function, so a just-collected
observation stayed hidden until the next weekday — three days over a weekend —
and refreshing appeared to do nothing. That was the opposite of what this section
already claimed. Showing it early cannot leak into a published number, because
nothing in the overlay is scored (see "Fundamentals left the score").
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
every path writing real content stamps). Without it, the default override —
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
live observation with `available: true`, so the card presented placeholders as a
collected reading. Those are the absence of an observation, not an observation of
absence. `fundamental_overlay` never had this problem: no observation means no
effective date, which means `pending`, which already blanks the content.
Each snapshot stores the fixed basket symbols, hash, and freeze date. Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory. Reconstructed history before that freeze date is retrospective/exploratory.
## Presentation
The page is deliberately thin: two gauges, one chart card, one pillar table, the
overlay, and a provenance strip. Time and Path are two projections of the same
snapshot series and share one card and one query key — they were previously two
panels, which read as two datasets. Methodology rationale lives in this document,
not on the page; page text is limited to what changes how the reader interprets
today's number. The quadrant dividers rendered in Path view come from
`quadrant_config` and are the same constants the alert path consumes
(`alert_service`), so the chart cannot drift from what actually fires.
## Warning study ## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first The study calls the outcome a **10% correction**, not a regime break. The first
@@ -194,6 +227,93 @@ 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 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. states its limits rather than pretending to a precision it does not have.
## Open calibration questions
Raised 2026-08-07 during the page refactor. **None are implemented.** Each one
changes a published score, so acting on any of them means cutting `METHODOLOGY`
to v4 — which reseeds 400 sessions and discards the cached event study. They are
recorded here rather than hand-patched into v3.
**1. State's top band is a credit-event band.** `f2_credit_spreads` returns
`0.0` — not `None` — for any OAS below the 3.5 mild anchor, so credit stays
*available* at weight 20 and is not renormalized out. It is simply pinned at
zero. Verified: with price, breadth and volatility all pegged at 100 and OAS at
the cutover's 2.77, State computes to exactly **80.0** at 100% coverage — the
"breaking" threshold to the decimal. So the top State band requires either a
credit event or all three remaining pillars simultaneously at maximum. A pure
AI/Tech drawdown with calm credit — the scenario this monitor exists to
measure — cannot print it with anything to spare. Anchors-only credit was
nonzero on 27 of 408 calibration sessions, so that 20-point weight sits at zero
roughly 93% of the time. This is structurally the same defect v3 corrected on
the Warning axis ("the upper half of the Warning axis was unreachable"), and it
means the State bands were fit against a v2 credit distribution that v3 no
longer produces.
**2. V1 saturates at VIX 30.** `(vix - 15) / 15 * 100` reaches 100 at VIX 30 and
has no resolution above it: VIX 30, 50 and 82 all score identically. That is the
same failure mode, at a similar percentile, as the `dd_pct * 5` formula this
version replaced for pegging at a 20% drawdown. If addressed, it should get an
anchor table in the P3 style rather than a rescaled slope.
**3. `max(P1, P2, P3)` defeats P3's anchoring.** The `max` is deliberate ("one
capped vote for correlated reads"), but `_under_200` is binary, so P1 prints 100
whenever SMH and QQQ are both below their 200-DMA. P3's anchor ladder therefore
only resolves anything while price is *above* the 200-DMA — that is, before the
drawdown it measures is underway. Note also that "P3's realized share of State
falls from 65% to 40%" is argmax-share accounting, which is a slippery statistic
under `max()`.
## Fixed 2026-08-07: the OAS fetch window did not cover a rebuild
`HY_OAS_WINDOW_DAYS` was 400 **calendar** days, but a rebuild replays
`leader_series[-REBUILD_SESSIONS:]` — 400 **trading** sessions, about 579
calendar days. The oldest ~180 calendar days of any rebuild therefore got no OAS
data at all, so `f2_credit_spreads` and `w3_credit_impulse` both returned `None`.
Verified: State then lands at 80% coverage and Warning at exactly 75.0% —
`MIN_COVERAGE` — so **both still publish bands**. The rebuilt series would look
homogeneous while its oldest rows had been scored without credit, the tell being
a null `data_quality.credit_history_days` on exactly those rows.
The window is now 700 days: it must cover the oldest replayed date (~579) plus
W3's lookback and slack, while staying under ICE's ~3-year cap so FRED still
honours the request. This required **no methodology bump** — C1 reads
`oas_values[-1]` and W3 reads `oas_values[-21]`, both indexed from the end, so
widening only prepends older observations and every live score is bit-identical.
Confirmed by evaluating both windows against a varying synthetic series: today's
C1/W3 match exactly, while the oldest rebuild row goes from `None`/`None` to real
values.
Expect `credit_history_days` on new snapshots to rise from ~400 to ~700. That is
the widened request, not new upstream history — and it makes the chip a better
truncation canary, since a 700-day request returning ~1095 days' worth is now
the visible ceiling.
**Widening the window alone does not repair stored history.** Routine runs
recompute only the latest trading date, and `rebuilding` was keyed on "no v3
snapshot exists at all" — which is false once the cutover has run — so every row
already written would have kept its credit gap indefinitely. `SENSOR_REVISION`
fixes that: it is stamped into each snapshot, snapshots predating it read as 1,
and a stored revision below the current one triggers exactly one reseed.
It is deliberately not `METHODOLOGY`. That constant partitions the history API
and discards the cached event study; neither is warranted here, because the study
recomputes its Warning series from source (`_warning_series` calls
`warning_sensor_scores` against freshly fetched prices and OAS) rather than
reading snapshots, so a reseed cannot stale it.
The reseed is bounded by `REBUILD_LOOKBACK_DAYS` in calendar days rather than a
session count, because the binding constraint is the OAS fetch: each replayed row
needs W3's 20-business-day lookback inside `HY_OAS_WINDOW_DAYS`. At 672 days the
replay reaches ~464 sessions, W3's oldest requirement lands exactly on the first
fetched OAS day, and the ~400-session series the v3 cutover wrote is fully
covered. A test asserts that relationship so the two constants cannot drift into
recreating the gap.
The fix was sequenced deliberately: acting on items 13 above bumps
`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
rows into the fresh series. Fixing the window afterwards would mean reseeding
twice.
## Operator rule ## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they Quadrant alerts default off for new/reset configurations. When enabled they
-65
View File
@@ -4,7 +4,6 @@ import type {
AdminUser, AdminUser,
AlertConfig, AlertConfig,
AlertTestResult, AlertTestResult,
FundamentalsCutoverConfig,
PipelineReadiness, PipelineReadiness,
RecommendationConfig, RecommendationConfig,
ScheduleConfig, ScheduleConfig,
@@ -57,18 +56,6 @@ export function updateSetting(key: string, value: string) {
.then((r) => r.data); .then((r) => r.data);
} }
export function getFundamentalsCutoverSettings() {
return apiClient
.get<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover')
.then((r) => r.data);
}
export function updateFundamentalsCutoverSettings(enabled: boolean) {
return apiClient
.put<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover', { enabled })
.then((r) => r.data);
}
export function getRecommendationSettings() { export function getRecommendationSettings() {
return apiClient return apiClient
.get<RecommendationConfig>('admin/settings/recommendations') .get<RecommendationConfig>('admin/settings/recommendations')
@@ -246,40 +233,6 @@ export interface TriggerJobResponse {
cadence?: BacktestCadence; cadence?: BacktestCadence;
} }
export interface ParityFieldStats {
legacy_available: number;
candidate_available: number;
both_available: number;
material_differences: number;
median_absolute_delta: number | null;
p95_absolute_delta: number | null;
max_absolute_delta: number | null;
}
export interface FundamentalsParityReport {
report_version: number;
generated_at: string;
as_of_date: string;
approval_status: string;
read_only: boolean;
summary: {
universe_count: number;
legacy_fundamental_score_available: number;
candidate_fundamental_score_available: number;
fundamental_scores_compared: number;
fundamental_score_material_changes: number;
fundamental_rank_changes: number;
field_stats: Record<string, ParityFieldStats>;
};
source_runs: Record<string, {
run_id: number;
status: string;
revision: string | null;
source_max_date: string | null;
completed_at: string | null;
} | null>;
}
export type BacktestTargetModel = 'production_gtl' | 'structural_sr'; export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
export type BacktestCadence = 'weekly' | 'daily'; export type BacktestCadence = 'weekly' | 'daily';
@@ -306,24 +259,6 @@ export function triggerJob(
.then((r) => r.data); .then((r) => r.data);
} }
export function getFundamentalsParityReport() {
return apiClient
.get<FundamentalsParityReport | null>('admin/fundamentals-parity')
.then((r) => r.data);
}
export function getFundamentalsParityCsv() {
return apiClient
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/csv')
.then((r) => r.data);
}
export function getFundamentalsParityJson() {
return apiClient
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/json')
.then((r) => r.data);
}
// System events (operational warnings / errors) // System events (operational warnings / errors)
export interface SystemEvent { export interface SystemEvent {
id: number; id: number;
+1 -1
View File
@@ -14,7 +14,7 @@ export interface FetchDataResult {
} }
/** Provider sources that cost an API call/quota. */ /** Provider sources that cost an API call/quota. */
export type FetchSource = 'ohlcv' | 'sentiment' | 'fundamentals'; export type FetchSource = 'ohlcv' | 'sentiment';
/** Source selector: omit → fetch all; array → those providers; 'recompute' → derived only (free). */ /** Source selector: omit → fetch all; array → those providers; 'recompute' → derived only (free). */
export type FetchSelector = FetchSource[] | 'recompute'; export type FetchSelector = FetchSource[] | 'recompute';
@@ -21,7 +21,7 @@ const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' }, { key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers composite drops sharply' }, { key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers composite drops sharply' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' }, { key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' }, { key: 'regime_quadrant_enabled', label: 'Risk quadrant change', hint: 'the AI/Tech risk monitor shifts quadrant (hysteresis + cooldown)' },
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' }, { key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
]; ];
@@ -1,176 +0,0 @@
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>
);
}
@@ -1,156 +0,0 @@
import { useState } from 'react';
import {
getFundamentalsParityCsv,
getFundamentalsParityJson,
} from '../../api/admin';
import { useFundamentalsParityReport } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const FIELD_LABELS: Record<string, string> = {
pe_ratio: 'P/E',
revenue_growth: 'Revenue growth',
earnings_surprise: 'Earnings surprise',
};
function downloadText(filename: string, content: string, type: string) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function FundamentalsParityPanel() {
const { data: report, isLoading, isError, error } = useFundamentalsParityReport();
const [downloading, setDownloading] = useState(false);
if (isLoading) return <SkeletonTable rows={2} cols={4} />;
if (isError) {
return <p className="text-sm text-red-400">{(error as Error).message}</p>;
}
if (!report) {
return (
<div className="glass p-5">
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
<p className="mt-1 text-xs text-gray-500">
No report yet. Trigger Fundamentals Parity Report (read-only) below.
</p>
</div>
);
}
const summary = report.summary;
const generated = new Date(report.generated_at).toLocaleString();
async function downloadCsv() {
setDownloading(true);
try {
const artifact = await getFundamentalsParityCsv();
if (artifact) downloadText(artifact.filename, artifact.content, 'text/csv;charset=utf-8');
} finally {
setDownloading(false);
}
}
async function downloadJson() {
setDownloading(true);
try {
const artifact = await getFundamentalsParityJson();
if (artifact) downloadText(artifact.filename, artifact.content, 'application/json;charset=utf-8');
} finally {
setDownloading(false);
}
}
return (
<div className="glass p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
<span className="rounded-full border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-amber-300">
approval pending
</span>
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-cyan-300">
read-only
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
Generated {generated} · as of {report.as_of_date} · {summary.universe_count} tracked tickers
</p>
</div>
<div className="flex gap-2">
<button
type="button"
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white"
onClick={downloadJson}
disabled={downloading}
>
Download JSON
</button>
<button
type="button"
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white disabled:opacity-50"
onClick={downloadCsv}
disabled={downloading}
>
{downloading ? 'Preparing…' : 'Download CSV'}
</button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Summary label="Candidate score coverage" value={`${summary.candidate_fundamental_score_available}/${summary.universe_count}`} />
<Summary label="Scores compared" value={summary.fundamental_scores_compared} />
<Summary label="Material score moves" value={summary.fundamental_score_material_changes} />
<Summary label="Fundamental rank moves" value={summary.fundamental_rank_changes} />
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-[10px] uppercase tracking-wider text-gray-500">
<tr>
<th className="pb-2 pr-4 font-medium">Field</th>
<th className="pb-2 px-3 font-medium">Legacy</th>
<th className="pb-2 px-3 font-medium">Candidate</th>
<th className="pb-2 px-3 font-medium">Compared</th>
<th className="pb-2 px-3 font-medium">Material</th>
<th className="pb-2 pl-3 font-medium">Median |Δ|</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06] text-gray-300">
{Object.entries(summary.field_stats).map(([key, stats]) => (
<tr key={key}>
<td className="py-2.5 pr-4">{FIELD_LABELS[key] ?? key}</td>
<td className="py-2.5 px-3 num">{stats.legacy_available}</td>
<td className="py-2.5 px-3 num">{stats.candidate_available}</td>
<td className="py-2.5 px-3 num">{stats.both_available}</td>
<td className="py-2.5 px-3 num">{stats.material_differences}</td>
<td className="py-2.5 pl-3 num">
{stats.median_absolute_delta == null ? 'n/a' : stats.median_absolute_delta.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] leading-relaxed text-gray-500">
Materiality bands highlight review candidates only. They do not approve a cutover or write fundamentals,
scores, rankings, or qualification state.
</p>
</div>
);
}
function Summary({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-white/[0.07] bg-white/[0.025] px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="mt-1 num text-lg text-gray-200">{value}</div>
</div>
);
}
@@ -8,11 +8,9 @@ const DEFAULTS: ScheduleConfig = {
schedule_daily_pipeline_cron: '0 2 * * *', schedule_daily_pipeline_cron: '0 2 * * *',
schedule_dolt_earnings_cron: '30 2 * * *', schedule_dolt_earnings_cron: '30 2 * * *',
schedule_sec_fundamentals_cron: '0 4 * * *', schedule_sec_fundamentals_cron: '0 4 * * *',
schedule_fundamentals_parity_cron: '30 5 * * *',
schedule_near_close_pipeline_cron: '30 15 * * mon-fri', schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
schedule_after_close_pipeline_cron: '45 16 * * mon-fri', schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri', schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
schedule_fundamentals_cron: '0 1 * * mon',
}; };
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
@@ -24,25 +22,19 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
{ {
key: 'schedule_daily_pipeline_cron', key: 'schedule_daily_pipeline_cron',
label: 'Morning pipeline', label: 'Morning pipeline',
hint: 'OHLCV → benchmark → sentiment → regime → alerts (no R:R scan). Default 02:00 ET so regime-quadrant changes hit Telegram in the morning.', hint: 'OHLCV → benchmark → sentiment → trend/risk → alerts (no R:R scan). Default 02:00 ET so risk-quadrant changes hit Telegram in the morning.',
mono: true, mono: true,
}, },
{ {
key: 'schedule_dolt_earnings_cron', key: 'schedule_dolt_earnings_cron',
label: 'Dolt earnings', label: 'Dolt earnings',
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The activated cache refresh uses these local events.', hint: 'Pull and import earnings dates/results daily at 02:30 ET. The fundamentals cache refresh uses these local events.',
mono: true, mono: true,
}, },
{ {
key: 'schedule_sec_fundamentals_cron', key: 'schedule_sec_fundamentals_cron',
label: 'SEC fundamentals', label: 'SEC fundamentals',
hint: 'Import tracked-universe SEC facts daily at 04:00 ET and refresh the scoring cache when the cutover is active.', hint: 'Import tracked-universe SEC facts daily at 04:00 ET, then refresh the fundamentals cache scoring reads. Disabling the job stops the SEC fetch only — the local cache refresh still runs.',
mono: true,
},
{
key: 'schedule_fundamentals_parity_cron',
label: 'Fundamentals parity report',
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the bulk imports.',
mono: true, mono: true,
}, },
{ {
@@ -63,12 +55,6 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
hint: 'Refresh prices + resolve outcomes mid-session. Default hourly 10:0015:00 ET weekdays.', hint: 'Refresh prices + resolve outcomes mid-session. Default hourly 10:0015:00 ET weekdays.',
mono: true, mono: true,
}, },
{
key: 'schedule_fundamentals_cron',
label: 'Legacy fundamentals (weekly)',
hint: 'Fallback provider chain. Automatically skipped while the SEC + Dolt cutover is active.',
mono: true,
},
]; ];
export function ScheduleSettings() { export function ScheduleSettings() {
@@ -3,8 +3,6 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types'; import type { SystemSetting } from '../../lib/types';
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
export function SettingsForm() { export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings(); const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
@@ -34,11 +32,10 @@ export function SettingsForm() {
if (isLoading) return <SkeletonTable rows={4} cols={2} />; if (isLoading) return <SkeletonTable rows={4} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>; if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>; if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
const visibleSettings = settings.filter((setting) => !MANAGED_SETTINGS.has(setting.key));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{visibleSettings.map((setting) => ( {settings.map((setting) => (
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover"> <div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label> <label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{setting.key === 'registration' ? ( {setting.key === 'registration' ? (
+1 -1
View File
@@ -7,7 +7,7 @@ const navItems = [
{ to: '/', label: 'Overview', end: true }, { to: '/', label: 'Overview', end: true },
{ to: '/market', label: 'Market', end: false }, { to: '/market', label: 'Market', end: false },
{ to: '/signals', label: 'Signals', end: false }, { to: '/signals', label: 'Signals', end: false },
{ to: '/regime', label: 'Regime', end: false }, { to: '/regime', label: 'Risk', end: false },
]; ];
export default function MobileNav() { export default function MobileNav() {
+4 -3
View File
@@ -13,7 +13,8 @@ const navItems = [
{ to: '/', label: 'Overview', end: true }, { to: '/', label: 'Overview', end: true },
{ to: '/market', label: 'Market', end: false }, { to: '/market', label: 'Market', end: false },
{ to: '/signals', label: 'Signals', end: false }, { to: '/signals', label: 'Signals', end: false },
{ to: '/regime', label: 'Regime', end: false }, // Route stays /regime so existing links keep working; only the label changes.
{ to: '/regime', label: 'Risk', end: false },
]; ];
const linkClasses = (isActive: boolean) => const linkClasses = (isActive: boolean) =>
@@ -84,7 +85,7 @@ export default function TopBar() {
</div> </div>
<div className="ml-auto flex items-center gap-5"> <div className="ml-auto flex items-center gap-5">
{/* Market regime — ambient status; the full picture lives on /regime */} {/* SPY trend — ambient status; the full picture lives on /regime */}
{regime.data && ( {regime.data && (
<NavLink <NavLink
to="/regime" to="/regime"
@@ -99,7 +100,7 @@ export default function TopBar() {
> >
<span className={`inline-block h-1.5 w-1.5 rounded-full ${regimeDot(regime.data.label)}`} /> <span className={`inline-block h-1.5 w-1.5 rounded-full ${regimeDot(regime.data.label)}`} />
<span className="text-[11px] capitalize text-gray-500 transition-colors group-hover:text-gray-300"> <span className="text-[11px] capitalize text-gray-500 transition-colors group-hover:text-gray-300">
{regime.data.label} regime {regime.data.label} trend
</span> </span>
</NavLink> </NavLink>
)} )}
@@ -0,0 +1,308 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
Cell,
Line,
LineChart,
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
ZAxis,
} from 'recharts';
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Time and Path are two projections of one series, so they share a card and a
// query rather than sitting in two panels that look like different data.
const VIEWS = ['Time', 'Path'] as const;
type View = (typeof VIEWS)[number];
const RANGES = [
{ key: '1M', days: 30 },
{ key: '3M', days: 90 },
{ key: '6M', days: 182 },
{ key: 'All', days: Number.POSITIVE_INFINITY },
] as const;
type RangeKey = (typeof RANGES)[number]['key'];
/** Sessions drawn in Path view. The full series is unreadable as a path. */
const PATH_TRAIL = 60;
const STATE_COLOR = '#60a5fa';
const WARNING_COLOR = '#fb923c';
// Fall back to the v3 constants, not v2's shared 60/60, so a missing
// quadrant_config cannot draw dividers that disagree with the alert path.
const DEFAULT_STATE_DIVIDER = 50;
const DEFAULT_WARNING_DIVIDER = 40;
interface PathPoint {
x: number;
y: number;
date: string;
}
/** Centered moving average to de-noise the path; today (last) kept exact. */
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
const n = points.length;
return points.map((p, i) => {
if (i === n - 1) return { ...p };
let sx = 0;
let sy = 0;
let c = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
sx += points[j].x;
sy += points[j].y;
c += 1;
}
return { x: sx / c, y: sy / c, date: p.date };
});
}
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
function recencyColor(t: number): string {
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
}
function SegmentedControl<T extends string>({
options,
value,
onChange,
label,
}: {
options: readonly T[];
value: T;
onChange: (next: T) => void;
label: string;
}) {
return (
<div className="flex gap-1" role="group" aria-label={label}>
{options.map((option) => (
<button
key={option}
type="button"
aria-pressed={value === option}
onClick={() => onChange(option)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{option}
</button>
))}
</div>
);
}
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{formatDate(p.date)}</div>
<div className="text-gray-400">
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeChart() {
const [view, setView] = useState<View>('Time');
const [range, setRange] = useState<RangeKey>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const xDiv = monitor.data?.quadrant_config?.state_divider ?? DEFAULT_STATE_DIVIDER;
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? DEFAULT_WARNING_DIVIDER;
const basketAsOf = monitor.data?.basket?.basket_asof;
const series = useMemo(() => {
const data = history.data ?? [];
if (view === 'Path') {
return data
.filter((p) => p.state != null && p.warning != null)
.slice(-PATH_TRAIL);
}
const days = RANGES.find((r) => r.key === range)!.days;
if (!Number.isFinite(days)) return data;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return data.filter((p) => new Date(p.date) >= cutoff);
}, [history.data, view, range]);
const pathPoints = useMemo<PathPoint[]>(
() => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })),
[series],
);
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
const latest = view === 'Path' && pathPoints.length ? pathPoints[pathPoints.length - 1] : null;
// Only warn about pre-freeze history when the drawn window actually reaches
// back past the freeze date.
const crossesFreeze = Boolean(basketAsOf && series.length && series[0].date < basketAsOf);
const enoughData = view === 'Path' ? pathPoints.length > 0 : series.length >= 2;
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
</span>
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
</div>
{view === 'Time' ? (
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
) : (
latest && (
<span className="text-[11px] text-gray-500">
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
</span>
)
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !enoughData ? (
<Callout variant="empty">Not enough coverage-qualified history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-72">
<ResponsiveContainer width="100%" height="100%">
{view === 'Time' ? (
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
{/* width must clear a 3-digit label: the old chart paired
width 28 with margin.left -18 and clipped every tick. */}
<YAxis
domain={[0, 100]}
ticks={[0, 25, 50, 75, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={34}
tickLine={false}
axisLine={false}
/>
{/* The two axes have different thresholds, so each divider is
drawn in its series' colour rather than as shared gridlines. */}
<ReferenceLine y={xDiv} stroke={STATE_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
<ReferenceLine y={yDiv} stroke={WARNING_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
<Line type="monotone" dataKey="state" name="State" stroke={STATE_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
<Line type="monotone" dataKey="warning" name="Warning" stroke={WARNING_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
</LineChart>
) : (
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
<Scatter data={trail} line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }} isAnimationActive={false}>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
)}
/>
)}
</ScatterChart>
)}
</ResponsiveContainer>
</div>
{view === 'Time' ? (
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
State
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
Warning
</span>
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
</div>
) : (
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm, broadly supported</span>
<span><span className="text-red-400">Stabilizing</span> damage remains, warning lower</span>
<span className="text-gray-600 sm:col-span-2">White dot = today; trail brightens toward the present, smoothed.</span>
</div>
)}
{crossesFreeze && (
<p className="mt-2 text-[11px] text-gray-600">
History before {basketAsOf} is reconstructed against today's basket retrospective, not a live record.
</p>
)}
</>
)}
</div>
);
}
@@ -1,184 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ScatterChart,
Scatter,
Cell,
XAxis,
YAxis,
ZAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Quadrant boundaries come from the backend v2 methodology response.
const TRAIL = 60; // sessions shown
interface QPoint {
x: number;
y: number;
date: string;
}
/** Centered moving average to de-noise the path; today (last) kept exact. */
function smoothTrail(points: QPoint[], half = 2): QPoint[] {
const n = points.length;
return points.map((p, i) => {
if (i === n - 1) return { ...p };
let sx = 0;
let sy = 0;
let c = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
sx += points[j].x;
sy += points[j].y;
c += 1;
}
return { x: sx / c, y: sy / c, date: p.date };
});
}
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
function recencyColor(t: number): string {
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
const r = lerp(71, 96);
const g = lerp(85, 165);
const b = lerp(105, 250);
const alpha = (0.3 + 0.7 * t).toFixed(2);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div>
<div className="text-gray-400">
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const xDiv = monitor.data?.quadrant_config?.state_divider ?? 60;
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? 60;
const points = useMemo<QPoint[]>(() => {
const data = history.data ?? [];
return data
.filter((p) => p.state != null && p.warning != null)
.slice(-TRAIL)
.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date }));
}, [history.data]);
const trail = useMemo(() => smoothTrail(points), [points]);
const latest = points.length ? points[points.length - 1] : null;
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
State × Warning quadrant last {TRAIL} sessions
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<Callout variant="empty">
Not enough coverage-qualified v2 history yet.
</Callout>
) : (
<>
<div className="mt-3 h-80">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
{/* Smoothed trail with a recency gradient (old → new) */}
<Scatter
data={trail}
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
isAnimationActive={false}
>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{/* Today */}
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> state calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm and broadly supported</span>
<span><span className="text-red-400">Stressed / stabilizing</span> damage remains, warning lower</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
sessions, smoothed. The path matters more than a single point. Risk thermometer not an entry, exit,
or sizing signal.
</p>
</>
)}
</div>
);
}
@@ -1,133 +0,0 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk.
const HISTORY_RANGES = [
{ key: '1M', days: 30 },
{ key: '3M', days: 90 },
{ key: '6M', days: 182 },
{ key: 'All', days: 100000 },
] as const;
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [
{ key: 'state', label: 'State', color: '#60a5fa' },
{ key: 'warning', label: 'Warning', color: '#fb923c' },
] as const;
export default function ScoreHistoryChart() {
const [range, setRange] = useState<HistoryRange>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const filtered = useMemo(() => {
const data = history.data ?? [];
const days = HISTORY_RANGES.find((r) => r.key === range)!.days;
if (range === 'All') return data;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return data.filter((p) => new Date(p.date) >= cutoff);
}, [history.data, range]);
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Score history</div>
<div className="flex gap-1">
{HISTORY_RANGES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRange(r.key)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{r.key}
</button>
))}
</div>
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-56" />
) : filtered.length < 2 ? (
<Callout variant="empty">Not enough history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
<YAxis
domain={[0, 100]}
ticks={[0, 30, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={28}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
{HISTORY_SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color}
dot={false}
strokeWidth={1.5}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap gap-4">
{HISTORY_SERIES.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
</>
)}
</div>
);
}
-38
View File
@@ -90,36 +90,6 @@ export function useUpdateSetting() {
}); });
} }
export function useFundamentalsCutoverSettings() {
return useQuery({
queryKey: ['admin', 'fundamentals-cutover'],
queryFn: () => adminApi.getFundamentalsCutoverSettings(),
});
}
export function useUpdateFundamentalsCutoverSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (enabled: boolean) =>
adminApi.updateFundamentalsCutoverSettings(enabled),
onSuccess: (config) => {
qc.setQueryData(['admin', 'fundamentals-cutover'], config);
qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
addToast(
config.enabled ? 'success' : 'info',
config.enabled
? 'SEC + Dolt fundamentals activated'
: 'SEC + Dolt cache refresh paused',
);
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update fundamentals data source');
},
});
}
export function useRecommendationSettings() { export function useRecommendationSettings() {
return useQuery({ return useQuery({
queryKey: ['admin', 'recommendation-settings'], queryKey: ['admin', 'recommendation-settings'],
@@ -346,14 +316,6 @@ export function useJobs() {
}); });
} }
export function useFundamentalsParityReport() {
return useQuery({
queryKey: ['admin', 'fundamentals-parity'],
queryFn: () => adminApi.getFundamentalsParityReport(),
refetchInterval: 15_000,
});
}
export function usePipelineReadiness() { export function usePipelineReadiness() {
return useQuery({ return useQuery({
queryKey: ['admin', 'pipeline-readiness'], queryKey: ['admin', 'pipeline-readiness'],
+1 -1
View File
@@ -36,7 +36,7 @@ export function regimeHeadline(r: MarketRegime): string {
return `${b} ${r.label}${pct}`; return `${b} ${r.label}${pct}`;
} }
/** Whether a setup direction fights the prevailing market regime. */ /** Whether a setup direction fights the prevailing SPY trend. */
export function isCounterTrend(direction: string, label: MarketRegime['label']): boolean { export function isCounterTrend(direction: string, label: MarketRegime['label']): boolean {
if (label === 'bullish') return direction === 'short'; if (label === 'bullish') return direction === 'short';
if (label === 'bearish') return direction === 'long'; if (label === 'bearish') return direction === 'long';
+7 -7
View File
@@ -187,21 +187,15 @@ export interface ActivationConfig {
exclude_neutral: boolean; exclude_neutral: boolean;
} }
export interface FundamentalsCutoverConfig {
enabled: boolean;
}
// Cron schedule for morning / near-close / after-close / intraday + fundamentals // Cron schedule for morning / near-close / after-close / intraday + fundamentals
export interface ScheduleConfig { export interface ScheduleConfig {
schedule_timezone: string; schedule_timezone: string;
schedule_daily_pipeline_cron: string; schedule_daily_pipeline_cron: string;
schedule_dolt_earnings_cron: string; schedule_dolt_earnings_cron: string;
schedule_sec_fundamentals_cron: string; schedule_sec_fundamentals_cron: string;
schedule_fundamentals_parity_cron: string;
schedule_near_close_pipeline_cron: string; schedule_near_close_pipeline_cron: string;
schedule_after_close_pipeline_cron: string; schedule_after_close_pipeline_cron: string;
schedule_intraday_pipeline_cron: string; schedule_intraday_pipeline_cron: string;
schedule_fundamentals_cron: string;
} }
// Runtime sentiment LLM configuration // Runtime sentiment LLM configuration
@@ -506,6 +500,9 @@ export interface RegimeFundamentalOverlay {
reasoning: string | null; reasoning: string | null;
source: string | null; source: string | null;
fetched_at: string | null; fetched_at: string | null;
/** Whether anything was actually collected. Live reading only; the snapshot's
* point-in-time overlay omits it. */
observed?: boolean;
observed_in_snapshot?: boolean; observed_in_snapshot?: boolean;
} }
@@ -555,6 +552,9 @@ export interface RegimeMonitor {
inputs_fresh: boolean; inputs_fresh: boolean;
snapshot_age_days?: number; snapshot_age_days?: number;
is_fresh?: boolean; is_fresh?: boolean;
/** Upstream history spans, so a silently truncated series is visible. */
credit_history_days?: number | null;
vix_history_days?: number | null;
}; };
quadrant_config?: { state_divider: number; warning_divider: number; margin: number }; quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
} }
@@ -892,7 +892,7 @@ export interface TickerUniverseSetting {
export interface TickerUniverseBootstrapResult { export interface TickerUniverseBootstrapResult {
universe: TickerUniverse; universe: TickerUniverse;
/** Where the member list came from: wikipedia_sp500 | fmp | cache | seed | … */ /** Where the member list came from: wikipedia_sp500 | nasdaq_trader | cache | seed | … */
source?: string; source?: string;
total_universe_symbols: number; total_universe_symbols: number;
added: number; added: number;
-4
View File
@@ -5,8 +5,6 @@ import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls'; import { JobControls } from '../components/admin/JobControls';
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
import { FundamentalsCutoverSettings } from '../components/admin/FundamentalsCutoverSettings';
import { PerformanceSettings } from '../components/admin/PerformanceSettings'; import { PerformanceSettings } from '../components/admin/PerformanceSettings';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel'; import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
@@ -37,7 +35,6 @@ export default function AdminPage() {
{activeTab === 'Tickers' && <TickerManagement />} {activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && ( {activeTab === 'Settings' && (
<div className="space-y-4"> <div className="space-y-4">
<FundamentalsCutoverSettings />
<ActivationSettings /> <ActivationSettings />
<ExitPolicySettings /> <ExitPolicySettings />
<PerformanceSettings /> <PerformanceSettings />
@@ -51,7 +48,6 @@ export default function AdminPage() {
{activeTab === 'Jobs' && ( {activeTab === 'Jobs' && (
<div className="space-y-4"> <div className="space-y-4">
<ScheduleSettings /> <ScheduleSettings />
<FundamentalsParityPanel />
<JobControls /> <JobControls />
<PipelineReadinessPanel /> <PipelineReadinessPanel />
</div> </div>
+123 -70
View File
@@ -24,11 +24,11 @@ import type {
RegimeFundamentalOverlay, RegimeFundamentalOverlay,
RegimeFundamentals, RegimeFundamentals,
RegimeFundamentalsUpdate, RegimeFundamentalsUpdate,
RegimeMonitor,
RegimeReading, RegimeReading,
} from '../lib/types'; } from '../lib/types';
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart')); const RegimeChart = lazy(() => import('../components/regime/RegimeChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = { const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' }, stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' },
@@ -53,12 +53,10 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
function ScoreGauge({ function ScoreGauge({
label, label,
reading, reading,
divider,
footnote, footnote,
}: { }: {
label: string; label: string;
reading: RegimeReading | undefined; reading: RegimeReading | undefined;
divider?: number;
footnote: ReactNode; footnote: ReactNode;
}) { }) {
const score = reading?.score; const score = reading?.score;
@@ -66,7 +64,9 @@ function ScoreGauge({
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null; const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const position = Math.min(100, Math.max(0, score ?? 0)); const position = Math.min(100, Math.max(0, score ?? 0));
const bands = reading?.bands; const bands = reading?.bands;
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80]; // No fallback ticks: the two axes have different thresholds, so guessing a
// shared set would mislabel one of them. Render none rather than wrong ones.
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
return ( return (
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}> <div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
@@ -92,10 +92,10 @@ function ScoreGauge({
</div> </div>
{score != null && ( {score != null && (
<> <>
{/* The quadrant divider is each axis's watch/elevated boundary, so it
is already the middle tick below drawing it again was two marks
for one threshold. */}
<div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40"> <div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
{divider != null && (
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
)}
<div <div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`} className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`}
style={{ left: `${position}%` }} style={{ left: `${position}%` }}
@@ -113,7 +113,7 @@ function ScoreGauge({
</div> </div>
</> </>
)} )}
<p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p> <p className="mt-4 text-xs text-gray-500">{footnote}</p>
</div> </div>
); );
} }
@@ -125,30 +125,48 @@ const CAPEX_TONE: Record<CapexState, string> = {
unknown: 'text-gray-500', unknown: 'text-gray-500',
}; };
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) { function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
const capex = overlay.capex ?? {}; const capex = overlay.capex ?? {};
const reaction = overlay.good_news_stock_down; const reaction = overlay.good_news_stock_down;
// Nothing collected: the stored default is "unknown" for every hyperscaler
// and "mixed" for the reaction, which are placeholders, not a reading.
if (overlay.observed === false) {
return (
<div className="glass border border-white/[0.06] p-5">
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
<p className="mt-3 text-xs text-gray-500">
No observation collected yet. An admin can collect one under Admin · Monitor settings. It is
context only it never enters State or Warning.
</p>
</div>
);
}
return ( return (
<div className="glass border border-white/[0.06] p-5"> <div className="glass border border-white/[0.06] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500"> <div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
Fundamental overlay · context, not scored
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500"> <div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
{overlay.source && <span>{overlay.source}</span>} {overlay.source && <span>{overlay.source}</span>}
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>} {/* When pending, the line below is the single carrier of this date. */}
{overlay.effective_date && !overlay.pending && <span>· effective {overlay.effective_date}</span>}
{overlay.pending && <Badge label="pending" variant="manual" />} {overlay.pending && <Badge label="pending" variant="manual" />}
{overlay.stale && <Badge label="stale" variant="manual" />} {overlay.stale && <Badge label="stale" variant="manual" />}
</div> </div>
</div> </div>
{overlay.pending ? ( {/* A pending observation is still shown it is the freshest read we
<p className="mt-3 text-xs leading-relaxed text-amber-400/90"> have, and nothing here is scored. The date says when the stored
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}. point-in-time record picks it up. */}
Observations are never backdated, so the reading below appears from that session onward. {overlay.pending && (
<p className="mt-3 text-xs text-amber-400/90">
Shown as collected. The point-in-time record picks it up{' '}
{overlay.effective_date ?? 'next session'} observations are never backdated.
</p> </p>
) : ( )}
<>
<div className="mt-4 grid gap-4 sm:grid-cols-2"> <div className="mt-4 grid gap-4 sm:grid-cols-2">
<div> <div>
<div className="mb-2 flex items-baseline justify-between text-xs"> <div className="mb-2 flex items-baseline justify-between text-xs">
@@ -174,24 +192,20 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
</div> </div>
</div> </div>
</div> </div>
{overlay.reasoning && ( {overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
<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> </div>
); );
} }
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) { /** One table for both axes they share a shape, and two panels invited
* comparing numbers that are not on the same scale. */
function PillarTable({ state, warning }: { state: RegimeReading; warning: RegimeReading }) {
const groups: { title: string; reading: RegimeReading }[] = [
{ title: 'State', reading: state },
{ title: 'Warning', reading: warning },
];
return ( return (
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}> <Disclosure summary="Pillars & sensors · what drives each score">
<div className="overflow-x-auto rounded-lg border border-white/[0.06]"> <div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -202,7 +216,16 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
<th className="px-4 py-3 text-right font-medium">Contribution</th> <th className="px-4 py-3 text-right font-medium">Contribution</th>
</tr> </tr>
</thead> </thead>
<tbody> {groups.map(({ title, reading }) => (
<tbody key={title}>
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
{title}
<span className="ml-2 normal-case tracking-normal text-gray-600">
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
</span>
</td>
</tr>
{reading.pillars.map((pillar) => ( {reading.pillars.map((pillar) => (
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0"> <tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -218,16 +241,53 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
</td> </td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td> <td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td> <td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : '—'}</td> <td className="px-4 py-3 text-right num text-gray-300">
{pillar.available ? pillar.contribution.toFixed(1) : '—'}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
))}
</table> </table>
</div> </div>
</Disclosure> </Disclosure>
); );
} }
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
return (
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-[11px] text-gray-500" title={title}>
{label} <span className="num text-gray-400">{value}</span>
</span>
);
}
/** Provenance strip — replaces three separate prose blocks. */
function MetaStrip({ data }: { data: RegimeMonitor }) {
const quality = data.data_quality;
const basket = data.basket;
const days = (value: number | null | undefined) => (value == null ? '—' : `${value}d`);
return (
<div className="flex flex-wrap items-center gap-2">
<MetaChip label="as of" value={data.date ?? '—'} />
<MetaChip label="oldest input" value={days(quality?.oldest_market_input_age_days)} />
{basket && (
<MetaChip
label="basket"
value={`${basket.members_available ?? '—'}/${basket.members_expected} · frozen ${basket.basket_asof}`}
title={`hash ${basket.hash}`}
/>
)}
<MetaChip
label="credit history"
value={days(quality?.credit_history_days)}
title="Upstream span actually available. ICE caps the HY OAS series at 3 rolling years."
/>
<MetaChip label="VIX history" value={days(quality?.vix_history_days)} />
</div>
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) { function EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics; const metrics = report.metrics;
return ( return (
@@ -278,9 +338,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
<p> <p>
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '} <strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
{report.reliability.events_detected} detected corrections fall in the test period ( {report.reliability.events_detected} detected corrections fall in the test period (
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially {report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
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> </p>
)} )}
{report.reliability.sensor_coverage_mismatch && ( {report.reliability.sensor_coverage_mismatch && (
@@ -290,17 +348,12 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
{report.reliability.sensors_expected} Warning sensors versus{' '} {report.reliability.sensors_expected} Warning sensors versus{' '}
{report.reliability.holdout_full_sensor_share}% of test sessions {report.reliability.holdout_full_sensor_share}% of test sessions
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`} {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 . The threshold was frozen on a partly different construct than it is measured against.
different construct than it is measured against.
</p> </p>
)} )}
</div> </div>
</Callout> </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.
</p>
</div> </div>
); );
} }
@@ -375,7 +428,7 @@ function FundamentalsEditor({
</label> </label>
))} ))}
</div> </div>
<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> <p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</p>
</div> </div>
<label className="flex items-center justify-between gap-3 text-xs text-gray-400"> <label className="flex items-center justify-between gap-3 text-xs text-gray-400">
<span> <span>
@@ -450,14 +503,17 @@ export default function RegimePage() {
const isAdmin = useAuthStore((state) => state.role) === 'admin'; const isAdmin = useAuthStore((state) => state.role) === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const data = monitor.data; const data = monitor.data;
const inputs = data?.inputs;
return ( return (
<div className="space-y-6 animate-slide-up"> <div className="space-y-6 animate-slide-up">
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" /> <PageHeader
<Callout variant="info"><strong>Risk thermometer not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout> title="AI/Tech Risk Monitor"
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
/>
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>} {monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
{monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>} {monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>}
{data && !data.available && <Callout variant="empty">V2 is not computed yet run Regime Monitor from Admin Jobs or wait for the daily pipeline.</Callout>} {data && !data.available && <Callout variant="empty">Not computed yet run AI/Tech Risk Monitor from Admin Jobs or wait for the daily pipeline.</Callout>}
{data?.available && data.state && data.warning && ( {data?.available && data.state && data.warning && (
<> <>
@@ -467,39 +523,36 @@ export default function RegimePage() {
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}. {data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
</Callout> </Callout>
)} )}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<ScoreGauge <ScoreGauge
label="State · current structural stress" label="State · stress right now"
reading={data.state} reading={data.state}
divider={data.quadrant_config?.state_divider} footnote={
footnote={<>One capped price vote plus fixed-basket breadth, HY credit, and volatility. As of {data.date}. VIX {data.inputs?.vix ?? '—'} · HY OAS {data.inputs?.hy_oas ?? '—'}.</>} <>
Price, breadth, credit and volatility levels · VIX{' '}
<span className="num text-gray-400">{inputs?.vix ?? '—'}</span> · HY OAS{' '}
<span className="num text-gray-400">{inputs?.hy_oas ?? '—'}</span> · breadth{' '}
<span className="num text-gray-400">
{inputs?.breadth_pct_above_200 == null ? '—' : `${inputs.breadth_pct_above_200}%`}
</span>
</>
}
/> />
<ScoreGauge <ScoreGauge
label="Warning · deterioration & divergence" label="Warning · deterioration & divergence"
reading={data.warning} reading={data.warning}
divider={data.quadrant_config?.warning_divider} footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
footnote={<>Breadth divergence, SMH/SPY rollover, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
/> />
</div> </div>
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<p className="text-xs text-gray-600">
Data quality · oldest market input:{' '}
{data.data_quality?.oldest_market_input_age_days == null
? 'unavailable'
: `${data.data_quality.oldest_market_input_age_days}d`}
</p>
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense> <Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
<div className="grid gap-3 lg:grid-cols-2"> <PillarTable state={data.state} warning={data.warning} />
<PillarBreakdown title="State" reading={data.state} />
<PillarBreakdown title="Warning" reading={data.warning} /> {data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
</div>
{data.basket && ( <MetaStrip data={data} />
<p className="text-xs leading-relaxed text-gray-600">
Fixed basket {data.basket.members_available ?? '—'}/{data.basket.members_expected} available · hash {data.basket.hash} · frozen {data.basket.basket_asof}. History reconstructed before the freeze date is retrospective/exploratory; readings after it form the trustworthy forward series.
</p>
)}
</> </>
)} )}
+6 -3
View File
@@ -102,7 +102,7 @@ interface DataStatusItem {
available: boolean; available: boolean;
timestamp?: string | null; timestamp?: string | null;
timestampLabel?: string | null; timestampLabel?: string | null;
selector: FetchSelector; // what a refresh of this row fetches selector?: FetchSelector; // what a refresh fetches; omit for rows with no manual refresh
paid?: boolean; // provider call that may cost money/quota paid?: boolean; // provider call that may cost money/quota
} }
@@ -138,6 +138,7 @@ function DataFreshnessBar({
) : !item.available ? ( ) : !item.available ? (
<span className="text-[10px] text-gray-600">no data</span> <span className="text-[10px] text-gray-600">no data</span>
) : null} ) : null}
{item.selector && (
<button <button
onClick={() => onRefresh(item)} onClick={() => onRefresh(item)}
disabled={busy} disabled={busy}
@@ -146,6 +147,7 @@ function DataFreshnessBar({
> >
<RefreshIcon spinning={pendingLabel === item.label} /> <RefreshIcon spinning={pendingLabel === item.label} />
</button> </button>
)}
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>} {item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
</div> </div>
))} ))}
@@ -226,11 +228,11 @@ export default function TickerDetailPage() {
paid: true, paid: true,
}, },
{ {
// Rebuilt for the whole universe by the nightly SEC + Dolt imports —
// there is no per-ticker fetch to offer here.
label: 'Fundamentals', label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null, available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at, timestamp: fundamentals.data?.fetched_at,
selector: ['fundamentals'] as FetchSelector,
paid: true,
}, },
{ {
label: 'S/R Levels', label: 'S/R Levels',
@@ -247,6 +249,7 @@ export default function TickerDetailPage() {
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]); ], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
const handleRefresh = (item: DataStatusItem) => { const handleRefresh = (item: DataStatusItem) => {
if (!item.selector) return;
setRefreshingLabel(item.label); setRefreshingLabel(item.label);
ingestion.mutate( ingestion.mutate(
{ symbol, sources: item.selector }, { symbol, sources: item.selector },
+18
View File
@@ -40,3 +40,21 @@ include = ["app*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
# Pinned explicitly rather than inherited. CI installs ruff unpinned, and the
# default rule set is not stable across releases: 0.16 broadened it so far that
# `ruff check app/` went from 0 findings to 376 -- 168 of them B008 flagging
# FastAPI's `Depends()` in a signature default, which is the framework's
# documented idiom and not a defect. An unpinned linter with drifting defaults
# fails the deploy pipeline on code nobody touched, so the rule set is the thing
# to pin; the ruff version can then float freely.
#
# E4 imports, E7 statements, E9 syntax/IO errors, F pyflakes. This is the set the
# tree was already clean under, now applied repo-wide instead of to app/ alone.
# Adding rules is welcome -- do it here, deliberately, with the fixes in the same
# commit.
select = ["E4", "E7", "E9", "F"]
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.
-515
View File
@@ -1,515 +0,0 @@
"""Bulk-only historical earnings backfill for a local SQLite snapshot.
The job uses FMP's date-range earnings-calendar endpoint. One request covers all
symbols in a date window; per-symbol endpoints are intentionally not available
in this task runner. Successful windows are committed independently so a later
run resumes after a daily quota boundary without repeating completed windows.
Example:
python scripts/backfill_earnings_events.py --snapshot backtest_snapshots/prod.sqlite \
--from-date 2012-01-01 --window-days 30 --limit 250
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
FMP_STABLE = "https://financialmodelingprep.com/stable"
EVENTS_DDL = """
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
announce_date TEXT NOT NULL,
announce_time TEXT,
eps_estimate REAL,
eps_actual REAL,
revenue_estimate REAL,
revenue_actual REAL,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL,
UNIQUE(symbol, announce_date)
)
"""
META_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_meta (
symbol TEXT PRIMARY KEY,
status TEXT NOT NULL,
n_events INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT
)
"""
WINDOW_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_windows (
from_date TEXT NOT NULL,
to_date TEXT NOT NULL,
status TEXT NOT NULL,
requests INTEGER NOT NULL DEFAULT 0,
rows_raw INTEGER NOT NULL DEFAULT 0,
rows_universe INTEGER NOT NULL DEFAULT 0,
duplicate_rows INTEGER NOT NULL DEFAULT 0,
restated_rows INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT,
PRIMARY KEY(from_date, to_date)
)
"""
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
parser.add_argument("--from-date", default="2012-01-01")
parser.add_argument("--to-date", default=None)
parser.add_argument("--window-days", type=int, default=30)
parser.add_argument("--limit", type=int, default=250)
parser.add_argument("--sleep", type=float, default=0.35)
parser.add_argument(
"--refetch-windows",
action="store_true",
help="Re-fetch date windows already logged as done.",
)
return parser.parse_args()
def _ensure_tables(engine) -> None:
with engine.begin() as conn:
conn.execute(text(EVENTS_DDL))
conn.execute(text(META_DDL))
conn.execute(text(WINDOW_DDL))
def _number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _normalise_session(value: Any) -> str | None:
if value is None:
return None
cleaned = str(value).strip().lower().replace("_", " ").replace("-", " ")
aliases = {
"bmo": "bmo",
"before market open": "bmo",
"before open": "bmo",
"amc": "amc",
"after market close": "amc",
"after close": "amc",
"during market hours": "during",
"dmh": "during",
}
return aliases.get(cleaned, cleaned or None)
def _parse_bulk_item(item: dict) -> dict | None:
symbol = str(item.get("symbol") or "").strip().upper().replace(".", "-")
raw_date = item.get("date") or item.get("earningsDate")
if not symbol or not raw_date:
return None
return {
"symbol": symbol,
"announce_date": str(raw_date)[:10],
"announce_time": _normalise_session(
item.get("time") or item.get("announceTime")
),
"eps_estimate": _number(
item.get("epsEstimated")
if item.get("epsEstimated") is not None
else item.get("estimatedEarning")
),
"eps_actual": _number(
item.get("epsActual")
if item.get("epsActual") is not None
else item.get("eps")
),
"revenue_estimate": _number(item.get("revenueEstimated")),
"revenue_actual": _number(item.get("revenueActual")),
}
def _windows(start: date, end: date, window_days: int) -> list[tuple[date, date]]:
if window_days < 1:
raise ValueError("window_days must be positive")
result: list[tuple[date, date]] = []
cursor = start
while cursor <= end:
window_end = min(end, cursor + timedelta(days=window_days - 1))
result.append((cursor, window_end))
cursor = window_end + timedelta(days=1)
return result
def _dedupe_bulk_rows(rows: list[dict]) -> tuple[list[dict], int, int]:
"""Prefer the most complete duplicate; use the later row as the tie-break."""
fields = (
"announce_time",
"eps_estimate",
"eps_actual",
"revenue_estimate",
"revenue_actual",
)
chosen: dict[tuple[str, str], dict] = {}
duplicate_extras = 0
restated = 0
for row in rows:
key = (str(row["symbol"]), str(row["announce_date"]))
previous = chosen.get(key)
if previous is None:
chosen[key] = row
continue
duplicate_extras += 1
if any(
previous.get(field) is not None
and row.get(field) is not None
and previous.get(field) != row.get(field)
for field in fields
):
restated += 1
previous_score = sum(previous.get(field) is not None for field in fields)
new_score = sum(row.get(field) is not None for field in fields)
if new_score >= previous_score:
chosen[key] = row
return list(chosen.values()), duplicate_extras, restated
def _upsert_events(conn, rows: list[dict]) -> int:
if not rows:
return 0
fetched_at = datetime.now(timezone.utc).isoformat()
statement = text(
"""
INSERT INTO earnings_events (
symbol, announce_date, announce_time, eps_estimate, eps_actual,
revenue_estimate, revenue_actual, source, fetched_at
) VALUES (
:symbol, :announce_date, :announce_time, :eps_estimate, :eps_actual,
:revenue_estimate, :revenue_actual, 'fmp_earnings_calendar', :fetched_at
)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=COALESCE(excluded.announce_time, earnings_events.announce_time),
eps_estimate=COALESCE(excluded.eps_estimate, earnings_events.eps_estimate),
eps_actual=COALESCE(excluded.eps_actual, earnings_events.eps_actual),
revenue_estimate=COALESCE(excluded.revenue_estimate, earnings_events.revenue_estimate),
revenue_actual=COALESCE(excluded.revenue_actual, earnings_events.revenue_actual),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
)
conn.execute(statement, [{**row, "fetched_at": fetched_at} for row in rows])
return len(rows)
async def _fetch_bulk_window(
client: httpx.AsyncClient, api_key: str, start: date, end: date
) -> tuple[list[dict], int, str | None]:
response = await client.get(
f"{FMP_STABLE}/earnings-calendar",
params={"from": start.isoformat(), "to": end.isoformat(), "apikey": api_key},
)
if response.status_code in (402, 403):
return [], response.status_code, "bulk_endpoint_unavailable"
if response.status_code == 429:
return [], response.status_code, "daily_limit_reached"
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
return [], response.status_code, f"unexpected_payload:{type(payload).__name__}"
rows = []
for item in payload:
if isinstance(item, dict):
parsed = _parse_bulk_item(item)
if parsed:
rows.append(parsed)
return rows, response.status_code, None
def _write_window_status(
engine,
*,
start: date,
end: date,
status: str,
raw_n: int = 0,
universe_n: int = 0,
duplicate_n: int = 0,
restated_n: int = 0,
note: str | None = None,
) -> None:
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO earnings_backfill_windows(
from_date, to_date, status, requests, rows_raw, rows_universe,
duplicate_rows, restated_rows, updated_at, note
) VALUES (:a, :b, :status, 1, :raw, :uni, :dup, :rest, :now, :note)
ON CONFLICT(from_date, to_date) DO UPDATE SET
status=excluded.status,
requests=earnings_backfill_windows.requests + 1,
rows_raw=excluded.rows_raw,
rows_universe=excluded.rows_universe,
duplicate_rows=excluded.duplicate_rows,
restated_rows=excluded.restated_rows,
updated_at=excluded.updated_at,
note=excluded.note
"""
),
{
"a": start.isoformat(),
"b": end.isoformat(),
"status": status,
"raw": raw_n,
"uni": universe_n,
"dup": duplicate_n,
"rest": restated_n,
"now": datetime.now(timezone.utc).isoformat(),
"note": note,
},
)
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
from app.config import settings
if not settings.fmp_api_key:
raise SystemExit("FMP_API_KEY required")
start = date.fromisoformat(args.from_date)
end = date.fromisoformat(args.to_date) if args.to_date else date.today()
if start > end:
raise SystemExit("--from-date must not be after --to-date")
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
_ensure_tables(engine)
all_windows = _windows(start, end, int(args.window_days))
with engine.connect() as conn:
symbols = [
str(row[0]).upper().replace(".", "-")
for row in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
]
completed = {
(str(row[0]), str(row[1]))
for row in conn.execute(
text(
"SELECT from_date, to_date FROM earnings_backfill_windows "
"WHERE status='done'"
)
)
}
pending = [
window
for window in all_windows
if args.refetch_windows
or (window[0].isoformat(), window[1].isoformat()) not in completed
]
universe = set(symbols)
print(f"Snapshot: {snapshot}")
print(f"Universe: {len(symbols)} symbols")
print(f"Window: {start} -> {end}")
print(
f"Bulk windows: {len(all_windows)} total; "
f"{len(all_windows) - len(pending)} done; {len(pending)} pending"
)
print("Provider: FMP bulk earnings-calendar only")
requests_this_run = 0
rows_upserted = 0
duplicate_rows = 0
restated_rows = 0
stop_note: str | None = None
async with httpx.AsyncClient(timeout=60.0) as client:
for index, (window_start, window_end) in enumerate(pending, 1):
if requests_this_run >= int(args.limit):
stop_note = "request_budget_exhausted"
break
try:
raw_rows, status_code, error = await _fetch_bulk_window(
client, settings.fmp_api_key, window_start, window_end
)
except Exception as exc:
raw_rows, status_code = [], 0
error = f"request_error:{type(exc).__name__}:{exc}"
requests_this_run += 1
if error:
_write_window_status(
engine,
start=window_start,
end=window_end,
status="error",
note=f"http={status_code} {error}"[:300],
)
stop_note = error
print(
f"STOP {window_start}..{window_end}: {error} "
f"(http={status_code}, request={requests_this_run})"
)
break
in_universe = [row for row in raw_rows if row["symbol"] in universe]
deduped, duplicate_n, restated_n = _dedupe_bulk_rows(in_universe)
with engine.begin() as conn:
rows_upserted += _upsert_events(conn, deduped)
_write_window_status(
engine,
start=window_start,
end=window_end,
status="done",
raw_n=len(raw_rows),
universe_n=len(deduped),
duplicate_n=duplicate_n,
restated_n=restated_n,
note="bulk",
)
duplicate_rows += duplicate_n
restated_rows += restated_n
if index == 1 or index % 10 == 0 or index == len(pending):
print(
f"progress windows={index}/{len(pending)} "
f"requests={requests_this_run}/{args.limit} "
f"last={window_start}..{window_end} rows={len(deduped)}"
)
if args.sleep > 0:
await asyncio.sleep(float(args.sleep))
with engine.begin() as conn:
windows_done = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_backfill_windows "
"WHERE status='done' AND from_date >= :a AND to_date <= :b"
),
{"a": start.isoformat(), "b": end.isoformat()},
).scalar_one()
)
complete = windows_done >= len(all_windows)
if complete:
now = datetime.now(timezone.utc).isoformat()
for symbol in symbols:
count = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol=:symbol AND announce_date BETWEEN :a AND :b"
),
{"symbol": symbol, "a": start.isoformat(), "b": end.isoformat()},
).scalar_one()
)
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:symbol, 'done', :count, :now, 'bulk_complete')
ON CONFLICT(symbol) DO UPDATE SET
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
"""
),
{"symbol": symbol, "count": count, "now": now},
)
params = {"a": start.isoformat(), "b": end.isoformat()}
total_events = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b"
),
params,
).scalar_one()
)
paired_events = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b "
"AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL"
),
params,
).scalar_one()
)
date_range = conn.execute(
text(
"SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b"
),
params,
).fetchone()
done_symbols = int(
conn.execute(
text("SELECT COUNT(*) FROM earnings_backfill_meta WHERE status='done'")
).scalar_one()
)
totals = conn.execute(
text(
"SELECT COALESCE(SUM(requests),0), COALESCE(SUM(duplicate_rows),0), "
"COALESCE(SUM(restated_rows),0) FROM earnings_backfill_windows "
"WHERE from_date >= :a AND to_date <= :b"
),
params,
).fetchone()
summary = {
"mode": "fmp_bulk_date_range_only",
"window": {"from": start.isoformat(), "to": end.isoformat()},
"window_days": int(args.window_days),
"bulk_windows_total": len(all_windows),
"bulk_windows_done": windows_done,
"bulk_requests_this_run": requests_this_run,
"bulk_requests_logged_total": int(totals[0]),
"rows_upserted_this_run": rows_upserted,
"duplicate_rows_this_run": duplicate_rows,
"restated_rows_this_run": restated_rows,
"duplicate_rows_logged_total": int(totals[1]),
"restated_rows_logged_total": int(totals[2]),
"dedupe_policy": (
"UNIQUE(symbol, announce_date); prefer more non-null fields, then "
"the provider's later occurrence; non-null bulk fields replace prior "
"values while null bulk fields retain existing values"
),
"events_in_window": total_events,
"events_with_actual_and_estimate": paired_events,
"symbols_done": done_symbols,
"symbols_universe": len(symbols),
"announce_date_range": {"min": date_range[0], "max": date_range[1]},
"request_budget": int(args.limit),
"stop_note": stop_note,
"complete": complete,
}
output = Path("reports/earnings-backfill-status.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
print(f"Wrote {output}")
if __name__ == "__main__":
asyncio.run(_main())
+2 -11
View File
@@ -128,11 +128,10 @@ async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
"""Return sorted unique symbols and source labels. """Return sorted unique symbols and source labels.
Offline-safe: does **not** use production Postgres or SystemSetting cache Offline-safe: does **not** use production Postgres or SystemSetting cache
(those require a schema). Public sources first, then FMP, then seeds. (those require a schema). Public sources first, then seeds.
""" """
from app.services.ticker_universe_service import ( from app.services.ticker_universe_service import (
_SEED_UNIVERSES, _SEED_UNIVERSES,
_fetch_universe_symbols_from_fmp,
_fetch_universe_symbols_from_public, _fetch_universe_symbols_from_public,
_normalise_symbols, _normalise_symbols,
) )
@@ -150,19 +149,11 @@ async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
cleaned = _normalise_symbols(public_symbols) cleaned = _normalise_symbols(public_symbols)
if cleaned: if cleaned:
src = public_source or "public" src = public_source or "public"
else: elif public_failures:
if public_failures:
print( print(
f" WARNING: public fetch {universe}: " f" WARNING: public fetch {universe}: "
f"{'; '.join(public_failures[:3])}" f"{'; '.join(public_failures[:3])}"
) )
try:
fmp_symbols = await _fetch_universe_symbols_from_fmp(universe)
cleaned = _normalise_symbols(fmp_symbols)
if cleaned:
src = "fmp"
except Exception as exc:
print(f" WARNING: FMP fetch {universe}: {exc}")
if not cleaned: if not cleaned:
cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, [])) cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, []))
+4 -4
View File
@@ -15,10 +15,10 @@ Cost: a reparse cannot be served from the database -- the facts a fixed parser n
accepts were never stored -- so it refetches Company Facts for every tracked issuer 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. 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 Scope note: this rewrites ``fundamental_snapshots`` only. Those rows now feed both
rows feed the fundamentals API/UI and the parity report; scoring still reads the the fundamentals API/UI *and* through the nightly ``fundamental_data`` refresh
legacy ``fundamental_data`` table, so a reparse does not move composite scores or the fundamental dimension of the composite score, so a reparse does move scores
backtests until the cutover happens. and backtests. Run it deliberately.
Examples Examples
-------- --------
+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
+4 -79
View File
@@ -29,6 +29,10 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
)
POLICY_NAMES = ( POLICY_NAMES = (
"immediate", "immediate",
"next_session", "next_session",
@@ -107,85 +111,6 @@ def _default_output_path() -> Path:
return Path("reports") / f"daily-reentry-matrix-{stamp}.json" return Path("reports") / f"daily-reentry-matrix-{stamp}.json"
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
"""Production-style percentiles, one deterministic symbol row per period."""
by_period: dict[tuple, list[dict]] = {}
seen: set[tuple[str, str]] = set()
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
if identity in seen:
raise ValueError(f"Duplicate universe rank observation: {identity}")
seen.add(identity)
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group,
key=lambda row: (float(row[value_key]), str(row["symbol"])),
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
"""Historical equivalent of ``compute_activation_ranks``.
Every ticker contributes at most once per session. Residual momentum starts
only once 252 benchmark closes were point-in-time available; earlier dates
use the same raw-momentum fallback as production.
"""
identities = [(str(row["symbol"]), str(row["date"])) for row in observations]
if len(identities) != len(set(identities)):
raise ValueError("Universe ranking requires one observation per ticker/date")
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
class PrecomputedDailyEngine: class PrecomputedDailyEngine:
"""Exact date/symbol lookup over the already-ranked production gate.""" """Exact date/symbol lookup over the already-ranked production gate."""
+4 -60
View File
@@ -55,6 +55,10 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
)
# Must match Phase A cache when reusing research-cands.pkl # Must match Phase A cache when reusing research-cands.pkl
CACHE_VERSION = "research-matrix-v1-daily-prod" CACHE_VERSION = "research-matrix-v1-daily-prod"
@@ -104,66 +108,6 @@ def _parse_args() -> argparse.Namespace:
return p.parse_args() return p.parse_args()
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
by_period: dict[tuple, list[dict]] = {}
for row in observations:
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
def _window(arm: dict, name: str) -> dict | None: def _window(arm: dict, name: str) -> dict | None:
for row in arm.get("windows") or []: for row in arm.get("windows") or []:
if row.get("window") == name: if row.get("window") == name:
+6 -7
View File
@@ -162,13 +162,13 @@ def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
if len(rows) < 90: if len(rows) < 90:
return None return None
ords, opens, highs, lows, closes, vols = [], [], [], [], [], [] ords, opens, highs, lows, closes, vols = [], [], [], [], [], []
for d, o, h, l, c, v in rows: for d, o, h, lo, c, v in rows:
if isinstance(d, str): if isinstance(d, str):
d = date.fromisoformat(d[:10]) d = date.fromisoformat(d[:10])
ords.append(d.toordinal()) ords.append(d.toordinal())
opens.append(float(o)) opens.append(float(o))
highs.append(float(h)) highs.append(float(h))
lows.append(float(l)) lows.append(float(lo))
closes.append(float(c)) closes.append(float(c))
vols.append(float(v or 0)) vols.append(float(v or 0))
return (symbol, ords, opens, highs, lows, closes, vols, spy) return (symbol, ords, opens, highs, lows, closes, vols, spy)
@@ -275,7 +275,8 @@ def main() -> None:
vol_weeks = collected.get("vol_6m") or {} vol_weeks = collected.get("vol_6m") or {}
momr_weeks = collected.get("mom_12_1_resid") or {} momr_weeks = collected.get("mom_12_1_resid") or {}
# Index mom/vol by (week, symbol) for joins # Index mom by (week, symbol) for joins. vol/momr are consumed as week maps
# directly further down, so they need no index.
def _index(weeks_map: dict) -> dict[tuple, dict]: def _index(weeks_map: dict) -> dict[tuple, dict]:
out: dict[tuple, dict] = {} out: dict[tuple, dict] = {}
for wk, recs in weeks_map.items(): for wk, recs in weeks_map.items():
@@ -290,8 +291,6 @@ def main() -> None:
return out return out
mom_ix = _index(mom_weeks) mom_ix = _index(mom_weeks)
vol_ix = _index(vol_weeks)
momr_ix = _index(momr_weeks)
# Per-week membership + extended checks via shared rich filter # Per-week membership + extended checks via shared rich filter
same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list) same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
@@ -606,8 +605,8 @@ def _update_md(path: Path, results: dict, artifact: Path) -> None:
"", "",
"### Authoritative unconditional fip (liquid top-N, post-mask)", "### Authoritative unconditional fip (liquid top-N, post-mask)",
"", "",
f"| metric | value |", "| metric | value |",
f"|---|---|", "|---|---|",
f"| mean_ic | {h.get('mean_ic')} |", f"| mean_ic | {h.get('mean_ic')} |",
f"| ic_t_stat | {h.get('ic_t_stat')} |", f"| ic_t_stat | {h.get('ic_t_stat')} |",
f"| weeks | {h.get('weeks')} |", f"| weeks | {h.get('weeks')} |",
+2 -2
View File
@@ -162,8 +162,8 @@ def _write_md(path: Path, payload: dict) -> None:
row = br.get("row") or br row = br.get("row") or br
if row: if row:
lines.extend([ lines.extend([
f"| metric | value |", "| metric | value |",
f"|---|---|", "|---|---|",
f"| mean_ic | {row.get('mean_ic')} |", f"| mean_ic | {row.get('mean_ic')} |",
f"| ic_t_stat | {row.get('ic_t_stat')} |", f"| ic_t_stat | {row.get('ic_t_stat')} |",
f"| ic_positive_pct | {row.get('ic_positive_pct')} |", f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
-1
View File
@@ -26,7 +26,6 @@ import os
import pickle import pickle
import sys import sys
import time import time
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
+4 -60
View File
@@ -68,6 +68,10 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
)
CACHE_VERSION = "research-matrix-v1-daily-prod" CACHE_VERSION = "research-matrix-v1-daily-prod"
# Pre-registered arm catalogue (order is report order). Control is a0. # Pre-registered arm catalogue (order is report order). Control is a0.
@@ -210,66 +214,6 @@ def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}" return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
by_period: dict[tuple, list[dict]] = {}
for row in observations:
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=__doc__, description=__doc__,
+1 -17
View File
@@ -3,7 +3,6 @@
# #
# Kept after Tier-1 cleanup: # Kept after Tier-1 cleanup:
# --ssl-check diagnose corporate CA / proxy # --ssl-check diagnose corporate CA / proxy
# --earnings-only resume FMP earnings backfill + 2a/2b (parked)
# --prod-book-matrix re-run 505 vs liquid universe × horizon book matrix # --prod-book-matrix re-run 505 vs liquid universe × horizon book matrix
# #
# Prerequisites: git checkout research branch, .env, deep research.sqlite for # Prerequisites: git checkout research branch, .env, deep research.sqlite for
@@ -21,8 +20,6 @@ cd "$ROOT"
RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}" RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}"
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}" PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
WORKERS="${WORKERS:-8}" WORKERS="${WORKERS:-8}"
FMP_LIMIT="${FMP_LIMIT:-250}"
FMP_SLEEP="${FMP_SLEEP:-0.35}"
PYTHON="${PYTHON:-python3}" PYTHON="${PYTHON:-python3}"
USE_CORP_PROXY="${USE_CORP_PROXY:-0}" USE_CORP_PROXY="${USE_CORP_PROXY:-0}"
PHASE="" PHASE=""
@@ -35,7 +32,6 @@ usage() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--ssl-check) PHASE=ssl; shift ;; --ssl-check) PHASE=ssl; shift ;;
--earnings-only) PHASE=earnings; shift ;;
--prod-book-matrix) PHASE=prod_book; shift ;; --prod-book-matrix) PHASE=prod_book; shift ;;
--corp-proxy) USE_CORP_PROXY=1; shift ;; --corp-proxy) USE_CORP_PROXY=1; shift ;;
--workers) WORKERS="$2"; shift 2 ;; --workers) WORKERS="$2"; shift 2 ;;
@@ -46,7 +42,7 @@ while [[ $# -gt 0 ]]; do
done done
if [[ -z "$PHASE" ]]; then if [[ -z "$PHASE" ]]; then
echo "Pick a phase: --ssl-check | --earnings-only | --prod-book-matrix" >&2 echo "Pick a phase: --ssl-check | --prod-book-matrix" >&2
usage 1 usage 1
fi fi
@@ -104,7 +100,6 @@ print(json.dumps(ssl_status(), indent=2))
print("bootstrap ->", bootstrap_ssl()) print("bootstrap ->", bootstrap_ssl())
for url in ( for url in (
"https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&limit=1", "https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&limit=1",
"https://financialmodelingprep.com/stable/profile?symbol=AAPL",
): ):
try: try:
req = urllib.request.Request(url, headers={"User-Agent": "ssl-check"}) req = urllib.request.Request(url, headers={"User-Agent": "ssl-check"})
@@ -118,17 +113,6 @@ PY
setup_ssl setup_ssl
case "$PHASE" in case "$PHASE" in
ssl) ssl_check ;; ssl) ssl_check ;;
earnings)
need_file "$PROD_SNAP"
need_file "$RESEARCH_SNAP"
log "Earnings Task 2 bulk backfill + registered 2a/2b closeout"
"$PYTHON" scripts/backfill_earnings_events.py \
--snapshot "$PROD_SNAP" --from-date 2016-01-04 --window-days 30 \
--limit "$FMP_LIMIT" --sleep "$FMP_SLEEP"
"$PYTHON" scripts/run_earnings_research.py \
--snapshot "$RESEARCH_SNAP" --universe-snapshot "$PROD_SNAP" \
--earnings-snapshot "$PROD_SNAP" --workers "$WORKERS" --allow-spawn
;;
prod_book) prod_book)
need_file "$RESEARCH_SNAP" need_file "$RESEARCH_SNAP"
log "Production book universe × horizon matrix" log "Production book universe × horizon matrix"
-17
View File
@@ -8,9 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ValidationError from app.exceptions import ValidationError
from app.services.admin_service import ( from app.services.admin_service import (
get_activation_config, get_activation_config,
get_fundamentals_cutover_config,
update_activation_config, update_activation_config,
update_fundamentals_cutover_config,
) )
@@ -78,18 +76,3 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession): async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_activation_config(session, {"min_confidence": 120.0}) await update_activation_config(session, {"min_confidence": 120.0})
class TestFundamentalsCutoverConfig:
async def test_defaults_off_when_unset(self, session: AsyncSession):
assert await get_fundamentals_cutover_config(session) == {"enabled": False}
async def test_round_trips_explicit_switch(self, session: AsyncSession):
assert await update_fundamentals_cutover_config(session, True) == {
"enabled": True
}
assert await get_fundamentals_cutover_config(session) == {"enabled": True}
assert await update_fundamentals_cutover_config(session, False) == {
"enabled": False
}
+20 -4
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import tempfile import tempfile
from datetime import date from datetime import date, timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -252,13 +252,29 @@ async def test_real_clone_smoke(engine):
# A few tickers spanning near + further-out reporters so the initial-load # A few tickers spanning near + further-out reporters so the initial-load
# forward-horizon gate (>= 21d) is satisfied on the fixed clone. # forward-horizon gate (>= 21d) is satisfied on the fixed clone.
await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"]) await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"])
# "today" is anchored to the clone, NOT the wall clock. The clone is fixed
# and do_pull=False, so a wall-clock today makes this test decay: the
# forward horizon shrinks a day per real day and eventually trips the
# >= 21d gate (it did, at 19d). Anchoring keeps it time-stable. Production
# pulls fresh data and is unaffected. Dot-free symbols only, so the query
# needs no symbol normalisation.
rows = await dolt_client.query_csv(
_CLONE_DIR,
"SELECT MAX(`date`) AS max_date FROM earnings_calendar "
"WHERE act_symbol IN ('AAPL', 'MSFT', 'NVDA', 'JPM')",
binary=_DOLT_BIN,
)
max_date = date.fromisoformat(rows[0]["max_date"])
today = max_date - timedelta(days=35) # ~35d horizon, per the importer's note
imp = DoltEarningsImporter( imp = DoltEarningsImporter(
repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=date.today(), do_pull=False, dolt=dolt_client repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=today, do_pull=False, dolt=dolt_client
) )
run = await run_import(imp, engine=engine) run = await run_import(imp, engine=engine)
assert run.status == STATUS_PROMOTED assert run.status == STATUS_PROMOTED, run.error_details
events = await _events(factory) events = await _events(factory)
assert events, "no earnings parsed from the real clone" assert events, "no earnings parsed from the real clone"
assert any(e.announce_date > date.today() for e in events), "no forward calendar" assert any(e.announce_date > today for e in events), "no forward calendar"
assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing" assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing"
-37
View File
@@ -1,6 +1,5 @@
from datetime import date, timedelta from datetime import date, timedelta
from scripts.backfill_earnings_events import _dedupe_bulk_rows, _windows
from scripts.import_dolthub_earnings import _align_symbol from scripts.import_dolthub_earnings import _align_symbol
from scripts.run_earnings_research import ( from scripts.run_earnings_research import (
_analyse_2a_trades, _analyse_2a_trades,
@@ -41,42 +40,6 @@ def test_dolthub_alignment_allows_fiscal_period_label_after_announcement() -> No
assert matches == [(0, 0), (1, 1)] assert matches == [(0, 0), (1, 1)]
def test_bulk_windows_cover_range_without_overlap() -> None:
result = _windows(date(2020, 1, 1), date(2020, 1, 10), 4)
assert result == [
(date(2020, 1, 1), date(2020, 1, 4)),
(date(2020, 1, 5), date(2020, 1, 8)),
(date(2020, 1, 9), date(2020, 1, 10)),
]
def test_bulk_dedupe_prefers_more_complete_and_counts_restatement() -> None:
rows = [
{
"symbol": "AAPL",
"announce_date": "2024-01-01",
"announce_time": None,
"eps_estimate": 1.0,
"eps_actual": 1.1,
"revenue_estimate": None,
"revenue_actual": None,
},
{
"symbol": "AAPL",
"announce_date": "2024-01-01",
"announce_time": "amc",
"eps_estimate": 1.0,
"eps_actual": 1.2,
"revenue_estimate": 10.0,
"revenue_actual": 11.0,
},
]
deduped, duplicates, restated = _dedupe_bulk_rows(rows)
assert duplicates == 1
assert restated == 1
assert deduped == [rows[1]]
def test_2a_uses_net_r_strict_hold_and_next_session_stop() -> None: def test_2a_uses_net_r_strict_hold_and_next_session_stop() -> None:
calendar = [ calendar = [
date(2024, 1, 2), date(2024, 1, 2),
-92
View File
@@ -1,92 +0,0 @@
"""Unit tests for FinnhubFundamentalProvider unit conversions."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from app.providers.fundamentals_chain import FinnhubFundamentalProvider
def _mock_response(status_code: int, json_data: object = None) -> httpx.Response:
return httpx.Response(
status_code=status_code,
json=json_data if json_data is not None else {},
request=httpx.Request("GET", "https://example.com"),
)
@pytest.fixture
def provider() -> FinnhubFundamentalProvider:
return FinnhubFundamentalProvider(api_key="test-key")
@pytest.mark.asyncio
async def test_finnhub_market_cap_converted_from_millions_to_dollars(provider):
"""Finnhub marketCapitalization is in millions — store absolute USD.
SPCX-scale example: ~$1.8T Finnhub reports 1_800_000 (millions).
Without conversion the UI showed 1.8M / micro cap.
"""
profile = {"marketCapitalization": 1_800_000} # millions → $1.8T
metrics = {"metric": {"peTTM": 40.0, "revenueGrowthTTMYoy": 25.0}}
earnings = [{"surprisePercent": 2.5}]
calendar = {"earningsCalendar": []}
async def mock_get(url, params=None):
if "profile2" in url:
return _mock_response(200, profile)
if "stock/metric" in url:
return _mock_response(200, metrics)
if "stock/earnings" in url:
return _mock_response(200, earnings)
if "calendar/earnings" in url:
return _mock_response(200, calendar)
return _mock_response(200, {})
with patch("app.providers.fundamentals_chain.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.get.side_effect = mock_get
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
result = await provider.fetch_fundamentals("SPCX")
assert result.market_cap == 1_800_000 * 1_000_000 # $1.8T
assert result.pe_ratio == 40.0
assert result.revenue_growth == 25.0
assert result.earnings_surprise == 2.5
@pytest.mark.asyncio
async def test_finnhub_market_cap_none_when_missing(provider):
profile: dict = {}
metrics = {"metric": {}}
earnings: list = []
calendar = {"earningsCalendar": []}
async def mock_get(url, params=None):
if "profile2" in url:
return _mock_response(200, profile)
if "stock/metric" in url:
return _mock_response(200, metrics)
if "stock/earnings" in url:
return _mock_response(200, earnings)
if "calendar/earnings" in url:
return _mock_response(200, calendar)
return _mock_response(200, {})
with patch("app.providers.fundamentals_chain.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.get.side_effect = mock_get
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
result = await provider.fetch_fundamentals("XYZ")
assert result.market_cap is None
assert "market_cap" in result.unavailable_fields
-156
View File
@@ -1,156 +0,0 @@
"""Unit tests for FMPFundamentalProvider 402 reason recording."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from app.providers.fmp import FMPFundamentalProvider
def _mock_response(status_code: int, json_data: object = None) -> httpx.Response:
"""Build a fake httpx.Response."""
resp = httpx.Response(
status_code=status_code,
json=json_data if json_data is not None else {},
request=httpx.Request("GET", "https://example.com"),
)
return resp
@pytest.fixture
def provider() -> FMPFundamentalProvider:
return FMPFundamentalProvider(api_key="test-key")
class TestFetchJsonOptional402Tracking:
"""_fetch_json_optional returns (data, was_402) tuple."""
@pytest.mark.asyncio
async def test_returns_empty_dict_and_true_on_402(self, provider):
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(402)
data, was_402 = await provider._fetch_json_optional(
mock_client, "ratios-ttm", {}, "AAPL"
)
assert data == {}
assert was_402 is True
@pytest.mark.asyncio
async def test_returns_data_and_false_on_200(self, provider):
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(
200, [{"priceToEarningsRatioTTM": 25.5}]
)
data, was_402 = await provider._fetch_json_optional(
mock_client, "ratios-ttm", {}, "AAPL"
)
assert data == {"priceToEarningsRatioTTM": 25.5}
assert was_402 is False
class TestFetchFundamentals402Recording:
"""fetch_fundamentals records 402 endpoints in unavailable_fields."""
@pytest.mark.asyncio
async def test_all_402_records_all_fields(self, provider):
"""When all supplementary endpoints return 402, all three fields are recorded."""
profile_resp = _mock_response(200, [{"marketCap": 1_000_000}])
ratios_resp = _mock_response(402)
growth_resp = _mock_response(402)
earnings_resp = _mock_response(402)
async def mock_get(url, params=None):
if "profile" in url:
return profile_resp
if "ratios-ttm" in url:
return ratios_resp
if "financial-growth" in url:
return growth_resp
if "earnings" in url:
return earnings_resp
return _mock_response(200, [{}])
with patch("app.providers.fmp.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.get.side_effect = mock_get
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
result = await provider.fetch_fundamentals("AAPL")
assert result.unavailable_fields == {
"pe_ratio": "requires paid plan",
"revenue_growth": "requires paid plan",
"earnings_surprise": "requires paid plan",
}
@pytest.mark.asyncio
async def test_mixed_200_402_records_only_402_fields(self, provider):
"""When only ratios-ttm returns 402, only pe_ratio is recorded."""
profile_resp = _mock_response(200, [{"marketCap": 2_000_000}])
ratios_resp = _mock_response(402)
growth_resp = _mock_response(200, [{"revenueGrowth": 0.15}])
earnings_resp = _mock_response(200, [{"epsActual": 3.0, "epsEstimated": 2.5}])
async def mock_get(url, params=None):
if "profile" in url:
return profile_resp
if "ratios-ttm" in url:
return ratios_resp
if "financial-growth" in url:
return growth_resp
if "earnings" in url:
return earnings_resp
return _mock_response(200, [{}])
with patch("app.providers.fmp.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.get.side_effect = mock_get
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
result = await provider.fetch_fundamentals("AAPL")
assert result.unavailable_fields == {"pe_ratio": "requires paid plan"}
assert result.revenue_growth == 0.15
assert result.earnings_surprise is not None
@pytest.mark.asyncio
async def test_no_402_empty_unavailable_fields(self, provider):
"""When all endpoints succeed, unavailable_fields is empty."""
profile_resp = _mock_response(200, [{"marketCap": 3_000_000}])
ratios_resp = _mock_response(200, [{"priceToEarningsRatioTTM": 20.0}])
growth_resp = _mock_response(200, [{"revenueGrowth": 0.10}])
earnings_resp = _mock_response(200, [{"epsActual": 2.0, "epsEstimated": 1.8}])
async def mock_get(url, params=None):
if "profile" in url:
return profile_resp
if "ratios-ttm" in url:
return ratios_resp
if "financial-growth" in url:
return growth_resp
if "earnings" in url:
return earnings_resp
return _mock_response(200, [{}])
with patch("app.providers.fmp.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.get.side_effect = mock_get
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
result = await provider.fetch_fundamentals("AAPL")
assert result.unavailable_fields == {}
assert result.pe_ratio == 20.0
+3 -44
View File
@@ -15,7 +15,6 @@ from app.models.fundamental import FundamentalData
from app.models.fundamental_snapshot import FundamentalSnapshot from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore, DimensionScore from app.models.score import CompositeScore, DimensionScore
from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.services import fundamentals_candidate_service as candidates from app.services import fundamentals_candidate_service as candidates
from app.services import fundamentals_derivation as deriv from app.services import fundamentals_derivation as deriv
@@ -76,49 +75,9 @@ def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
return rows return rows
async def test_default_off_performs_no_candidate_read_or_write( async def test_refresh_updates_all_fields_and_invalidates_scores(
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: AsyncSession,
): ):
session.add(
SystemSetting(key=refresh_service.ACTIVATION_KEY, value="true")
)
first = Ticker(symbol="AAA", cik="0000000001") first = Ticker(symbol="AAA", cik="0000000001")
second = Ticker(symbol="AAB", cik="0000000001") second = Ticker(symbol="AAB", cik="0000000001")
session.add_all([first, second]) session.add_all([first, second])
@@ -191,7 +150,7 @@ async def test_activated_refresh_updates_all_fields_and_invalidates_scores(
) )
await session.commit() await session.commit()
summary = await refresh_service.refresh_if_enabled( summary = await refresh_service.refresh(
session, now=NOW, today=TODAY session, now=NOW, today=TODAY
) )
@@ -225,7 +184,7 @@ async def test_activated_refresh_updates_all_fields_and_invalidates_scores(
for row in (*dimensions, *composites): for row in (*dimensions, *composites):
row.is_stale = False row.is_stale = False
await session.commit() await session.commit()
unchanged = await refresh_service.refresh_if_enabled( unchanged = await refresh_service.refresh(
session, now=NOW + timedelta(hours=1), today=TODAY session, now=NOW + timedelta(hours=1), today=TODAY
) )
assert unchanged["score_inputs_changed"] == 0 assert unchanged["score_inputs_changed"] == 0
+26 -40
View File
@@ -1,13 +1,20 @@
"""Unit tests for fundamental_service — unavailable_fields persistence.""" """Unit tests for fundamental_service — the surviving read path.
Writes to ``fundamental_data`` are covered by test_fundamental_data_refresh.py;
this file only guards the lookup used by the router and scoring.
"""
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base from app.database import Base
from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.services import fundamental_service from app.services import fundamental_service
@@ -43,57 +50,36 @@ async def ticker(session: AsyncSession) -> Ticker:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_store_fundamental_persists_unavailable_fields( async def test_get_fundamental_returns_the_cached_row(
session: AsyncSession, ticker: Ticker session: AsyncSession, ticker: Ticker
): ):
"""unavailable_fields dict is serialized to JSON and stored.""" fields = {"pe_ratio": "split guard applied"}
fields = {"pe_ratio": "requires paid plan", "revenue_growth": "requires paid plan"} session.add(
FundamentalData(
record = await fundamental_service.store_fundamental( ticker_id=ticker.id,
session,
symbol="AAPL",
pe_ratio=None, pe_ratio=None,
revenue_growth=None,
market_cap=1_000_000.0, market_cap=1_000_000.0,
unavailable_fields=fields, fetched_at=datetime.now(timezone.utc),
unavailable_fields_json=json.dumps(fields),
) )
)
await session.commit()
record = await fundamental_service.get_fundamental(session, symbol="aapl")
assert record is not None
assert record.market_cap == 1_000_000.0
assert json.loads(record.unavailable_fields_json) == fields assert json.loads(record.unavailable_fields_json) == fields
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_store_fundamental_defaults_to_empty_dict( async def test_get_fundamental_returns_none_without_a_cached_row(
session: AsyncSession, ticker: Ticker session: AsyncSession, ticker: Ticker
): ):
"""When unavailable_fields is not provided, column defaults to '{}'.""" assert await fundamental_service.get_fundamental(session, symbol="AAPL") is None
record = await fundamental_service.store_fundamental(
session,
symbol="AAPL",
pe_ratio=25.0,
)
assert json.loads(record.unavailable_fields_json) == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_store_fundamental_updates_unavailable_fields( async def test_get_fundamental_rejects_an_unknown_symbol(session: AsyncSession):
session: AsyncSession, ticker: Ticker with pytest.raises(NotFoundError):
): await fundamental_service.get_fundamental(session, symbol="NOPE")
"""Updating an existing record also updates unavailable_fields_json."""
# First store
await fundamental_service.store_fundamental(
session,
symbol="AAPL",
pe_ratio=None,
unavailable_fields={"pe_ratio": "requires paid plan"},
)
# Second store — fields now available
record = await fundamental_service.store_fundamental(
session,
symbol="AAPL",
pe_ratio=25.0,
unavailable_fields={},
)
assert json.loads(record.unavailable_fields_json) == {}
@@ -1,181 +0,0 @@
"""Unit tests for chained fundamentals provider fallback behavior."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.exceptions import ProviderError, RateLimitError
from app.providers.fundamentals_chain import ChainedFundamentalProvider
from app.providers.protocol import FundamentalData
class _FailProvider:
def __init__(self, message: str) -> None:
self._message = message
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
raise ProviderError(f"{self._message} ({ticker})")
class _RateLimitedProvider:
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
raise RateLimitError(f"rate limit hit for {ticker}")
class _DataProvider:
def __init__(self, data: FundamentalData) -> None:
self._data = data
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
return FundamentalData(
ticker=ticker,
pe_ratio=self._data.pe_ratio,
revenue_growth=self._data.revenue_growth,
earnings_surprise=self._data.earnings_surprise,
market_cap=self._data.market_cap,
fetched_at=self._data.fetched_at,
unavailable_fields=self._data.unavailable_fields,
)
@pytest.mark.asyncio
async def test_chained_provider_uses_fallback_provider_on_primary_failure():
fallback_data = FundamentalData(
ticker="AAPL",
pe_ratio=25.0,
revenue_growth=None,
earnings_surprise=None,
market_cap=1_000_000.0,
fetched_at=datetime.now(timezone.utc),
unavailable_fields={},
)
provider = ChainedFundamentalProvider([
("primary", _FailProvider("primary down")),
("fallback", _DataProvider(fallback_data)),
])
result = await provider.fetch_fundamentals("AAPL")
assert result.pe_ratio == 25.0
assert result.market_cap == 1_000_000.0
assert result.unavailable_fields.get("source_pe_ratio") == "fallback"
@pytest.mark.asyncio
async def test_chained_provider_merges_fields_across_providers():
"""Primary supplies only market cap; fallback fills P/E and earnings."""
primary_data = FundamentalData(
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
fallback_data = FundamentalData(
ticker="AAPL", pe_ratio=18.0, revenue_growth=12.0, earnings_surprise=4.0,
market_cap=999.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
provider = ChainedFundamentalProvider([
("fmp", _DataProvider(primary_data)),
("finnhub", _DataProvider(fallback_data)),
])
result = await provider.fetch_fundamentals("AAPL")
# market cap from primary (first to supply it), the rest from fallback
assert result.market_cap == 2_000_000.0
assert result.pe_ratio == 18.0
assert result.revenue_growth == 12.0
assert result.earnings_surprise == 4.0
assert result.unavailable_fields.get("source_market_cap") == "fmp"
assert result.unavailable_fields.get("source_pe_ratio") == "finnhub"
@pytest.mark.asyncio
async def test_chained_provider_raises_when_all_providers_fail():
provider = ChainedFundamentalProvider([
("p1", _FailProvider("p1 failed")),
("p2", _FailProvider("p2 failed")),
])
with pytest.raises(ProviderError) as exc:
await provider.fetch_fundamentals("MSFT")
assert "All fundamentals providers failed" in str(exc.value)
@pytest.mark.asyncio
async def test_rate_limited_fallback_raises_when_incomplete():
"""FMP gives market cap; the fallback is rate-limited → chain signals it so
the collector can back off instead of storing a degraded record."""
primary_data = FundamentalData(
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
provider = ChainedFundamentalProvider([
("fmp", _DataProvider(primary_data)),
("finnhub", _RateLimitedProvider()),
])
with pytest.raises(RateLimitError):
await provider.fetch_fundamentals("AAPL")
@pytest.mark.asyncio
async def test_rate_limited_fallback_allows_partial():
"""With allow_partial=True the chain returns the market cap it did get."""
primary_data = FundamentalData(
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
provider = ChainedFundamentalProvider([
("fmp", _DataProvider(primary_data)),
("finnhub", _RateLimitedProvider()),
])
result = await provider.fetch_fundamentals("AAPL", allow_partial=True)
assert result.market_cap == 2_000_000.0
assert result.pe_ratio is None
@pytest.mark.asyncio
async def test_rate_limited_but_complete_does_not_raise():
"""If every field is filled, a rate limit on a later (unused) provider is moot."""
full = FundamentalData(
ticker="AAPL", pe_ratio=20.0, revenue_growth=10.0, earnings_surprise=2.0,
market_cap=5.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
provider = ChainedFundamentalProvider([
("fmp", _DataProvider(full)),
("finnhub", _RateLimitedProvider()),
])
result = await provider.fetch_fundamentals("AAPL")
assert result.pe_ratio == 20.0
@pytest.mark.asyncio
async def test_chain_merges_next_earnings_date():
"""Earnings date is taken from the first provider that supplies it."""
from datetime import date as _date
primary = FundamentalData(
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
market_cap=100.0, fetched_at=datetime.now(timezone.utc),
)
class _EarningsProvider:
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
return FundamentalData(
ticker=ticker, pe_ratio=10.0, revenue_growth=5.0, earnings_surprise=1.0,
market_cap=None, fetched_at=datetime.now(timezone.utc),
next_earnings_date=_date(2026, 7, 1),
)
provider = ChainedFundamentalProvider([
("fmp", _DataProvider(primary)),
("finnhub", _EarningsProvider()),
])
result = await provider.fetch_fundamentals("AAPL")
assert result.next_earnings_date == _date(2026, 7, 1)
-209
View File
@@ -1,209 +0,0 @@
"""A5 fundamentals parity report: read-only comparison + artifact archive."""
from __future__ import annotations
from datetime import date, datetime, timezone
import pytest
from sqlalchemy import func, select
from app.models.data_import_run import DataImportRun
from app.models.earnings_event import EarningsEvent
from app.models.fundamental import FundamentalData
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.services.fundamentals_parity_service import (
build_report,
fundamental_score,
load_latest,
load_latest_csv,
load_latest_json,
store_report,
)
UTC = timezone.utc
GENERATED = datetime(2026, 7, 23, 10, 30, tzinfo=UTC)
def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
rows = []
periods = ("Q1", "Q2", "Q3", "FY")
months = (3, 6, 9, 12)
for fy, multiplier in ((2025, 1.0), (2026, 1.1)):
revenues = [100 * multiplier, 110 * multiplier, 120 * multiplier, 130 * multiplier]
eps = [1.0 * multiplier, 1.1 * multiplier, 1.2 * multiplier, 1.3 * multiplier]
for index, period in enumerate(periods):
period_end = date(fy, months[index], 28)
rows.append(
FundamentalSnapshot(
cik=cik,
accession=f"{cik}-{fy}-{period}",
form="10-K" if period == "FY" else "10-Q",
filed_date=period_end,
accepted_at=datetime(fy, months[index], 28, tzinfo=UTC),
period_end=period_end,
fiscal_year=fy,
fiscal_period=period,
revenue=sum(revenues[: index + 1]),
operating_income=sum(revenues[: index + 1]) * 0.2,
diluted_eps=sum(eps[: index + 1]),
cfo=sum(revenues[: index + 1]) * 0.25,
capex=sum(revenues[: index + 1]) * 0.05,
depreciation_amortization=sum(revenues[: index + 1]) * 0.05,
cash_and_st_investments=40,
total_debt=100,
shares_outstanding=1000,
)
)
return rows
async def _seed(db_session):
first = Ticker(symbol="AAA", cik="0000000001", sic="3571")
second = Ticker(symbol="BBB", cik=None, sic=None)
db_session.add_all([first, second])
await db_session.flush()
db_session.add_all(_snapshot_rows(first.cik))
db_session.add_all(
[
FundamentalData(
ticker_id=first.id,
pe_ratio=25,
revenue_growth=5,
earnings_surprise=0,
fetched_at=GENERATED,
),
FundamentalData(
ticker_id=second.id,
pe_ratio=12,
revenue_growth=3,
earnings_surprise=None,
fetched_at=GENERATED,
),
OHLCVRecord(
ticker_id=first.id,
date=date(2026, 7, 22),
open=100,
high=100,
low=100,
close=100,
volume=100,
),
EarningsEvent(
ticker_id=first.id,
announce_date=date(2026, 7, 1),
session="amc",
eps_estimate=2,
eps_actual=2.2,
source="dolt_earnings",
),
DataImportRun(
source="sec_facts",
revision="sec-rev",
status="promoted",
source_max_date=date(2026, 7, 22),
started_at=GENERATED,
completed_at=GENERATED,
),
DataImportRun(
source="dolt_earnings",
revision="dolt-rev",
status="no_op",
source_max_date=date(2026, 7, 22),
started_at=GENERATED,
completed_at=GENERATED,
),
]
)
await db_session.flush()
def test_score_formula_matches_production_rules():
score = fundamental_score(pe_ratio=15, revenue_growth=0, earnings_surprise=0)
assert score == pytest.approx((100 + 50 + 50) / 3)
assert fundamental_score(pe_ratio=15, revenue_growth=None, earnings_surprise=None) is None
async def test_report_compares_sources_and_leaves_database_untouched(db_session):
await _seed(db_session)
before = await db_session.scalar(select(func.count()).select_from(FundamentalData))
report = await build_report(
db_session,
generated_at=GENERATED,
today=date(2026, 7, 23),
)
after = await db_session.scalar(select(func.count()).select_from(FundamentalData))
assert before == after == 2
assert not db_session.new and not db_session.dirty and not db_session.deleted
assert report["read_only"] is True
assert report["approval_status"] == "pending_explicit_approval"
assert report["source_runs"]["sec_facts"]["revision"] == "sec-rev"
assert report["source_runs"]["dolt_earnings"]["revision"] == "dolt-rev"
first = next(row for row in report["rows"] if row["symbol"] == "AAA")
assert first["fields"]["pe_ratio"]["candidate"] == pytest.approx(
100 / 5.06, abs=1e-4
)
assert first["fields"]["revenue_growth"]["candidate"] == pytest.approx(10)
assert first["fields"]["earnings_surprise"]["candidate"] == pytest.approx(10)
assert first["scores"]["candidate_fundamental"] is not None
assert report["summary"]["universe_count"] == 2
assert report["summary"]["field_stats"]["pe_ratio"]["both_available"] == 1
async def test_artifacts_archive_and_latest_manifest(db_session, tmp_path):
await _seed(db_session)
report = await build_report(
db_session,
generated_at=GENERATED,
today=date(2026, 7, 23),
)
paths = store_report(report, tmp_path)
assert tmp_path.joinpath("latest.json").exists()
assert paths["json"].endswith(".json") and paths["csv"].endswith(".csv")
assert load_latest(tmp_path)["generated_at"] == GENERATED.isoformat()
csv_artifact = load_latest_csv(tmp_path)
assert csv_artifact is not None
assert csv_artifact[0].endswith(".csv")
assert "legacy_fundamental,candidate_fundamental" in csv_artifact[1]
assert "AAA" in csv_artifact[1]
json_artifact = load_latest_json(tmp_path)
assert json_artifact is not None and '"rows"' in json_artifact[1]
async def test_admin_endpoints_return_compact_summary_and_downloads(
client, db_session, tmp_path, monkeypatch
):
from app.config import settings
from app.dependencies import require_admin
from app.main import app
await _seed(db_session)
report = await build_report(
db_session,
generated_at=GENERATED,
today=date(2026, 7, 23),
)
store_report(report, tmp_path)
monkeypatch.setattr(settings, "fundamentals_parity_report_dir", str(tmp_path))
app.dependency_overrides[require_admin] = lambda: None
try:
summary_response = await client.get("/api/v1/admin/fundamentals-parity")
assert summary_response.status_code == 200
summary = summary_response.json()["data"]
assert summary["summary"]["universe_count"] == 2
assert "rows" not in summary
csv_response = await client.get("/api/v1/admin/fundamentals-parity/csv")
assert csv_response.status_code == 200
assert "AAA" in csv_response.json()["data"]["content"]
json_response = await client.get("/api/v1/admin/fundamentals-parity/json")
assert json_response.status_code == 200
assert '"rows"' in json_response.json()["data"]["content"]
finally:
app.dependency_overrides.pop(require_admin, None)
@@ -6,7 +6,6 @@ from datetime import date, datetime, timezone
from app.models.data_import_run import DataImportRun from app.models.data_import_run import DataImportRun
from app.models.fundamental_snapshot import FundamentalSnapshot from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.sec_filing_gap import SecFilingGap from app.models.sec_filing_gap import SecFilingGap
from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.services import fundamentals_quality_service from app.services import fundamentals_quality_service
@@ -19,12 +18,6 @@ async def test_latest_sec_validation_blocks_deferred_and_no_history_ciks(
healthy = Ticker(symbol="HEALTHY", cik="0000000003") healthy = Ticker(symbol="HEALTHY", cik="0000000003")
db_session.add_all([missing, no_history, healthy]) db_session.add_all([missing, no_history, healthy])
await db_session.flush() await db_session.flush()
db_session.add(
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
)
)
db_session.add( db_session.add(
DataImportRun( DataImportRun(
source="sec_facts", source="sec_facts",
@@ -44,38 +37,11 @@ async def test_latest_sec_validation_blocks_deferred_and_no_history_ciks(
} }
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): async def test_active_gap_is_blocked_until_a_later_filing_supersedes_it(db_session):
ticker = Ticker(symbol="HIST", cik="0000000043") ticker = Ticker(symbol="HIST", cik="0000000043")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
db_session.add_all([ db_session.add_all([
ticker, ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
SecFilingGap( SecFilingGap(
cik=ticker.cik, cik=ticker.cik,
accession="HIST-Q", accession="HIST-Q",
@@ -116,10 +82,6 @@ async def test_gap_without_index_date_uses_first_seen_date_for_supersession(
first_seen = datetime(2026, 5, 1, 12, tzinfo=timezone.utc) first_seen = datetime(2026, 5, 1, 12, tzinfo=timezone.utc)
db_session.add_all([ db_session.add_all([
ticker, ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
SecFilingGap( SecFilingGap(
cik=ticker.cik, cik=ticker.cik,
accession="DATELESS-Q", accession="DATELESS-Q",
@@ -157,10 +119,6 @@ async def test_ticker_quality_explains_no_xbrl_block(db_session):
ticker = Ticker(symbol="NEWREG", cik="0000000044") ticker = Ticker(symbol="NEWREG", cik="0000000044")
db_session.add_all([ db_session.add_all([
ticker, ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
DataImportRun( DataImportRun(
source="sec_facts", source="sec_facts",
status="promoted", status="promoted",
@@ -0,0 +1,38 @@
"""A6: `sources=fundamentals` is accepted but never fetches from a provider.
`fundamental_data` is rebuilt for the whole universe by the nightly SEC + Dolt
imports, so there is no per-ticker fetch left. The source key stays valid so an
older client gets a truthful `skipped` instead of a silent omission.
"""
from __future__ import annotations
from app.models.ticker import Ticker
async def test_fundamentals_source_reports_skipped(client, db_session):
from app.dependencies import require_access
from app.main import app
app.dependency_overrides[require_access] = lambda: None
try:
db_session.add(Ticker(symbol="AAPL"))
await db_session.flush()
resp = await client.post(
"/api/v1/ingestion/fetch/AAPL", params={"sources": "fundamentals"}
)
assert resp.status_code == 200
source = resp.json()["data"]["sources"]["fundamentals"]
assert source["status"] == "skipped"
assert "SEC + Dolt" in source["message"]
finally:
app.dependency_overrides.pop(require_access, None)
def test_fundamentals_remains_a_recognised_source_key():
"""Older clients keep getting an entry for it rather than a missing key."""
from app.routers.ingestion import _parse_requested_sources
assert "fundamentals" in _parse_requested_sources("fundamentals")
assert "fundamentals" in _parse_requested_sources(None) # None => all sources
+1 -2
View File
@@ -2,9 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
+163 -6
View File
@@ -1,4 +1,4 @@
"""Pure-function tests for the v3 Regime Monitor contract.""" """Pure-function tests for the v3 AI/Tech Risk Monitor contract."""
from __future__ import annotations from __future__ import annotations
@@ -27,6 +27,7 @@ from app.services.regime_monitor_service import (
breadth_level_score, breadth_level_score,
drawdown_pct, drawdown_pct,
f2_credit_spreads, f2_credit_spreads,
current_observation,
fundamental_overlay, fundamental_overlay,
p1_trend_break, p1_trend_break,
p2_death_cross, p2_death_cross,
@@ -238,6 +239,77 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
assert expired["available"] is False assert expired["available"] is False
def test_live_observation_is_visible_before_its_effective_date():
"""Refreshing must not look like it did nothing.
The stored snapshot keeps the effective-date gate so a rebuild cannot
backdate an observation, but the live card reports that date instead of
blanking the content -- otherwise a Friday refresh stays invisible until
Monday.
"""
overrides = {
"f1_score": 50.0,
"f3_score": 100.0,
"capex": {"GOOGL": "holding"},
"good_news_stock_down": "yes",
"reasoning": "fresh read",
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
before = date(2026, 6, 1)
record = fundamental_overlay(overrides, config, before)
now = current_observation(overrides, config, before)
# Same day, same observation: the record hides it, the live reading shows it.
assert record["capex"] is None and record["reasoning"] is None
assert now["capex"] == {"GOOGL": "holding"}
assert now["reasoning"] == "fresh read"
assert now["capex_stress"] == 50.0
assert now["earnings_stress"] == 100.0
# ...while still reporting when the stored record picks it up.
assert now["pending"] is True
assert now["effective_date"] == "2026-06-02"
assert now["available"] is True
# Staleness still expires the live reading.
assert current_observation(overrides, config, date(2026, 8, 22))["stale"] is True
assert current_observation(overrides, config, date(2026, 8, 22))["available"] is False
def test_an_uncollected_observation_is_not_reported_as_collected():
"""The default override is placeholders, not a reading.
``capex`` defaults to "unknown" for every hyperscaler and the reaction to
"mixed". Surfacing those as an observation made the card claim a read that
never happened.
"""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
nothing_collected = {
"f1_score": None,
"f3_score": None,
"capex": {name: "unknown" for name in names},
"good_news_stock_down": "mixed",
"reasoning": None,
"fetched_at": None,
"effective_date": None,
"source": "default",
}
blank = current_observation(nothing_collected, DEFAULT_CONFIG, date(2026, 8, 7))
assert blank["observed"] is False
assert blank["available"] is False
assert blank["capex"] is None
assert blank["good_news_stock_down"] is None
assert blank["reasoning"] is None
# One real observation flips it, placeholders and all.
collected = {**nothing_collected, "fetched_at": "2026-08-07T10:00:00+00:00", "source": "gemini"}
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
def test_fundamentals_do_not_move_the_warning_score(): def test_fundamentals_do_not_move_the_warning_score():
"""The v3 complaint: a maxed-out LLM read must not silently do nothing. """The v3 complaint: a maxed-out LLM read must not silently do nothing.
@@ -421,11 +493,11 @@ async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
changed["state"] = {"score": 90.0, "band": "breaking"} changed["state"] = {"score": 90.0, "band": "breaking"}
written, _ = await rms._upsert_snapshot( written, _ = await rms._upsert_snapshot(
db_session, first, rewrite_existing_v2=True db_session, first, rewrite_existing=True
) )
await db_session.flush() await db_session.flush()
rewritten, persisted = await rms._upsert_snapshot( rewritten, persisted = await rms._upsert_snapshot(
db_session, changed, rewrite_existing_v2=False db_session, changed, rewrite_existing=False
) )
row = ( row = (
await db_session.execute( await db_session.execute(
@@ -468,10 +540,10 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
return {}, {} return {}, {}
async def fake_latest(_db): async def fake_latest(_db):
return object(), {"methodology": "v3"} return object(), {"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}
async def fake_upsert(_db, result, *, rewrite_existing_v2): async def fake_upsert(_db, result, *, rewrite_existing):
rewrites.append(rewrite_existing_v2) rewrites.append(rewrite_existing)
return True, result return True, result
class FakeDB: class FakeDB:
@@ -492,6 +564,91 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
assert rewrites == [True] assert rewrites == [True]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("stored", "expect_reseed"),
[
({"methodology": "v3"}, True), # written before the marker existed
({"methodology": "v3", "sensor_revision": 1}, True),
({"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}, False),
],
)
async def test_a_stale_sensor_revision_reseeds_stored_history(
monkeypatch, stored, expect_reseed
):
"""Widening the OAS window has to reach rows that are already stored.
Routine runs recompute only the latest date, so without this trigger every
older row would keep the credit gap the wider window exists to close.
"""
sessions = [date.today() - timedelta(days=offset) for offset in reversed(range(10))]
prices = {symbol: [(day, 100.0) for day in sessions] for symbol in ("SMH", "QQQ", "SPY")}
written: list[date] = []
revisions: list[int] = []
async def fake_config(_db):
return copy.deepcopy(DEFAULT_CONFIG)
async def fake_overrides(_db):
return {"locked": True, "fetched_at": None, "effective_date": None}
async def fake_prices(_config, _start, _end):
return prices
async def fake_fred(_series_id, _start, _end):
return None
async def fake_breadth(_db, _symbols, window, min_tickers):
return {}, {}
async def fake_latest(_db):
return object(), stored
async def fake_upsert(_db, result, *, rewrite_existing):
written.append(date.fromisoformat(result["date"]))
revisions.append(result["sensor_revision"])
# Every replayed row must be rewritable, or a reseed writes one row.
assert rewrite_existing is True
return True, result
class FakeDB:
async def commit(self):
return None
for name, value in (
("get_regime_config", fake_config),
("get_fundamental_overrides", fake_overrides),
("_fetch_prices", fake_prices),
("_fetch_fred_series", fake_fred),
("_latest_snapshot_row", fake_latest),
("_upsert_snapshot", fake_upsert),
):
monkeypatch.setattr(rms, name, value)
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
await rms.update_regime_monitor(FakeDB())
if expect_reseed:
assert written == sessions, "a reseed must replay the whole stored span"
else:
assert written == [sessions[-1]], "a current revision must not reseed"
assert set(revisions) == {rms.SENSOR_REVISION}
def test_the_rebuild_span_stays_inside_the_oas_window():
"""The reseed must not replay rows it cannot compute credit for.
Each replayed row needs W3's lookback inside the fetched OAS window; if the
replay reached further back than the fetch, the reseed would recreate the
very gap it exists to close.
"""
replay_calendar_days = rms.REBUILD_LOOKBACK_DAYS
w3_lookback_calendar = rms.W3_OAS_LOOKBACK * 7 / 5 # business days -> calendar
assert replay_calendar_days + w3_lookback_calendar <= rms.HY_OAS_WINDOW_DAYS
# ...and still covers the 400-session series the v3 cutover wrote.
assert replay_calendar_days >= 400 * 365 / 252
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch): async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch):
calls: list[str] = [] calls: list[str] = []
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -24,7 +24,6 @@ from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sec_filing_gap import SecFilingGap from app.models.sec_filing_gap import SecFilingGap
from app.models.settings import SystemSetting
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
@@ -524,10 +523,6 @@ async def test_get_trade_setups_hides_active_sec_filing_gap(
db_session.add(ticker) db_session.add(ticker)
await db_session.flush() await db_session.flush()
db_session.add_all([ db_session.add_all([
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
SecFilingGap( SecFilingGap(
cik=ticker.cik, cik=ticker.cik,
accession="0000000042-26-000001", accession="0000000042-26-000001",
+1 -16
View File
@@ -66,26 +66,11 @@ class TestTradingDayCrons:
assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?" assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?"
assert {"Sat", "Sun"}.isdisjoint(weekdays), f"{key} fires on a weekend" assert {"Sat", "Sun"}.isdisjoint(weekdays), f"{key} fires on a weekend"
def test_fundamentals_runs_on_monday(self):
from datetime import datetime
from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(
SCHEDULE_DEFAULTS["schedule_fundamentals_cron"],
timezone=SCHEDULE_DEFAULTS["schedule_timezone"],
)
fire = trigger.get_next_fire_time(
None, datetime(2026, 7, 19, tzinfo=trigger.timezone)
)
assert fire.strftime("%a") == "Mon"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("key", "hour", "minute"), ("key", "hour", "minute"),
( (
("schedule_dolt_earnings_cron", 2, 30), ("schedule_dolt_earnings_cron", 2, 30),
("schedule_sec_fundamentals_cron", 4, 0), ("schedule_sec_fundamentals_cron", 4, 0),
("schedule_fundamentals_parity_cron", 5, 30),
), ),
) )
def test_shadow_imports_run_daily_at_expected_et_time( def test_shadow_imports_run_daily_at_expected_et_time(
@@ -122,7 +107,7 @@ class TestScheduleConfig:
async def test_rejects_bad_cron(self, session: AsyncSession): async def test_rejects_bad_cron(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_fundamentals_cron": "every monday"}) await update_schedule_config(session, {"schedule_daily_pipeline_cron": "every monday"})
async def test_rejects_bad_timezone(self, session: AsyncSession): async def test_rejects_bad_timezone(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
+85 -87
View File
@@ -1,5 +1,6 @@
"""Unit tests for app.scheduler module.""" """Unit tests for app.scheduler module."""
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -12,9 +13,7 @@ from app.scheduler import (
_parse_frequency, _parse_frequency,
_resume_tickers, _resume_tickers,
_last_successful, _last_successful,
_run_shadow_import, _run_source_import,
collect_fundamentals,
run_fundamentals_parity_report,
run_sec_fundamentals_import, run_sec_fundamentals_import,
configure_scheduler, configure_scheduler,
get_job_runtime_snapshot, get_job_runtime_snapshot,
@@ -123,10 +122,8 @@ class TestConfigureScheduler:
"data_backfill", "data_backfill",
"benchmark_collector", "benchmark_collector",
"sentiment_collector", "sentiment_collector",
"fundamental_collector",
"dolt_earnings_import", "dolt_earnings_import",
"sec_fundamentals_import", "sec_fundamentals_import",
"fundamentals_parity_report",
"rr_scanner", "rr_scanner",
"shadow_book", "shadow_book",
"ticker_universe_sync", "ticker_universe_sync",
@@ -157,10 +154,8 @@ class TestConfigureScheduler:
"intraday_pipeline", "intraday_pipeline",
"data_collector", "data_collector",
"data_backfill", "data_backfill",
"fundamental_collector",
"dolt_earnings_import", "dolt_earnings_import",
"sec_fundamentals_import", "sec_fundamentals_import",
"fundamentals_parity_report",
"market_regime", "market_regime",
"near_close_pipeline", "near_close_pipeline",
"regime_monitor", "regime_monitor",
@@ -181,42 +176,7 @@ class _SessionContext:
return None return None
class TestFundamentalCollector: class TestSourceImportJobs:
@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 @staticmethod
def _session_factory(): def _session_factory():
return _SessionContext() return _SessionContext()
@@ -234,7 +194,7 @@ class TestShadowImportJobs:
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported) monkeypatch.setattr("app.scheduler.run_import", imported)
await _run_shadow_import("dolt_earnings_import", object()) await _run_source_import("dolt_earnings_import", object())
runtime = get_job_runtime_snapshot("dolt_earnings_import") runtime = get_job_runtime_snapshot("dolt_earnings_import")
assert runtime["status"] == "completed" assert runtime["status"] == "completed"
@@ -254,7 +214,7 @@ class TestShadowImportJobs:
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported) monkeypatch.setattr("app.scheduler.run_import", imported)
await _run_shadow_import("sec_fundamentals_import", object()) await _run_source_import("sec_fundamentals_import", object())
runtime = get_job_runtime_snapshot("sec_fundamentals_import") runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "error" assert runtime["status"] == "error"
@@ -276,7 +236,7 @@ class TestShadowImportJobs:
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported) monkeypatch.setattr("app.scheduler.run_import", imported)
await _run_shadow_import("sec_fundamentals_import", object()) await _run_source_import("sec_fundamentals_import", object())
runtime = get_job_runtime_snapshot("sec_fundamentals_import") runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == STATUS_DEFERRED assert runtime["status"] == STATUS_DEFERRED
@@ -294,7 +254,7 @@ class TestShadowImportJobs:
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported) monkeypatch.setattr("app.scheduler.run_import", imported)
await _run_shadow_import("dolt_earnings_import", object()) await _run_source_import("dolt_earnings_import", object())
runtime = get_job_runtime_snapshot("dolt_earnings_import") runtime = get_job_runtime_snapshot("dolt_earnings_import")
assert runtime["status"] == "skipped" assert runtime["status"] == "skipped"
@@ -311,14 +271,15 @@ class TestShadowImportJobs:
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled) monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
monkeypatch.setattr("app.scheduler.run_import", should_not_run) monkeypatch.setattr("app.scheduler.run_import", should_not_run)
await _run_shadow_import("sec_fundamentals_import", object()) await _run_source_import("sec_fundamentals_import", object())
runtime = get_job_runtime_snapshot("sec_fundamentals_import") runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "skipped" assert runtime["status"] == "skipped"
assert runtime["message"] == "Disabled" assert runtime["message"] == "Disabled"
async def test_sec_failure_still_runs_activated_local_refresh(self, monkeypatch): async def test_sec_failure_still_runs_local_cache_refresh(self, monkeypatch):
calls = [] calls = []
events = []
async def enabled(db, job_name): async def enabled(db, job_name):
return True return True
@@ -329,29 +290,40 @@ class TestShadowImportJobs:
async def refreshed(db): async def refreshed(db):
calls.append(db) calls.append(db)
return { return {
"enabled": True,
"refreshed": 511, "refreshed": 511,
"score_inputs_changed": 2, "score_inputs_changed": 2,
"dimension_scores_staled": 2, "dimension_scores_staled": 2,
"composite_scores_staled": 2, "composite_scores_staled": 2,
} }
async def record(**kwargs):
events.append(kwargs)
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory) monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", unavailable) monkeypatch.setattr("app.scheduler.run_import", unavailable)
monkeypatch.setattr("app.scheduler._record_system_event", record)
monkeypatch.setattr( monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled", "app.scheduler.fundamental_data_refresh_service.refresh",
refreshed, refreshed,
) )
await run_sec_fundamentals_import() await run_sec_fundamentals_import()
await asyncio.sleep(0) # let the fire-and-forget event task run
assert len(calls) == 1 assert len(calls) == 1
runtime = get_job_runtime_snapshot("sec_fundamentals_import") runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "error" assert runtime["status"] == "error"
assert runtime["message"] == "SEC unavailable" # the failure stays the headline, but the cache result is still visible
assert runtime["message"] == (
"SEC unavailable · cache 511 · 2 score inputs changed"
)
# Rewording the outcome must not duplicate the durable event: the dedup
# key includes the message, so a second finish would show up twice in
# Admin → System Events.
assert len(events) == 1, events
async def test_sec_success_surfaces_activated_refresh_summary(self, monkeypatch): async def test_sec_success_surfaces_cache_refresh_summary(self, monkeypatch):
async def enabled(db, job_name): async def enabled(db, job_name):
return True return True
@@ -362,7 +334,6 @@ class TestShadowImportJobs:
async def refreshed(db): async def refreshed(db):
return { return {
"enabled": True,
"refreshed": 511, "refreshed": 511,
"score_inputs_changed": 2, "score_inputs_changed": 2,
"dimension_scores_staled": 2, "dimension_scores_staled": 2,
@@ -373,7 +344,7 @@ class TestShadowImportJobs:
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", imported) monkeypatch.setattr("app.scheduler.run_import", imported)
monkeypatch.setattr( monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled", "app.scheduler.fundamental_data_refresh_service.refresh",
refreshed, refreshed,
) )
@@ -385,52 +356,79 @@ class TestShadowImportJobs:
"no_op · abcdef123456 · cache 511 · 2 score inputs changed" "no_op · abcdef123456 · cache 511 · 2 score inputs changed"
) )
async def test_disabled_sec_job_does_not_run_local_refresh(self, monkeypatch): async def test_source_locked_sec_run_still_reports_the_cache_refresh(
async def disabled(db, job_name): self, monkeypatch
return False ):
"""A skipped import keeps its skip status but shows the cache advanced."""
async def should_not_run(*args, **kwargs): async def enabled(db, job_name):
raise AssertionError("disabled SEC job ran work") return True
async def locked(importer):
return None # another import owns the source lock
async def refreshed(db):
return {
"refreshed": 511,
"score_inputs_changed": 0,
"dimension_scores_staled": 0,
"composite_scores_staled": 0,
}
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory) monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled) monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr("app.scheduler.run_import", should_not_run) monkeypatch.setattr("app.scheduler.run_import", locked)
monkeypatch.setattr( monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled", "app.scheduler.fundamental_data_refresh_service.refresh",
should_not_run, refreshed,
) )
await run_sec_fundamentals_import() await run_sec_fundamentals_import()
runtime = get_job_runtime_snapshot("sec_fundamentals_import") runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "skipped" assert runtime["status"] == "skipped"
assert runtime["message"] == "Disabled" assert runtime["message"] == (
"Another import for this source is already running · "
"cache 511 · 0 score inputs changed"
async def test_fundamentals_parity_job_surfaces_report_summary(monkeypatch):
async def enabled(db, job_name):
return True
async def generated(db, report_dir):
return (
{
"generated_at": "2026-07-23T10:30:00+00:00",
"summary": {
"universe_count": 511,
"fundamental_score_material_changes": 12,
},
},
{"json": "report.json", "csv": "report.csv"},
) )
monkeypatch.setattr("app.scheduler.async_session_factory", TestShadowImportJobs._session_factory) async def test_disabled_sec_job_still_refreshes_local_cache(self, monkeypatch):
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled) """Disabling the job stops the SEC fetch, not the local cache.
The cache is derived from stored snapshots, earnings events and closes.
Prices and earnings move daily even when no filing does, and there is no
provider fallback since A6 freezing it would silently stale scoring.
"""
calls = []
async def disabled(db, job_name):
return False
async def should_not_run(*args, **kwargs):
raise AssertionError("disabled SEC job hit the network")
async def refreshed(db):
calls.append(db)
return {
"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", disabled)
monkeypatch.setattr("app.scheduler.run_import", should_not_run)
monkeypatch.setattr( monkeypatch.setattr(
"app.scheduler.fundamentals_parity_service.generate_and_store", generated "app.scheduler.fundamental_data_refresh_service.refresh",
refreshed,
) )
await run_fundamentals_parity_report() await run_sec_fundamentals_import()
runtime = get_job_runtime_snapshot("fundamentals_parity_report") assert len(calls) == 1
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "completed" assert runtime["status"] == "completed"
assert runtime["message"] == "511 tickers · 12 material score changes" assert runtime["message"] == (
"Import disabled · cache 511 · 2 score inputs changed"
)
-1
View File
@@ -3,7 +3,6 @@ httpx transport (no network)."""
from __future__ import annotations from __future__ import annotations
import json
from datetime import date from datetime import date
import httpx import httpx
@@ -10,7 +10,6 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base from app.database import Base
from app.exceptions import ProviderError
from app.models.settings import SystemSetting from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.services import ticker_universe_service from app.services import ticker_universe_service
@@ -97,11 +96,7 @@ async def test_fetch_universe_symbols_uses_cached_snapshot_when_live_sources_fai
async def _fake_public(_universe: str): async def _fake_public(_universe: str):
return [], ["public failed"], None return [], ["public failed"], None
async def _fake_fmp(_universe: str):
raise ProviderError("fmp failed")
monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public) monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public)
monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_fmp", _fake_fmp)
symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500") symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500")
assert symbols == ["AAPL", "MSFT"] assert symbols == ["AAPL", "MSFT"]
@@ -116,11 +111,7 @@ async def test_fetch_universe_symbols_uses_seed_when_live_and_cache_fail(
async def _fake_public(_universe: str): async def _fake_public(_universe: str):
return [], ["public failed"], None return [], ["public failed"], None
async def _fake_fmp(_universe: str):
raise ProviderError("fmp failed")
monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public) monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public)
monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_fmp", _fake_fmp)
symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500") symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500")
assert "AAPL" in symbols assert "AAPL" in symbols