Compare commits
18
Commits
+4
-18
@@ -18,16 +18,9 @@ OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
OPENAI_SENTIMENT_BATCH_SIZE=5
|
||||
|
||||
# Fundamentals Provider — Financial Modeling Prep
|
||||
FMP_API_KEY=
|
||||
|
||||
# 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 bulk data — local clone of post-no-preference/earnings. Together with the
|
||||
# SEC EDGAR block below this is the ONLY fundamentals source; there is no
|
||||
# provider-API fallback.
|
||||
# DOLT_BINARY: path to the dolt CLI (set the full path in dev if it's not on PATH,
|
||||
# e.g. Windows: C:\Program Files\Dolt\bin\dolt.exe). DOLT_DATA_DIR holds the
|
||||
# clones; in PRODUCTION it MUST be outside the deploy tree (deploy is
|
||||
@@ -52,21 +45,14 @@ SEC_REQUEST_SPACING_SECONDS=0.2
|
||||
SEC_MAX_RETRIES=4
|
||||
SEC_REQUEST_TIMEOUT_SECONDS=30.0
|
||||
|
||||
# A5 read-only parity report archive. In production keep this outside the
|
||||
# rsync deployment tree, e.g. /var/lib/signal-platform/reports/fundamentals-parity.
|
||||
FUNDAMENTALS_PARITY_REPORT_DIR=reports/fundamentals-parity
|
||||
|
||||
# Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
||||
# AI/Tech Risk 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.
|
||||
FRED_API_KEY=
|
||||
|
||||
# Scheduled Jobs
|
||||
DATA_COLLECTOR_FREQUENCY=daily
|
||||
SENTIMENT_POLL_INTERVAL_MINUTES=30
|
||||
FUNDAMENTAL_FETCH_FREQUENCY=daily
|
||||
RR_SCAN_FREQUENCY=daily
|
||||
FUNDAMENTAL_RATE_LIMIT_RETRIES=3
|
||||
FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS=15
|
||||
|
||||
# Scoring Defaults
|
||||
DEFAULT_WATCHLIST_AUTO_SIZE=10
|
||||
|
||||
@@ -38,7 +38,10 @@ jobs:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
- 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:
|
||||
needs: lint
|
||||
|
||||
@@ -133,8 +133,8 @@ indicators.
|
||||
|
||||
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
|
||||
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
||||
3. **Market Regime** + **Regime Monitor** — breadth/trend and the 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.
|
||||
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 (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
||||
|
||||
**Near-close** (~15:30 ET Mon–Fri) — the only full-universe qualifying observation:
|
||||
|
||||
@@ -155,7 +155,7 @@ Hourly mid-session (Mon–Fri ~10:00–15:00 ET): only **OHLCV → Outcome Eval*
|
||||
|
||||
### 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"
|
||||
|
||||
@@ -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 |
|
||||
| Routing | React Router v6 (SPA) |
|
||||
| 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
|
||||
|
||||
### Backend
|
||||
- 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
|
||||
- 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
|
||||
@@ -351,7 +351,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
||||
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
|
||||
| `/market` | Market — watchlist + rankings tabs | Authenticated |
|
||||
| `/signals` | Signals — scanner + track record tabs | Authenticated |
|
||||
| `/regime` | Market Regime | Authenticated |
|
||||
| `/regime` | AI/Tech Risk Monitor | Authenticated |
|
||||
| `/ticker/:symbol` | Ticker Detail | Authenticated |
|
||||
| `/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_MODEL` | No | `gpt-4o-mini` | OpenAI model name |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `FRED_API_KEY` | Optional (risk monitor) | — | FRED key for the AI/Tech risk monitor (VIX, credit spreads) |
|
||||
| `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 |
|
||||
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) |
|
||||
| `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 |
|
||||
| `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_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
|
||||
| `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)
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Durable last-run state per scheduled job
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-08-08 00:00:00.000000
|
||||
|
||||
Job run state lived only in an in-memory dict in ``app.scheduler``, so every
|
||||
process restart wiped it. Admin → Jobs could then only report "Active" with no
|
||||
indication of whether a job had ever run, or how it ended — which is exactly
|
||||
the information an operator opens that page for.
|
||||
|
||||
One row per job, upserted on ``job_name``. Not history: ``system_events``
|
||||
already grows unbounded with no retention job, and a second append-only
|
||||
operational table would repeat that debt.
|
||||
|
||||
The table starts empty; each job populates its row the next time it finishes.
|
||||
No backfill from ``system_events`` — that table only records warning/error
|
||||
outcomes and uses a different status vocabulary, so seeding from it would
|
||||
invent successful runs that never happened.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "031"
|
||||
down_revision: Union[str, None] = "030"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"job_run_state",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("job_name", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("processed", sa.Integer(), nullable=True),
|
||||
sa.Column("total", sa.Integer(), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("job_name", name="uq_job_run_state_job_name"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("job_run_state")
|
||||
+1
-21
@@ -28,15 +28,6 @@ class Settings(BaseSettings):
|
||||
deepseek_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_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
|
||||
@@ -61,11 +52,7 @@ class Settings(BaseSettings):
|
||||
sec_max_retries: int = 4
|
||||
sec_request_timeout_seconds: float = 30.0
|
||||
|
||||
# A5 read-only comparison artifacts. Production must keep this outside the
|
||||
# rsync deployment tree so the 5-7 day review window survives deploys.
|
||||
fundamentals_parity_report_dir: str = "reports/fundamentals-parity"
|
||||
|
||||
# Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it
|
||||
# AI/Tech Risk Monitor — FRED (VIX level + HY credit spreads). Optional: without it
|
||||
# the volatility (P5) and credit-spread (F2) signals are reported as n/a.
|
||||
fred_api_key: str = ""
|
||||
|
||||
@@ -86,15 +73,8 @@ class Settings(BaseSettings):
|
||||
# the score window is 7 days).
|
||||
sentiment_fresh_hours: int = 120
|
||||
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
|
||||
# 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
|
||||
default_watchlist_auto_size: int = 10
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Job topology: names, labels, pipeline membership, categories, ordering.
|
||||
|
||||
The single source of truth for *what the jobs are*, as opposed to how they run.
|
||||
It deliberately imports nothing from ``app`` so both ``app.scheduler`` and
|
||||
``app.services.admin_service`` can import it at module level -- admin_service
|
||||
otherwise has to do ``from app.scheduler import ...`` inside functions to dodge a
|
||||
cycle.
|
||||
|
||||
The pipeline step lists live here rather than in the scheduler because three
|
||||
separate things need them and used to keep private copies: the runner, the
|
||||
``PIPELINE_MEMBERS`` set the admin API reports, and the UI's grouping. Steps are
|
||||
``(step_name, coroutine_name)``; ``_run_pipeline`` resolves the coroutine late
|
||||
out of the scheduler's own globals, so nothing here depends on those functions
|
||||
existing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipelines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DAILY_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv"),
|
||||
("benchmark_collector", "collect_benchmark"),
|
||||
("sentiment_collector", "collect_sentiment"),
|
||||
("market_regime", "compute_market_regime"),
|
||||
# Observational only — display/alerts; not trade selection.
|
||||
("regime_monitor", "compute_regime_monitor"),
|
||||
# Alerts after regime so quadrant changes reach Telegram in the morning.
|
||||
# Dispatcher is change-driven; quiet days stay quiet. Setup alerts still
|
||||
# fire on the near-close pipeline after the qualifying scan.
|
||||
("alerts", "dispatch_alerts_job"),
|
||||
]
|
||||
|
||||
# Near-close (~15:30 ET Mon–Fri): refresh in-progress day-t bars (incremental
|
||||
# ingestion overlaps the latest stored session), then the only daily
|
||||
# qualifying R:R scan, then Telegram immediately so manual fills can still hit
|
||||
# MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan
|
||||
# may see ~15:15 prices — immaterial for a 12-1 momentum signal.
|
||||
#
|
||||
# US early-close days (~3/year, 13:00 ET close): this job runs post-close and
|
||||
# entries behave like stale_close (still acceptable per execution-recovery matrix).
|
||||
# No exchange calendar dependency.
|
||||
_NEAR_CLOSE_PIPELINE_STEPS = [
|
||||
# Must land today's in-progress bar (~20 min behind live), or the scan falls
|
||||
# back to the previous close and execution degrades to the stale_close floor.
|
||||
("data_collector", "collect_ohlcv_for_scan"),
|
||||
("rr_scanner", "scan_rr"),
|
||||
# Straight after the scan so shadow entries mark at the same near-close
|
||||
# prices the discretionary book is looking at.
|
||||
("shadow_book", "run_shadow_book"),
|
||||
("alerts", "dispatch_alerts_job"),
|
||||
]
|
||||
|
||||
# After close (~16:45 ET Mon–Fri): fresh OHLCV fetch so outcomes resolve on the
|
||||
# final bar, not the near-close partial bar, then outcome/paper close.
|
||||
_AFTER_CLOSE_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv_final"),
|
||||
("outcome_evaluator", "evaluate_outcomes"),
|
||||
]
|
||||
|
||||
# Intraday (light): keep prices current and resolve outcomes through the day,
|
||||
# without the expensive scan/sentiment. The dashboard recomputes live R:R from
|
||||
# the latest price, so refreshing OHLCV is enough to stop prices lagging; the
|
||||
# outcome step also closes paper trades that hit their stop/target intraday.
|
||||
_INTRADAY_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv"),
|
||||
("outcome_evaluator", "evaluate_outcomes"),
|
||||
]
|
||||
|
||||
# Ordered by trading day, not alphabetically: this is the sequence an operator
|
||||
# reads down the page, and it drives the UI's ordering too.
|
||||
PIPELINE_STEPS: dict[str, list[tuple[str, str]]] = {
|
||||
"daily_pipeline": _DAILY_PIPELINE_STEPS,
|
||||
"intraday_pipeline": _INTRADAY_PIPELINE_STEPS,
|
||||
"near_close_pipeline": _NEAR_CLOSE_PIPELINE_STEPS,
|
||||
"after_close_pipeline": _AFTER_CLOSE_PIPELINE_STEPS,
|
||||
}
|
||||
|
||||
# Derived, never hand-maintained: this used to be a literal set in admin_service
|
||||
# duplicating the four lists above from another module, with nothing asserting
|
||||
# the two agreed.
|
||||
PIPELINE_MEMBERS: frozenset[str] = frozenset(
|
||||
step for steps in PIPELINE_STEPS.values() for step, _ in steps
|
||||
)
|
||||
|
||||
|
||||
def _pipelines_by_member() -> dict[str, tuple[str, ...]]:
|
||||
"""Member -> the orchestrators that run it, in trading-day order.
|
||||
|
||||
Membership is many-to-many: data_collector runs in all four pipelines (via
|
||||
three different coroutines), alerts and outcome_evaluator in two each.
|
||||
"""
|
||||
out: dict[str, list[str]] = {}
|
||||
for pipeline, steps in PIPELINE_STEPS.items():
|
||||
for step, _ in steps:
|
||||
bucket = out.setdefault(step, [])
|
||||
if pipeline not in bucket:
|
||||
bucket.append(pipeline)
|
||||
return {member: tuple(pipelines) for member, pipelines in out.items()}
|
||||
|
||||
|
||||
PIPELINES_BY_MEMBER: dict[str, tuple[str, ...]] = _pipelines_by_member()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Orchestrators, in trading-day order.
|
||||
PIPELINE_JOBS: tuple[str, ...] = tuple(PIPELINE_STEPS)
|
||||
|
||||
# Own timer, independent of any pipeline.
|
||||
SCHEDULED_JOBS: tuple[str, ...] = (
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"ticker_universe_sync",
|
||||
"backtest",
|
||||
)
|
||||
|
||||
# Registered but never auto-fired; run only when a human asks.
|
||||
MANUAL_JOBS: tuple[str, ...] = ("event_study", "data_backfill")
|
||||
|
||||
# Steps in the order an operator meets them across the trading day, so the UI
|
||||
# reads as a sequence rather than an alphabetical jumble.
|
||||
PIPELINE_STEP_JOBS: tuple[str, ...] = tuple(
|
||||
dict.fromkeys(step for steps in PIPELINE_STEPS.values() for step, _ in steps)
|
||||
)
|
||||
|
||||
VALID_JOB_NAMES: frozenset[str] = frozenset(
|
||||
PIPELINE_JOBS + PIPELINE_STEP_JOBS + SCHEDULED_JOBS + MANUAL_JOBS
|
||||
)
|
||||
|
||||
JOB_LABELS: dict[str, str] = {
|
||||
"data_collector": "Data Collector (OHLCV)",
|
||||
"data_backfill": "Data Backfill (deep history)",
|
||||
"benchmark_collector": "Benchmark Collector",
|
||||
"sentiment_collector": "Sentiment Collector",
|
||||
"dolt_earnings_import": "Dolt Earnings Import",
|
||||
"sec_fundamentals_import": "SEC Fundamentals Import",
|
||||
"rr_scanner": "R:R Scanner",
|
||||
"ticker_universe_sync": "Ticker Universe Sync",
|
||||
"outcome_evaluator": "Outcome Evaluator",
|
||||
"alerts": "Alerts Dispatcher",
|
||||
# Keys are persisted job ids and must not change; these are display only.
|
||||
"market_regime": "Market Trend (SPY)",
|
||||
"regime_monitor": "AI/Tech Risk Monitor",
|
||||
"event_study": "Event Study",
|
||||
"backtest": "Backtest",
|
||||
"daily_pipeline": "Morning Pipeline",
|
||||
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
|
||||
"after_close_pipeline": "After-Close Pipeline (outcome)",
|
||||
"intraday_pipeline": "Intraday Pipeline",
|
||||
"shadow_book": "Shadow Book (auto-traded strategy)",
|
||||
}
|
||||
|
||||
CATEGORY_PIPELINE = "pipeline"
|
||||
CATEGORY_STEP = "pipeline_step"
|
||||
CATEGORY_SCHEDULED = "scheduled"
|
||||
CATEGORY_MANUAL = "manual"
|
||||
|
||||
# Order the sections appear in.
|
||||
CATEGORY_ORDER: tuple[str, ...] = (
|
||||
CATEGORY_PIPELINE,
|
||||
CATEGORY_STEP,
|
||||
CATEGORY_SCHEDULED,
|
||||
CATEGORY_MANUAL,
|
||||
)
|
||||
|
||||
CATEGORY_LABELS: dict[str, str] = {
|
||||
CATEGORY_PIPELINE: "Pipelines",
|
||||
CATEGORY_STEP: "Pipeline steps",
|
||||
CATEGORY_SCHEDULED: "Standalone scheduled",
|
||||
CATEGORY_MANUAL: "Manual only",
|
||||
}
|
||||
|
||||
_CATEGORY_MEMBERS: dict[str, tuple[str, ...]] = {
|
||||
CATEGORY_PIPELINE: PIPELINE_JOBS,
|
||||
CATEGORY_STEP: PIPELINE_STEP_JOBS,
|
||||
CATEGORY_SCHEDULED: SCHEDULED_JOBS,
|
||||
CATEGORY_MANUAL: MANUAL_JOBS,
|
||||
}
|
||||
|
||||
JOB_CATEGORY: dict[str, str] = {
|
||||
name: category
|
||||
for category, names in _CATEGORY_MEMBERS.items()
|
||||
for name in names
|
||||
}
|
||||
|
||||
# Registered and triggerable through the API, but kept out of Admin → Jobs.
|
||||
# data_backfill's only capability beyond collect_ohlcv (which already backfills
|
||||
# full history for *new* tickers) is re-deepening *existing* ones after
|
||||
# ohlcv_history_days is raised -- a rare one-off, not something to scan past
|
||||
# every time you open the page.
|
||||
HIDDEN_JOBS: frozenset[str] = frozenset({"data_backfill"})
|
||||
|
||||
_SORT_INDEX: dict[str, tuple[int, int]] = {
|
||||
name: (CATEGORY_ORDER.index(category), position)
|
||||
for category, names in _CATEGORY_MEMBERS.items()
|
||||
for position, name in enumerate(names)
|
||||
}
|
||||
|
||||
|
||||
def sort_order(job_name: str) -> tuple[int, int]:
|
||||
"""(category rank, position within category). Unknown jobs sort last."""
|
||||
return _SORT_INDEX.get(job_name, (len(CATEGORY_ORDER), 0))
|
||||
+9
-1
@@ -21,7 +21,12 @@ from app.config import settings
|
||||
from app.database import async_session_factory, engine
|
||||
from app.middleware import register_exception_handlers
|
||||
from app.models.user import User
|
||||
from app.scheduler import configure_scheduler, load_schedule_config, scheduler
|
||||
from app.scheduler import (
|
||||
configure_scheduler,
|
||||
flush_job_run_persists,
|
||||
load_schedule_config,
|
||||
scheduler,
|
||||
)
|
||||
from app.routers.admin import router as admin_router
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.health import router as health_router
|
||||
@@ -91,6 +96,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler stopped")
|
||||
# Drain detached last-run writes before the engine goes away, or a job that
|
||||
# finished during shutdown loses the row it just wrote.
|
||||
await flush_job_run_persists()
|
||||
await engine.dispose()
|
||||
logger.info("Shutting down")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.models.benchmark_price import BenchmarkPrice
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.system_event import SystemEvent
|
||||
from app.models.sec_filing_gap import SecFilingGap
|
||||
from app.models.job_run_state import JobRunState
|
||||
|
||||
__all__ = [
|
||||
"Ticker",
|
||||
@@ -42,4 +43,5 @@ __all__ = [
|
||||
"SignalContextSnapshot",
|
||||
"SystemEvent",
|
||||
"SecFilingGap",
|
||||
"JobRunState",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class JobRunState(Base):
|
||||
"""How each scheduled job last finished. One row per job, overwritten.
|
||||
|
||||
The scheduler's ``_job_runtime`` dict is the live view and is deliberately
|
||||
in-memory, but it is also wiped by every process restart -- so after a deploy
|
||||
Admin → Jobs could only say "Active" with no indication of whether a job had
|
||||
ever run. This is the durable half.
|
||||
|
||||
Deliberately not history: ``system_events`` already grows without a reaper,
|
||||
and a second append-only operational table would repeat that. Rows are
|
||||
upserted on ``job_name``; adding history later is purely additive.
|
||||
"""
|
||||
|
||||
__tablename__ = "job_run_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
job_name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
# Scheduler vocabulary: completed | skipped | error | rate_limited | deferred.
|
||||
# Distinct from data_import_runs' statuses, which is one reason this is its
|
||||
# own table rather than a widened column there.
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
processed: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
@@ -8,7 +8,7 @@ from app.database import 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
|
||||
``breakdown_json`` is authoritative for v2 State, Warning, source dates,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -44,20 +44,6 @@ class SentimentData:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -81,9 +67,5 @@ class SentimentProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class FundamentalProvider(Protocol):
|
||||
"""Protocol for fundamental data providers."""
|
||||
|
||||
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
||||
"""Fetch fundamental data for a ticker."""
|
||||
...
|
||||
# No fundamentals provider protocol: since A6 fundamentals come only from the
|
||||
# batch SEC/Dolt imports, never from a request-time provider call.
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.schemas.admin import (
|
||||
AlertConfigUpdate,
|
||||
CreateUserRequest,
|
||||
DataCleanupRequest,
|
||||
FundamentalsCutoverConfigUpdate,
|
||||
JobTriggerRequest,
|
||||
JobToggle,
|
||||
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)
|
||||
async def get_recommendation_settings(
|
||||
_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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,7 +23,6 @@ from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.user import User
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
||||
from app.services.rr_scanner_service import (
|
||||
resolve_activation_ranks_for_symbol,
|
||||
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.schemas.common import APIEnvelope
|
||||
from app.services import (
|
||||
fundamental_service,
|
||||
ingestion_service,
|
||||
scoring_service,
|
||||
sentiment_service,
|
||||
@@ -185,33 +183,13 @@ async def fetch_symbol(
|
||||
sources_out["sentiment"] = {"status": "error", "message": str(exc)}
|
||||
|
||||
# --- 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 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"] = {
|
||||
"status": "skipped",
|
||||
"message": "No fundamentals provider key configured",
|
||||
"message": "Fundamentals refresh nightly from the SEC + Dolt imports",
|
||||
}
|
||||
|
||||
# --- Derived pipeline: S/R levels (free, always) ---
|
||||
|
||||
+198
-341
@@ -1,9 +1,9 @@
|
||||
"""APScheduler job definitions and FastAPI lifespan integration.
|
||||
|
||||
Defines four scheduled jobs:
|
||||
Defines the scheduled jobs, among them:
|
||||
- Data Collector (OHLCV fetch 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)
|
||||
|
||||
Each job processes tickers independently, logs errors as structured JSON,
|
||||
@@ -18,29 +18,28 @@ import logging
|
||||
import asyncio
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import job_catalog
|
||||
from app.config import settings
|
||||
from app.database import async_session_factory
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.ticker import Ticker
|
||||
from app.exceptions import ProviderError
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
||||
from app.providers.protocol import SentimentData
|
||||
from app.services import job_run_store
|
||||
from app.services import (
|
||||
fundamental_service,
|
||||
ingestion_service,
|
||||
pipeline_run,
|
||||
sentiment_service,
|
||||
settings_store,
|
||||
shadow_book_service,
|
||||
fundamentals_parity_service,
|
||||
fundamental_data_refresh_service,
|
||||
)
|
||||
from app.services.data_import import (
|
||||
@@ -88,36 +87,58 @@ scheduler = AsyncIOScheduler(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _on_job_finished(event: object) -> None:
|
||||
"""Persist the run, then re-pause the job if it only runs on demand.
|
||||
|
||||
Covers every job APScheduler fires itself, including manual triggers.
|
||||
Pipeline *steps* are invoked as plain coroutines and emit no events, so
|
||||
``_run_pipeline`` persists those directly.
|
||||
"""
|
||||
job_id = getattr(event, "job_id", None)
|
||||
if job_id:
|
||||
_schedule_persist(job_id)
|
||||
_repause_after_manual_run(event)
|
||||
|
||||
|
||||
def _repause_after_manual_run(event: object) -> None:
|
||||
"""Re-pause a job that only ever runs on demand, once its run finishes.
|
||||
|
||||
Pipeline steps and manual jobs are registered with a 520-week interval and
|
||||
``next_run_time=None`` as a backstop. Triggering one sets next_run_time=now,
|
||||
and APScheduler then re-arms that backstop -- so Admin → Jobs would show a
|
||||
"next run" ten years out. Guarding on category means the six cron jobs and
|
||||
the real interval jobs are never touched.
|
||||
|
||||
Registered at module level, not inside ``configure_scheduler``: that function
|
||||
is called more than once (idempotency test) and ``add_listener`` does not
|
||||
deduplicate.
|
||||
"""
|
||||
job_id = getattr(event, "job_id", None)
|
||||
if job_catalog.JOB_CATEGORY.get(job_id) not in (
|
||||
job_catalog.CATEGORY_STEP,
|
||||
job_catalog.CATEGORY_MANUAL,
|
||||
):
|
||||
return
|
||||
try:
|
||||
scheduler.modify_job(job_id, next_run_time=None)
|
||||
except Exception: # job gone, scheduler stopped — nothing to re-pause
|
||||
logger.debug("Could not re-pause %s after its run", job_id, exc_info=True)
|
||||
|
||||
|
||||
scheduler.add_listener(_on_job_finished, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
|
||||
|
||||
# Track last successful ticker per job for rate-limit resume
|
||||
_last_successful: dict[str, str | None] = {
|
||||
"data_collector": None,
|
||||
"data_backfill": None,
|
||||
"sentiment_collector": None,
|
||||
"fundamental_collector": None,
|
||||
}
|
||||
|
||||
# Jobs whose per-run progress is surfaced to Admin → Jobs. (outcome_evaluator is
|
||||
# created lazily on first run via _runtime_start.)
|
||||
_JOB_NAMES = [
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"sentiment_collector",
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"ticker_universe_sync",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"event_study",
|
||||
"backtest",
|
||||
"daily_pipeline", # morning: OHLCV/sentiment/regime — no qualifying scan
|
||||
"near_close_pipeline", # OHLCV fetch → R:R scan → Telegram alerts
|
||||
"after_close_pipeline", # OHLCV fetch → outcome eval (final bar)
|
||||
"intraday_pipeline",
|
||||
]
|
||||
# Seeded from the catalog rather than a private list. The old literal held 16 of
|
||||
# the 19 jobs -- benchmark_collector, outcome_evaluator and shadow_book were
|
||||
# missing, so they had no runtime row (and so no "last run" line in Admin → Jobs)
|
||||
# until their first run in a given process.
|
||||
|
||||
|
||||
def _idle_runtime() -> dict[str, object]:
|
||||
@@ -134,7 +155,9 @@ def _idle_runtime() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
_job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in _JOB_NAMES}
|
||||
_job_runtime: dict[str, dict[str, object]] = {
|
||||
name: _idle_runtime() for name in sorted(job_catalog.VALID_JOB_NAMES)
|
||||
}
|
||||
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE
|
||||
|
||||
@@ -261,7 +284,14 @@ def _runtime_finish(
|
||||
processed: int,
|
||||
total: int | None,
|
||||
message: str | None = None,
|
||||
emit_event: bool = True,
|
||||
) -> 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.update({
|
||||
"running": False,
|
||||
@@ -275,7 +305,7 @@ def _runtime_finish(
|
||||
})
|
||||
_job_runtime[job_name] = runtime
|
||||
# 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"
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -292,6 +322,67 @@ def _runtime_finish(
|
||||
pass
|
||||
|
||||
|
||||
async def _persist_job_run(job_name: str) -> None:
|
||||
"""Write a job's finished runtime row to the durable last-run table.
|
||||
|
||||
Never raises: a persistence failure must not break the pipeline that was
|
||||
otherwise successful. The in-memory row stays authoritative for live state.
|
||||
"""
|
||||
runtime = _job_runtime.get(job_name)
|
||||
if not runtime or runtime.get("running") or not runtime.get("finished_at"):
|
||||
return
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
await job_run_store.record_finish(db, job_name, runtime)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Could not persist last-run state for %s", job_name)
|
||||
|
||||
|
||||
# Detached persists are kept referenced: a bare create_task result can be
|
||||
# garbage-collected mid-flight, and the shutdown drain needs something to await.
|
||||
_persist_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _schedule_persist(job_name: str) -> None:
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(_persist_job_run(job_name))
|
||||
except RuntimeError: # no loop (sync context / tests) — nothing to persist
|
||||
return
|
||||
_persist_tasks.add(task)
|
||||
task.add_done_callback(_persist_tasks.discard)
|
||||
|
||||
|
||||
async def flush_job_run_persists(timeout: float = 5.0, settle: float = 0.05) -> None:
|
||||
"""Drain last-run writes, including ones queued while we are draining.
|
||||
|
||||
``scheduler.shutdown(wait=False)`` returns before APScheduler has dispatched
|
||||
its job-completion events, and those events are what create persist tasks. A
|
||||
single snapshot of the set therefore misses writes still to be queued, and
|
||||
``engine.dispose()`` could then close the pool underneath them. So: give the
|
||||
loop a moment for pending callbacks to land, then keep draining until the
|
||||
set stays empty or the deadline passes.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
# Bounded settle so callbacks dispatched by shutdown get to queue their work
|
||||
# before the first emptiness check decides there is nothing to wait for.
|
||||
await asyncio.sleep(min(settle, timeout))
|
||||
while True:
|
||||
pending = {task for task in _persist_tasks if not task.done()}
|
||||
if not pending:
|
||||
return
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
logger.warning(
|
||||
"Timed out draining %d last-run write(s); some may be lost", len(pending)
|
||||
)
|
||||
return
|
||||
await asyncio.wait(pending, timeout=remaining)
|
||||
# Loop rather than return: a completion callback may have queued another.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def get_job_runtime_snapshot(job_name: str | None = None) -> dict[str, dict[str, object]] | dict[str, object]:
|
||||
if job_name is not None:
|
||||
return dict(_job_runtime.get(job_name, {}))
|
||||
@@ -466,23 +557,6 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
|
||||
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]:
|
||||
"""Reorder tickers to resume after the last successful one (rate-limit resume).
|
||||
|
||||
@@ -816,149 +890,16 @@ async def collect_sentiment() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Fundamental Collector
|
||||
# Jobs: bulk fundamentals source imports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def collect_fundamentals() -> None:
|
||||
"""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:
|
||||
async def _run_source_import(job_name: str, importer: SourceImporter) -> bool:
|
||||
"""Run an importer and return whether its scheduled job was enabled.
|
||||
|
||||
The SEC wrapper uses the return value to run its activated local cache step
|
||||
after deferred, failed, no-op, promoted, or source-locked attempts while honoring
|
||||
the job-level disable switch.
|
||||
The SEC wrapper uses the return value only to word its runtime message: its
|
||||
local cache step runs after deferred, failed, no-op, promoted, source-locked
|
||||
and disabled attempts alike.
|
||||
"""
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
_runtime_start(job_name, total=1)
|
||||
@@ -968,7 +909,7 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
|
||||
if not await _is_job_enabled(db, job_name):
|
||||
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
||||
return
|
||||
return False
|
||||
|
||||
run = await run_import(importer)
|
||||
if run is None:
|
||||
@@ -1015,25 +956,26 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
|
||||
|
||||
|
||||
async def run_dolt_earnings_import() -> None:
|
||||
"""Pull and import the Dolt earnings calendar/results feed in shadow."""
|
||||
await _run_shadow_import("dolt_earnings_import", DoltEarningsImporter())
|
||||
"""Pull and import the Dolt earnings calendar/results feed."""
|
||||
await _run_source_import("dolt_earnings_import", DoltEarningsImporter())
|
||||
|
||||
|
||||
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
|
||||
activated it therefore still runs from stored snapshots/earnings/prices when
|
||||
SEC is unavailable, unchanged, or another SEC import owns the source lock.
|
||||
The refresh is deliberately independent of the network import: it reads only
|
||||
stored snapshots, earnings events and closes, so it runs identically when SEC
|
||||
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_enabled = await _run_shadow_import(job_name, SecFundamentalsImporter())
|
||||
if not job_enabled:
|
||||
return
|
||||
import_ran = await _run_source_import(job_name, SecFundamentalsImporter())
|
||||
|
||||
try:
|
||||
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:
|
||||
_runtime_finish(
|
||||
job_name, "error", processed=0, total=1, message="Cancelled"
|
||||
@@ -1051,78 +993,37 @@ async def run_sec_fundamentals_import() -> None:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message=message)
|
||||
return
|
||||
|
||||
if not summary["enabled"]:
|
||||
_log_event(
|
||||
logging.INFO,
|
||||
"fundamental_data_refresh_skipped",
|
||||
job=job_name,
|
||||
reason="cutover_disabled",
|
||||
setting=fundamental_data_refresh_service.ACTIVATION_KEY,
|
||||
)
|
||||
return
|
||||
|
||||
_log_event(
|
||||
logging.INFO,
|
||||
"fundamental_data_refresh_complete",
|
||||
job=job_name,
|
||||
**summary,
|
||||
)
|
||||
runtime = get_job_runtime_snapshot(job_name)
|
||||
if runtime.get("status") == "completed":
|
||||
import_message = runtime.get("message") or "import completed"
|
||||
cache_message = (
|
||||
f"cache {summary['refreshed']} · "
|
||||
f"{summary['score_inputs_changed']} score inputs changed"
|
||||
)
|
||||
# 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(
|
||||
job_name,
|
||||
"completed",
|
||||
processed=1,
|
||||
status,
|
||||
processed=processed,
|
||||
total=1,
|
||||
message=f"{import_message} · {cache_message}",
|
||||
)
|
||||
|
||||
|
||||
async def run_fundamentals_parity_report() -> None:
|
||||
"""Generate the A5 comparison bundle without mutating live fundamentals/scores."""
|
||||
job_name = "fundamentals_parity_report"
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
_runtime_start(job_name, total=1)
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
if not await _is_job_enabled(db, job_name):
|
||||
_runtime_finish(
|
||||
job_name, "skipped", processed=0, total=1, message="Disabled"
|
||||
)
|
||||
return
|
||||
report, artifacts = await fundamentals_parity_service.generate_and_store(
|
||||
db, settings.fundamentals_parity_report_dir
|
||||
)
|
||||
summary = report["summary"]
|
||||
message = (
|
||||
f"{summary['universe_count']} tickers · "
|
||||
f"{summary['fundamental_score_material_changes']} material score changes"
|
||||
)
|
||||
_runtime_finish(job_name, "completed", processed=1, total=1, message=message)
|
||||
_log_event(
|
||||
logging.INFO,
|
||||
"job_complete",
|
||||
job=job_name,
|
||||
generated_at=report["generated_at"],
|
||||
json_path=artifacts["json"],
|
||||
csv_path=artifacts["csv"],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message="Cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||
_log_event(
|
||||
logging.ERROR,
|
||||
"job_error",
|
||||
job=job_name,
|
||||
error_type=type(exc).__name__,
|
||||
message=str(exc),
|
||||
emit_event=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -1247,7 +1148,7 @@ async def dispatch_alerts_job() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Market Regime
|
||||
# Job: Market Trend (SPY)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1306,7 +1207,7 @@ async def collect_benchmark() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Regime Monitor
|
||||
# Job: AI/Tech Risk Monitor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1490,54 +1391,14 @@ async def sync_ticker_universe() -> None:
|
||||
# the intraday partial one (covers a long weekend / holiday gap).
|
||||
_FINAL_REFETCH_DAYS = 5
|
||||
|
||||
_DAILY_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv"),
|
||||
("benchmark_collector", "collect_benchmark"),
|
||||
("sentiment_collector", "collect_sentiment"),
|
||||
("market_regime", "compute_market_regime"),
|
||||
# Observational only — display/alerts; not trade selection.
|
||||
("regime_monitor", "compute_regime_monitor"),
|
||||
# Alerts after regime so quadrant changes reach Telegram in the morning.
|
||||
# Dispatcher is change-driven; quiet days stay quiet. Setup alerts still
|
||||
# fire on the near-close pipeline after the qualifying scan.
|
||||
("alerts", "dispatch_alerts_job"),
|
||||
]
|
||||
|
||||
# Near-close (~15:30 ET Mon–Fri): refresh in-progress day-t bars (incremental
|
||||
# ingestion overlaps the latest stored session), then the only daily
|
||||
# qualifying R:R scan, then Telegram immediately so manual fills can still hit
|
||||
# MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan
|
||||
# may see ~15:15 prices — immaterial for a 12-1 momentum signal.
|
||||
#
|
||||
# US early-close days (~3/year, 13:00 ET close): this job runs post-close and
|
||||
# entries behave like stale_close (still acceptable per execution-recovery matrix).
|
||||
# No exchange calendar dependency.
|
||||
_NEAR_CLOSE_PIPELINE_STEPS = [
|
||||
# Must land today's in-progress bar (~20 min behind live), or the scan falls
|
||||
# back to the previous close and execution degrades to the stale_close floor.
|
||||
("data_collector", "collect_ohlcv_for_scan"),
|
||||
("rr_scanner", "scan_rr"),
|
||||
# Straight after the scan so shadow entries mark at the same near-close
|
||||
# prices the discretionary book is looking at.
|
||||
("shadow_book", "run_shadow_book"),
|
||||
("alerts", "dispatch_alerts_job"),
|
||||
]
|
||||
|
||||
# After close (~16:45 ET Mon–Fri): fresh OHLCV fetch so outcomes resolve on the
|
||||
# final bar, not the near-close partial bar, then outcome/paper close.
|
||||
_AFTER_CLOSE_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv_final"),
|
||||
("outcome_evaluator", "evaluate_outcomes"),
|
||||
]
|
||||
|
||||
# Intraday (light): keep prices current and resolve outcomes through the day,
|
||||
# without the expensive scan/sentiment. The dashboard recomputes live R:R from
|
||||
# the latest price, so refreshing OHLCV is enough to stop prices lagging; the
|
||||
# outcome step also closes paper trades that hit their stop/target intraday.
|
||||
_INTRADAY_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv"),
|
||||
("outcome_evaluator", "evaluate_outcomes"),
|
||||
]
|
||||
# Step lists live in app.job_catalog so the runner, the admin API's pipeline
|
||||
# membership and the UI's grouping all read one definition. Re-exported here
|
||||
# under their original names: _run_pipeline and the scheduler_configured log
|
||||
# payload refer to them directly.
|
||||
_DAILY_PIPELINE_STEPS = job_catalog._DAILY_PIPELINE_STEPS
|
||||
_NEAR_CLOSE_PIPELINE_STEPS = job_catalog._NEAR_CLOSE_PIPELINE_STEPS
|
||||
_AFTER_CLOSE_PIPELINE_STEPS = job_catalog._AFTER_CLOSE_PIPELINE_STEPS
|
||||
_INTRADAY_PIPELINE_STEPS = job_catalog._INTRADAY_PIPELINE_STEPS
|
||||
|
||||
# Warn if near-close fetch+scan+alert drifts past this — entries leave the close
|
||||
# and the stale_close floor quietly becomes the ceiling.
|
||||
@@ -1560,6 +1421,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
if not await _is_job_enabled(db, job_name):
|
||||
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
|
||||
await _persist_job_run(job_name)
|
||||
return
|
||||
|
||||
total = len(steps)
|
||||
@@ -1575,6 +1437,11 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
await funcs[func_name]()
|
||||
except Exception:
|
||||
logger.exception("%s step %s failed", job_name, step_name)
|
||||
# Outside the except on purpose: the step's own _runtime_finish has
|
||||
# already recorded its outcome, so persisting here captures failures
|
||||
# too. Steps are plain coroutine calls and fire no scheduler events,
|
||||
# so the listener cannot see them -- this is their only write path.
|
||||
await _persist_job_run(step_name)
|
||||
done += 1
|
||||
_runtime_finish(job_name, "completed", processed=done, total=total, message="Pipeline complete")
|
||||
_log_event(logging.INFO, "job_complete", job=job_name)
|
||||
@@ -1583,10 +1450,11 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||
finally:
|
||||
pipeline_run.release(token)
|
||||
await _persist_job_run(job_name)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -1666,19 +1534,22 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
|
||||
"schedule_timezone": "America/New_York",
|
||||
# Morning data/display refresh (no qualifying R:R scan).
|
||||
"schedule_daily_pipeline_cron": "0 2 * * *",
|
||||
# Bulk source imports. The SEC job writes the legacy compat cache only after
|
||||
# the explicit, default-off A5 cutover setting is enabled.
|
||||
# Bulk source imports. The SEC job also refreshes the fundamental_data compat
|
||||
# cache that scoring reads — locally, from stored snapshots/earnings/closes.
|
||||
"schedule_dolt_earnings_cron": "30 2 * * *",
|
||||
"schedule_sec_fundamentals_cron": "0 4 * * *",
|
||||
"schedule_fundamentals_parity_cron": "30 5 * * *",
|
||||
# Fetch in-progress bars → scan → Telegram (manual MOC window).
|
||||
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
|
||||
# Fetch final bars → outcome eval (must not run on the partial near-close bar).
|
||||
"schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
|
||||
# Hourly mid-session price + outcome (10:00–15:00 ET Mon–Fri).
|
||||
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
|
||||
# Weekly fundamentals early Monday NY.
|
||||
"schedule_fundamentals_cron": "0 1 * * mon",
|
||||
# Both were interval jobs until 2026-08-08 and hit exactly the pitfall
|
||||
# described above: configure_scheduler calls remove_all_jobs() on every
|
||||
# startup, so an interval countdown restarts from zero each deploy. A 168h
|
||||
# backtest needed a week of uninterrupted uptime to fire even once.
|
||||
"schedule_backtest_cron": "0 3 * * sun",
|
||||
"schedule_ticker_universe_cron": "0 1 * * *",
|
||||
}
|
||||
|
||||
# job id -> schedule setting key
|
||||
@@ -1686,11 +1557,11 @@ _CRON_JOBS: dict[str, str] = {
|
||||
"daily_pipeline": "schedule_daily_pipeline_cron",
|
||||
"dolt_earnings_import": "schedule_dolt_earnings_cron",
|
||||
"sec_fundamentals_import": "schedule_sec_fundamentals_cron",
|
||||
"fundamentals_parity_report": "schedule_fundamentals_parity_cron",
|
||||
"near_close_pipeline": "schedule_near_close_pipeline_cron",
|
||||
"after_close_pipeline": "schedule_after_close_pipeline_cron",
|
||||
"intraday_pipeline": "schedule_intraday_pipeline_cron",
|
||||
"fundamental_collector": "schedule_fundamentals_cron",
|
||||
"backtest": "schedule_backtest_cron",
|
||||
"ticker_universe_sync": "schedule_ticker_universe_cron",
|
||||
}
|
||||
|
||||
|
||||
@@ -1756,8 +1627,12 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
(scan_rr, "rr_scanner", "R:R Scanner"),
|
||||
(run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"),
|
||||
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
||||
(compute_market_regime, "market_regime", "Market Regime"),
|
||||
(compute_regime_monitor, "regime_monitor", "Regime Monitor"),
|
||||
# Labels only -- the ids are persisted (pipeline steps, cron config, run
|
||||
# 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:
|
||||
scheduler.add_job(
|
||||
@@ -1779,7 +1654,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
"schedule_dolt_earnings_cron",
|
||||
),
|
||||
id="dolt_earnings_import",
|
||||
name="Dolt Earnings Import (shadow)",
|
||||
name="Dolt Earnings Import",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
@@ -1793,17 +1668,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
name="SEC Fundamentals Import",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_fundamentals_parity_report,
|
||||
_cron_trigger(
|
||||
cfg["schedule_fundamentals_parity_cron"],
|
||||
tz,
|
||||
"schedule_fundamentals_parity_cron",
|
||||
),
|
||||
id="fundamentals_parity_report",
|
||||
name="Fundamentals Parity Report (read-only)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_near_close_pipeline,
|
||||
_cron_trigger(
|
||||
@@ -1831,17 +1695,13 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
_cron_trigger(cfg["schedule_intraday_pipeline_cron"], tz, "schedule_intraday_pipeline_cron"),
|
||||
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 jobs (own cadence, no ordering dependency). Cron, not interval,
|
||||
# for the reason documented at SCHEDULE_DEFAULTS: an interval countdown
|
||||
# restarts on every deploy, so these could be deferred indefinitely.
|
||||
scheduler.add_job(
|
||||
sync_ticker_universe, "interval", hours=24,
|
||||
sync_ticker_universe,
|
||||
_cron_trigger(cfg["schedule_ticker_universe_cron"], tz, "schedule_ticker_universe_cron"),
|
||||
id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
|
||||
)
|
||||
# Alerts auto-fire only via near_close_pipeline (scan → alert before MOC).
|
||||
@@ -1852,7 +1712,8 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
replace_existing=True, next_run_time=None,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_backtest_job, "interval", hours=168,
|
||||
run_backtest_job,
|
||||
_cron_trigger(cfg["schedule_backtest_cron"], tz, "schedule_backtest_cron"),
|
||||
id="backtest", name="Backtest", replace_existing=True,
|
||||
)
|
||||
# Deep history backfill: manual only (never auto-fires); triggered from
|
||||
@@ -1879,9 +1740,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
},
|
||||
dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]},
|
||||
sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]},
|
||||
fundamentals_parity_report={
|
||||
"cron": cfg["schedule_fundamentals_parity_cron"]
|
||||
},
|
||||
near_close_pipeline={
|
||||
"cron": cfg["schedule_near_close_pipeline_cron"],
|
||||
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
|
||||
@@ -1894,7 +1752,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
"cron": cfg["schedule_intraday_pipeline_cron"],
|
||||
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS],
|
||||
},
|
||||
fundamental_collector={"cron": cfg["schedule_fundamentals_cron"]},
|
||||
independent=["ticker_universe_sync", "backtest"],
|
||||
manual_only=["alerts", "data_backfill", "event_study"],
|
||||
)
|
||||
|
||||
@@ -73,11 +73,6 @@ class ActivationConfigUpdate(BaseModel):
|
||||
exclude_neutral: bool | None = None
|
||||
|
||||
|
||||
class FundamentalsCutoverConfigUpdate(BaseModel):
|
||||
"""Switch the legacy fundamentals cache from quota APIs to SEC/Dolt."""
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ScheduleConfigUpdate(BaseModel):
|
||||
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field
|
||||
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
|
||||
@@ -85,11 +80,11 @@ class ScheduleConfigUpdate(BaseModel):
|
||||
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_fundamentals_parity_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_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_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_backtest_cron: str | None = Field(default=None, max_length=120)
|
||||
schedule_ticker_universe_cron: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class PerformanceConfigUpdate(BaseModel):
|
||||
|
||||
+99
-122
@@ -7,6 +7,7 @@ from passlib.hash import bcrypt
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import job_catalog
|
||||
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
@@ -17,7 +18,7 @@ from app.models.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.models.user import User
|
||||
from app.services import fundamental_data_refresh_service, settings_store
|
||||
from app.services import job_run_store, settings_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,28 +160,6 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
|
||||
return setting
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fundamentals source cutover
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_fundamentals_cutover_config(db: AsyncSession) -> dict[str, bool]:
|
||||
"""Return the explicit A5 cache-cutover switch (default off)."""
|
||||
return {"enabled": await fundamental_data_refresh_service.is_enabled(db)}
|
||||
|
||||
|
||||
async def update_fundamentals_cutover_config(
|
||||
db: AsyncSession, enabled: bool
|
||||
) -> dict[str, bool]:
|
||||
"""Activate or pause SEC/Dolt writes to the legacy fundamentals cache."""
|
||||
await settings_store.upsert_setting(
|
||||
db,
|
||||
fundamental_data_refresh_service.ACTIVATION_KEY,
|
||||
"true" if enabled else "false",
|
||||
)
|
||||
await db.commit()
|
||||
return await get_fundamentals_cutover_config(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activation thresholds
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -628,94 +607,110 @@ async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
|
||||
# Job control (placeholder — scheduler is Task 12.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_JOB_NAMES = {
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"ticker_universe_sync",
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"event_study",
|
||||
"backtest",
|
||||
"daily_pipeline",
|
||||
"near_close_pipeline",
|
||||
"after_close_pipeline",
|
||||
"intraday_pipeline",
|
||||
"shadow_book",
|
||||
}
|
||||
# Job identity, labels and pipeline membership now live in app.job_catalog, which
|
||||
# derives PIPELINE_MEMBERS from the pipeline step lists instead of restating them.
|
||||
# Re-exported here because callers (routers, tests) import them from this module.
|
||||
VALID_JOB_NAMES = job_catalog.VALID_JOB_NAMES
|
||||
JOB_LABELS = job_catalog.JOB_LABELS
|
||||
PIPELINE_MEMBERS = job_catalog.PIPELINE_MEMBERS
|
||||
|
||||
JOB_LABELS = {
|
||||
"data_collector": "Data Collector (OHLCV)",
|
||||
"data_backfill": "Data Backfill (deep history)",
|
||||
"benchmark_collector": "Benchmark Collector",
|
||||
"sentiment_collector": "Sentiment Collector",
|
||||
"fundamental_collector": "Fundamental Collector",
|
||||
"dolt_earnings_import": "Dolt Earnings Import (shadow)",
|
||||
"sec_fundamentals_import": "SEC Fundamentals Import",
|
||||
"fundamentals_parity_report": "Fundamentals Parity Report (read-only)",
|
||||
"rr_scanner": "R:R Scanner",
|
||||
"ticker_universe_sync": "Ticker Universe Sync",
|
||||
"outcome_evaluator": "Outcome Evaluator",
|
||||
"alerts": "Alerts Dispatcher",
|
||||
"market_regime": "Market Regime",
|
||||
"regime_monitor": "Regime Monitor",
|
||||
"event_study": "Event Study",
|
||||
"backtest": "Backtest",
|
||||
"daily_pipeline": "Morning Pipeline",
|
||||
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
|
||||
"after_close_pipeline": "After-Close Pipeline (outcome)",
|
||||
"intraday_pipeline": "Intraday Pipeline",
|
||||
"shadow_book": "Shadow Book (auto-traded strategy)",
|
||||
}
|
||||
# Anything further out than this is a parked backstop, not a schedule: pipeline
|
||||
# steps and manual jobs are registered on a 520-week interval, and triggering one
|
||||
# re-arms it. Belt-and-braces behind the category rule in _next_run_fields.
|
||||
_NEXT_RUN_HORIZON_DAYS = 365
|
||||
|
||||
# Jobs driven by a pipeline (in order) rather than their own auto timer.
|
||||
PIPELINE_MEMBERS = {
|
||||
"data_collector",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"rr_scanner",
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"shadow_book",
|
||||
}
|
||||
|
||||
def _visible_next_run(next_run: datetime | None) -> datetime | None:
|
||||
"""Drop a next-run that is really the parked backstop."""
|
||||
if next_run is None:
|
||||
return None
|
||||
horizon = datetime.now(next_run.tzinfo) + timedelta(days=_NEXT_RUN_HORIZON_DAYS)
|
||||
return None if next_run > horizon else next_run
|
||||
|
||||
|
||||
def _own_next_run(scheduler, name: str) -> datetime | None:
|
||||
# getattr: APScheduler only sets next_run_time once the scheduler is running,
|
||||
# so a job registered but not yet started has no such attribute at all.
|
||||
job = scheduler.get_job(name)
|
||||
return _visible_next_run(getattr(job, "next_run_time", None)) if job else None
|
||||
|
||||
|
||||
def _next_run_fields(scheduler, name: str, enabled_map: dict[str, bool]) -> dict:
|
||||
"""Where this job's next run comes from, decided by category not by clock.
|
||||
|
||||
A pipeline step has no meaningful schedule of its own, so reporting one is
|
||||
the bug: its parent's timer is the answer. Manual jobs have no answer at all,
|
||||
and saying so beats rendering a parked backstop as a date.
|
||||
"""
|
||||
category = job_catalog.JOB_CATEGORY.get(name)
|
||||
if category == job_catalog.CATEGORY_STEP:
|
||||
parents = job_catalog.PIPELINES_BY_MEMBER.get(name, ())
|
||||
soonest: datetime | None = None
|
||||
via: str | None = None
|
||||
for parent in parents:
|
||||
if not enabled_map.get(parent, True):
|
||||
continue
|
||||
candidate = _own_next_run(scheduler, parent)
|
||||
if candidate is not None and (soonest is None or candidate < soonest):
|
||||
soonest, via = candidate, parent
|
||||
return {
|
||||
"next_run_at": None,
|
||||
"next_run_source": "via_pipeline",
|
||||
"via_next_run_at": soonest.isoformat() if soonest else None,
|
||||
"via_next_run_job": via,
|
||||
}
|
||||
if category == job_catalog.CATEGORY_MANUAL:
|
||||
return {
|
||||
"next_run_at": None,
|
||||
"next_run_source": "manual_only",
|
||||
"via_next_run_at": None,
|
||||
"via_next_run_job": None,
|
||||
}
|
||||
own = _own_next_run(scheduler, name)
|
||||
return {
|
||||
"next_run_at": own.isoformat() if own else None,
|
||||
"next_run_source": "own_schedule",
|
||||
"via_next_run_at": None,
|
||||
"via_next_run_job": None,
|
||||
}
|
||||
|
||||
|
||||
async def list_jobs(db: AsyncSession) -> list[dict]:
|
||||
"""Return status of all scheduled jobs."""
|
||||
"""Return status of all scheduled jobs, grouped and ordered by category."""
|
||||
from app.scheduler import get_job_runtime_snapshot, scheduler
|
||||
|
||||
visible = sorted(VALID_JOB_NAMES - job_catalog.HIDDEN_JOBS, key=job_catalog.sort_order)
|
||||
# One query for every flag instead of one per job. Parents are read too, since
|
||||
# a step reports its parent's next run only while that parent is enabled.
|
||||
flags = await settings_store.get_map(
|
||||
db, [f"job_{name}_enabled" for name in VALID_JOB_NAMES]
|
||||
)
|
||||
enabled_map = {
|
||||
name: flags.get(f"job_{name}_enabled", "true") == "true"
|
||||
for name in VALID_JOB_NAMES
|
||||
}
|
||||
last_runs = await job_run_store.get_map(db, visible)
|
||||
|
||||
jobs_out = []
|
||||
for name in sorted(VALID_JOB_NAMES):
|
||||
# Check enabled setting
|
||||
setting = await settings_store.get_setting(db, f"job_{name}_enabled")
|
||||
enabled = setting.value == "true" if setting else True # default enabled
|
||||
|
||||
# Get scheduler job info
|
||||
for name in visible:
|
||||
job = scheduler.get_job(name)
|
||||
next_run = None
|
||||
if job and job.next_run_time:
|
||||
next_run = job.next_run_time.isoformat()
|
||||
|
||||
runtime = get_job_runtime_snapshot(name)
|
||||
last = last_runs.get(name)
|
||||
|
||||
jobs_out.append({
|
||||
"name": name,
|
||||
"label": JOB_LABELS.get(name, name),
|
||||
"enabled": enabled,
|
||||
"next_run_at": next_run,
|
||||
"via_pipeline": name in PIPELINE_MEMBERS,
|
||||
"enabled": enabled_map.get(name, True),
|
||||
"category": job_catalog.JOB_CATEGORY.get(name),
|
||||
"sort_order": job_catalog.sort_order(name),
|
||||
# Parent pipelines for a step; the steps themselves for a pipeline.
|
||||
"pipelines": list(job_catalog.PIPELINES_BY_MEMBER.get(name, ())),
|
||||
"steps": [step for step, _ in job_catalog.PIPELINE_STEPS.get(name, ())],
|
||||
"registered": job is not None,
|
||||
"running": bool(runtime.get("running", False)),
|
||||
# runtime_* are strictly live in-memory state. Persisted history is
|
||||
# reported separately as last_run_*, so a stale error cannot pin the
|
||||
# status chip or the rate-limit banner.
|
||||
"runtime_status": runtime.get("status"),
|
||||
"runtime_processed": runtime.get("processed"),
|
||||
"runtime_total": runtime.get("total"),
|
||||
@@ -724,6 +719,15 @@ async def list_jobs(db: AsyncSession) -> list[dict]:
|
||||
"runtime_started_at": runtime.get("started_at"),
|
||||
"runtime_finished_at": runtime.get("finished_at"),
|
||||
"runtime_message": runtime.get("message"),
|
||||
# Survives restarts, unlike runtime_*. Reported separately so the
|
||||
# status chip keeps meaning "state now" rather than "last outcome,
|
||||
# forever" -- an error a week ago must not read as Inactive today.
|
||||
"last_run_at": last.finished_at.isoformat() if last else None,
|
||||
"last_run_status": last.status if last else None,
|
||||
"last_run_message": last.message if last else None,
|
||||
"last_run_processed": last.processed if last else None,
|
||||
"last_run_total": last.total if last else None,
|
||||
**_next_run_fields(scheduler, name, enabled_map),
|
||||
})
|
||||
|
||||
return jobs_out
|
||||
@@ -799,30 +803,3 @@ async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSe
|
||||
|
||||
key = f"job_{job_name}_enabled"
|
||||
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)
|
||||
|
||||
@@ -860,7 +860,7 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
|
||||
else:
|
||||
metrics = f"State {x:.0f} · Warning {y:.0f}"
|
||||
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"{metrics}\n"
|
||||
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
||||
|
||||
@@ -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
|
||||
# slots and cash allow.
|
||||
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_NOTIONAL_CAP = 0.20 # max fraction of equity per position (no margin)
|
||||
_EULER_MASCHERONI = 0.5772156649015329
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -12,38 +15,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import insert_for_session
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.services import fundamentals_candidate_service, settings_store
|
||||
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")
|
||||
|
||||
|
||||
async def is_enabled(db: AsyncSession) -> bool:
|
||||
raw = await settings_store.get_value(db, ACTIVATION_KEY, "false")
|
||||
return str(raw).strip().lower() == "true"
|
||||
|
||||
|
||||
async def refresh_if_enabled(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
today: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Refresh atomically when activated; otherwise perform no writes."""
|
||||
if not await is_enabled(db):
|
||||
return {
|
||||
"enabled": False,
|
||||
"refreshed": 0,
|
||||
"score_inputs_changed": 0,
|
||||
"dimension_scores_staled": 0,
|
||||
"composite_scores_staled": 0,
|
||||
}
|
||||
return await refresh(db, now=now, today=today)
|
||||
|
||||
|
||||
async def refresh(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -117,7 +93,6 @@ async def refresh(
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"enabled": True,
|
||||
"refreshed": len(candidates),
|
||||
"score_inputs_changed": len(changed_ids),
|
||||
"dimension_scores_staled": len(dimension_ids),
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
"""Fundamental data service.
|
||||
"""Fundamental data read access.
|
||||
|
||||
Stores fundamental data (P/E, revenue growth, earnings surprise, market cap)
|
||||
and marks the fundamental dimension score as stale on new data.
|
||||
``fundamental_data`` is the compat cache scoring reads. It is written solely by
|
||||
``fundamental_data_refresh_service`` from SEC snapshots, Dolt earnings events and
|
||||
stored closes; nothing fetches it per ticker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import insert_for_session
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.score import DimensionScore
|
||||
from app.models.ticker import Ticker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,65 +29,6 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> 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(
|
||||
db: AsyncSession,
|
||||
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
|
||||
``fundamental_data`` refresh. It never contacts SEC or Dolt: every input comes
|
||||
from PostgreSQL, so price- and earnings-driven values can still refresh when an
|
||||
upstream import is unchanged or unavailable.
|
||||
This is the read path behind the ``fundamental_data`` refresh. It never contacts
|
||||
SEC or Dolt: every input comes from PostgreSQL, so price- and earnings-driven
|
||||
values can still refresh when an upstream import is unchanged or unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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.sec_filing_gap import SecFilingGap
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import fundamental_data_refresh_service
|
||||
|
||||
_SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
|
||||
|
||||
@@ -78,8 +77,6 @@ async def blocked_reasons_by_cik(
|
||||
ciks: set[str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Current SEC blocker code by CIK; no historical audit scan."""
|
||||
if not await fundamental_data_refresh_service.is_enabled(db):
|
||||
return {}
|
||||
if ciks is not None and not ciks:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Single source for JobRunState reads/writes.
|
||||
|
||||
Mirrors ``settings_store``: ``record_finish`` never commits — the caller owns
|
||||
the transaction — and reads are batched so the admin listing stays one query.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.job_run_state import JobRunState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_datetime(value: object) -> datetime | None:
|
||||
"""Runtime snapshots carry ISO strings; the column wants a datetime."""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def get_map(db: AsyncSession, job_names: Iterable[str]) -> dict[str, JobRunState]:
|
||||
"""Return {job_name: row} for the given jobs that have ever finished.
|
||||
|
||||
``populate_existing`` because rows are written by core upserts, which leave
|
||||
any previously-loaded ORM instance in the identity map stale.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(JobRunState)
|
||||
.where(JobRunState.job_name.in_(list(job_names)))
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return {row.job_name: row for row in result.scalars().all()}
|
||||
|
||||
|
||||
def _insert_for(db: AsyncSession):
|
||||
"""ON CONFLICT is dialect-specific; prod is Postgres, tests are SQLite."""
|
||||
dialect = db.get_bind().dialect.name
|
||||
return pg_insert if dialect == "postgresql" else sqlite_insert
|
||||
|
||||
|
||||
async def record_finish(db: AsyncSession, job_name: str, runtime: dict) -> None:
|
||||
"""Upsert the last-run row from a scheduler runtime snapshot.
|
||||
|
||||
Atomic, and newer-wins. Select-then-insert loses races that really happen
|
||||
here: pipelines are separate scheduler jobs that can overlap, and they share
|
||||
step ids -- data_collector belongs to all four. Two of them finishing that
|
||||
step together would both see no row and both insert, and the loser's
|
||||
IntegrityError is swallowed by the caller, so the run silently vanishes.
|
||||
|
||||
The ``where`` guard is the other half: without it a slower pipeline
|
||||
finishing an *older* run last would rewind finished_at and the status with
|
||||
it, so the panel would report a stale outcome as the latest one.
|
||||
"""
|
||||
finished_at = _as_datetime(runtime.get("finished_at")) or datetime.now(timezone.utc)
|
||||
message = runtime.get("message")
|
||||
now = datetime.now(timezone.utc)
|
||||
values = {
|
||||
"job_name": job_name,
|
||||
"status": str(runtime.get("status") or "completed"),
|
||||
"started_at": _as_datetime(runtime.get("started_at")),
|
||||
"finished_at": finished_at,
|
||||
"processed": runtime.get("processed"),
|
||||
"total": runtime.get("total"),
|
||||
"message": str(message)[:4000] if message else None,
|
||||
# Set explicitly: the model's onupdate hook does not fire for a core
|
||||
# INSERT ... ON CONFLICT DO UPDATE.
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
statement = _insert_for(db)(JobRunState).values(**values)
|
||||
await db.execute(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=[JobRunState.job_name],
|
||||
set_={key: statement.excluded[key] for key in values if key != "job_name"},
|
||||
where=JobRunState.finished_at < statement.excluded.finished_at,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
two deliberately separate outputs:
|
||||
@@ -52,7 +52,14 @@ METHODOLOGY = "v3"
|
||||
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
||||
# collected by hand/LLM and carried across it when the format is compatible.
|
||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
||||
REBUILD_SESSIONS = 400
|
||||
|
||||
# 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
|
||||
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
|
||||
# encode the long-run distribution, so the credit *level* is now purely anchored
|
||||
# and credit *dynamics* live in W3 on the Warning axis where they belong.
|
||||
HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now
|
||||
# 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_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
|
||||
|
||||
|
||||
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:
|
||||
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
|
||||
|
||||
The effective-date gate stays even though nothing is scored from this: the
|
||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
||||
onto 2024 snapshots would be plain lookahead in the stored record.
|
||||
|
||||
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)
|
||||
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)))
|
||||
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||
return {
|
||||
"available": not pending and not stale,
|
||||
"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:
|
||||
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||
@@ -630,6 +703,8 @@ def _compute_index(
|
||||
|
||||
return {
|
||||
"methodology": METHODOLOGY,
|
||||
# Not part of the history filter -- only the reseed trigger.
|
||||
"sensor_revision": SENSOR_REVISION,
|
||||
"date": as_of.isoformat(),
|
||||
"state": state,
|
||||
"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)
|
||||
out[symbol] = sorted(((b.date, float(b.close)) for b in bars), key=lambda item: item[0])
|
||||
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
|
||||
|
||||
|
||||
@@ -866,7 +941,7 @@ async def _fetch_fred_series(series_id: str, start: date, end: date) -> Series |
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
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
|
||||
|
||||
out: Series = []
|
||||
@@ -889,7 +964,7 @@ async def _upsert_snapshot(
|
||||
db: AsyncSession,
|
||||
result: dict,
|
||||
*,
|
||||
rewrite_existing_v2: bool,
|
||||
rewrite_existing: bool,
|
||||
) -> tuple[bool, dict]:
|
||||
snapshot_date = date.fromisoformat(result["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),
|
||||
))
|
||||
else:
|
||||
existing_v2 = _parse_snapshot(row.breakdown_json)
|
||||
if existing_v2 is not None and not rewrite_existing_v2:
|
||||
return False, existing_v2
|
||||
existing_parsed = _parse_snapshot(row.breakdown_json)
|
||||
if existing_parsed is not None and not rewrite_existing:
|
||||
return False, existing_parsed
|
||||
row.total_score = float(state_score or 0.0)
|
||||
row.band = state_band or "unavailable"
|
||||
row.breakdown_json = payload
|
||||
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:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
@@ -934,14 +1017,16 @@ async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict]
|
||||
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)
|
||||
overrides = await get_fundamental_overrides(db)
|
||||
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
||||
try:
|
||||
overrides = await refresh_fundamental_overrides(db, config=config)
|
||||
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()
|
||||
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)
|
||||
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 = {}, {}, {}
|
||||
|
||||
latest_v2 = await _latest_snapshot_row(db)
|
||||
rebuilding = latest_v2 is None and bool(leader_series)
|
||||
latest_snapshot = await _latest_snapshot_row(db)
|
||||
# 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:
|
||||
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:
|
||||
# Routine PIT rule: only the latest trading date may be inserted/updated.
|
||||
dates = [latest_date]
|
||||
@@ -995,7 +1088,9 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
||||
written, latest_result = await _upsert_snapshot(
|
||||
db,
|
||||
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)
|
||||
await db.commit()
|
||||
@@ -1042,7 +1137,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
|
||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
latest = await _latest_snapshot_row(db)
|
||||
if latest is None:
|
||||
return {"available": False, "reason": "v2 not computed yet"}
|
||||
return {"available": False, "reason": "not computed yet"}
|
||||
row, result = latest
|
||||
basket_hash = (result.get("basket") or {}).get("hash")
|
||||
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.
|
||||
config = await get_regime_config(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"))
|
||||
result["fundamental_context"] = live
|
||||
result["available"] = True
|
||||
|
||||
@@ -497,8 +497,8 @@ async def _compute_fundamental_score(
|
||||
"reason": "Earnings surprise data not available",
|
||||
})
|
||||
|
||||
# Require at least two real metrics — a single available metric (e.g. only
|
||||
# market cap is free on FMP) does not make a meaningful fundamental score.
|
||||
# Require at least two real metrics — a single available metric (e.g. an
|
||||
# issuer with only a market cap) does not make a meaningful fundamental score.
|
||||
MIN_METRICS = 2
|
||||
if len(scores) < MIN_METRICS:
|
||||
unavailable.append({
|
||||
|
||||
@@ -39,7 +39,7 @@ logger = logging.getLogger(__name__)
|
||||
_WWW = "https://www.sec.gov"
|
||||
_DATA = "https://data.sec.gov"
|
||||
|
||||
# Resolve CA bundle for explicit httpx verify (matches app/providers/fmp.py).
|
||||
# Resolve CA bundle for explicit httpx verify (matches app/providers/alpaca.py).
|
||||
_CA = os.environ.get("SSL_CERT_FILE", "")
|
||||
_CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
|
||||
|
||||
|
||||
@@ -40,10 +40,12 @@ KEY_CAPACITY = "shadow_book_capacity"
|
||||
KEY_RISK_PCT = "shadow_book_risk_pct"
|
||||
KEY_START_EQUITY = "shadow_book_start_equity"
|
||||
|
||||
# Matches the validated configuration: 10-position book, 1% fixed-fractional
|
||||
# risk. Start equity is only a sizing base — comparisons are drawn in percent
|
||||
# and R-multiples, never in raw currency.
|
||||
DEFAULT_CAPACITY = 10
|
||||
# Matches the validated configuration: 1% fixed-fractional risk, and a count cap
|
||||
# set as headroom rather than a target — see backtest_service.SIM_MAX_POSITIONS,
|
||||
# which this must track. NOTIONAL_CAP below saturates the book near 12 positions,
|
||||
# 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_START_EQUITY = 100_000.0
|
||||
|
||||
|
||||
@@ -113,116 +113,6 @@ def _normalise_symbols(symbols: Iterable[str]) -> list[str]:
|
||||
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(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
@@ -351,13 +241,16 @@ async def fetch_universe_symbols(
|
||||
|
||||
Fallback order:
|
||||
1) Free public sources (Wikipedia/NASDAQ trader)
|
||||
2) FMP endpoints (if available)
|
||||
3) Cached snapshot in SystemSetting
|
||||
4) Built-in seed symbols
|
||||
2) Cached snapshot in SystemSetting
|
||||
3) Built-in seed symbols
|
||||
|
||||
Returns ``(symbols, source_label)`` so bootstrap UI can show where the
|
||||
list came from (important when Wikipedia/FMP fail and a stale cache still
|
||||
lists BK instead of BNY).
|
||||
list came from (important when the public source fails and a stale cache
|
||||
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)
|
||||
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")
|
||||
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)
|
||||
if cached_symbols:
|
||||
logger.warning(
|
||||
|
||||
@@ -15,7 +15,6 @@ MIN_FREE_GB="${DOLT_MIN_FREE_DISK_GB:-5}"
|
||||
EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}"
|
||||
DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}"
|
||||
DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}"
|
||||
FUNDAMENTALS_PARITY_REPORT_DIR="${FUNDAMENTALS_PARITY_REPORT_DIR:-/var/lib/signal-platform/reports/fundamentals-parity}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
@@ -80,8 +79,6 @@ check_env() {
|
||||
|| fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE"
|
||||
grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \
|
||||
|| fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email"
|
||||
grep -Fqx "FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR" "$ENV_FILE" \
|
||||
|| fail "set FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR in $ENV_FILE"
|
||||
}
|
||||
|
||||
check_all() {
|
||||
@@ -104,15 +101,6 @@ check_all() {
|
||||
identity_email="$(repo_config_value user.email 2>/dev/null || true)"
|
||||
[[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR"
|
||||
[[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR"
|
||||
[[ -d "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|
||||
|| fail "missing parity report directory: $FUNDAMENTALS_PARITY_REPORT_DIR"
|
||||
if [[ "$(id -un)" == "$APP_USER" ]]; then
|
||||
[[ -w "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|
||||
|| fail "parity report directory is not writable by $APP_USER"
|
||||
else
|
||||
runuser -u "$APP_USER" -- test -w "$FUNDAMENTALS_PARITY_REPORT_DIR" \
|
||||
|| fail "parity report directory is not writable by $APP_USER"
|
||||
fi
|
||||
check_free_space
|
||||
check_env
|
||||
echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned"
|
||||
@@ -139,7 +127,6 @@ fi
|
||||
version_ok || fail "Dolt $DOLT_VERSION installation failed"
|
||||
|
||||
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR"
|
||||
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$FUNDAMENTALS_PARITY_REPORT_DIR"
|
||||
check_free_space
|
||||
|
||||
if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
# Dolt bulk-data integration — implementation plan
|
||||
|
||||
Status: approved 2026-07-21, revised through four review rounds; direction: KISS
|
||||
backend, UI value first. Hand-off document for the implementing agent;
|
||||
self-contained.
|
||||
Status: **workstream A complete and deployed** (A0–A6, last step 2026-08-07);
|
||||
**workstream B dropped 2026-08-07** — see § Why B was dropped. Approved 2026-07-21,
|
||||
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
|
||||
|
||||
Replace the free-tier fundamentals APIs (FMP, Finnhub, Alpha Vantage) with bulk
|
||||
data: SEC Company Facts for fundamentals, the DoltHub earnings repo for the
|
||||
earnings calendar/history, and — later, independently — the DoltHub stocks repo for
|
||||
historical OHLCV. PostgreSQL stays the production system of record.
|
||||
data: SEC Company Facts for fundamentals and the DoltHub earnings repo for the
|
||||
earnings calendar/history. 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.**
|
||||
|
||||
@@ -17,9 +20,9 @@ historical OHLCV. PostgreSQL stays the production system of record.
|
||||
FundamentalsPanel + decommission FMP/Finnhub/Alpha Vantage. Valuation uses the
|
||||
existing Alpaca closes already in `ohlcv_records`. This alone achieves the goal
|
||||
(killing the quota-limited APIs) and delivers all the UI value.
|
||||
- **Workstream B (later, optional until needed):** replace historical OHLCV with
|
||||
the Dolt stocks repo. The most complex machinery (4.7 GB clone, split
|
||||
adjustment, source-bar table, reconciliation) lives here and blocks nothing in A.
|
||||
- **Workstream B — DROPPED 2026-08-07, see below.** Would have replaced historical
|
||||
OHLCV with the Dolt stocks repo. Its design is retained further down as a record,
|
||||
not as a backlog item.
|
||||
|
||||
**Guiding principle: KISS.** Plain daily importers with staging and atomic
|
||||
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 the importer module); **no public API, bulk export, or redistribution** of the
|
||||
data; re-review licensing before any public or commercial access. The
|
||||
`post-no-preference/stocks` repo (workstream B) is **not** covered here and will be
|
||||
reviewed separately if B begins.
|
||||
`post-no-preference/stocks` repo (workstream B) is **not** covered here. B was
|
||||
dropped before any licensing review, so that repo has never been assessed — any
|
||||
future use of it starts that review from scratch.
|
||||
|
||||
## Schema
|
||||
|
||||
@@ -119,7 +123,9 @@ reviewed separately if B begins.
|
||||
cache, repopulated by the daily SEC job — but only after the phase-A5 parity
|
||||
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`
|
||||
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
|
||||
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
|
||||
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
|
||||
default-off `fundamental_data_sec_dolt_cutover_enabled` SystemSetting; the
|
||||
remaining production action is flipping that switch on and observing it.
|
||||
- A6. Remove FMP/Finnhub/Alpha Vantage; keep monitoring + manual fallback.
|
||||
- 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.
|
||||
- B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca
|
||||
keeps owning `ohlcv_records`); historical backfill.
|
||||
- B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries.
|
||||
- B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source
|
||||
bars + splits); morning pipeline → 03:00.
|
||||
- ~~B0. Stocks clone (~4.7 GB) provisioned; migration 027.~~
|
||||
- ~~B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca
|
||||
keeps owning `ohlcv_records`); historical backfill.~~
|
||||
- ~~B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries.~~
|
||||
- ~~B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source
|
||||
bars + splits); morning pipeline → 03:00.~~
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
- Peer comparison disappears below 5 peer issuers; favorable-percentile direction
|
||||
correct for both polarities.
|
||||
- Workstream B: split-adjusted OHLCV matches Alpaca on representative normal /
|
||||
split / reverse-split symbols.
|
||||
- ~~Workstream B: split-adjusted OHLCV matches Alpaca on representative normal /
|
||||
split / reverse-split symbols.~~ (dropped)
|
||||
- UI states: positive, adverse, neutral, insufficient history, insufficient
|
||||
peers; mobile layout; non-color accessibility.
|
||||
- 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.
|
||||
Dennis reviewed the evidence 2026-07-24 and directed proceeding to cutover.
|
||||
|
||||
**Task 1 — A5 activation (IMPLEMENTED 2026-07-24; production switch remains).** The
|
||||
post-activation local refresh of `fundamental_data` derives `pe_ratio` and
|
||||
`market_cap` from newest valid snapshots × latest PostgreSQL close, `revenue_growth`
|
||||
from snapshots, `earnings_surprise`/`next_earnings_date` from `earnings_events`; mark
|
||||
affected cached fundamental scores stale; must run identically when SEC is unreachable.
|
||||
**Task 1 — A5 activation: DONE.** Implemented 2026-07-24, switched on and observed
|
||||
in production, and made unconditional by A6 (2026-08-07) — there is no longer a
|
||||
switch, an Admin card, or a weekly legacy collector to skip. The local refresh of
|
||||
`fundamental_data` derives `pe_ratio` and `market_cap` from newest valid snapshots ×
|
||||
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 —
|
||||
that path carries the split guard (`ttm_diluted_eps`
|
||||
nulls when contaminated, with `ttm_diluted_eps_caveat`) and the multi-class share
|
||||
fallback (`shares_outstanding` + `shares_outstanding_estimated`). Parity and activation
|
||||
share the same candidate builder. Activation is the explicit
|
||||
`fundamental_data_sec_dolt_cutover_enabled` SystemSetting and defaults off. It is
|
||||
managed by the **Fundamentals data source** card in Admin → Settings; while active,
|
||||
the weekly legacy collector skips itself so it cannot overwrite the SEC/Dolt cache.
|
||||
See `docs/fundamentals-deployment.md` for the production flip and rollback procedure.
|
||||
that path carries the split guard (`ttm_diluted_eps` nulls when contaminated, with
|
||||
`ttm_diluted_eps_caveat`) and the multi-class share fallback (`shares_outstanding` +
|
||||
`shares_outstanding_estimated`). See `docs/fundamentals-deployment.md` for current
|
||||
operations and rollback.
|
||||
|
||||
**Task 2 — A6 decommissioning.** After a short observation window: remove
|
||||
FMP/Finnhub/Alpha Vantage providers, config and env keys; keep monitoring + manual
|
||||
fallback. Gated by the acceptance criteria above — especially forward-calendar
|
||||
timeliness from `dolt_earnings` (its `source_max_date` ran ~5 weeks ahead as of
|
||||
2026-07-23, which passes).
|
||||
**Task 2 — A6 decommissioning: DONE 2026-08-07.** The cutover ran on and was
|
||||
observed in production, so the legacy providers, their config/env keys, the weekly
|
||||
collector job and the parity report were all removed. Two consequences to carry:
|
||||
(1) `fundamental_data` now has no provider fallback — recovery is restore-from-backup;
|
||||
(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):**
|
||||
- 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.
|
||||
- FITB: unscored (split guard + no taggable revenue) — the one name that lost its
|
||||
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)
|
||||
|
||||
- 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.
|
||||
- Point-in-time backtest enforcement (`accepted_at` is stored now; derivation and
|
||||
backtest visibility rules are built only when fundamentals enter
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
# Fundamentals production deployment
|
||||
|
||||
This is the one-time production setup for the Dolt earnings and SEC fundamentals
|
||||
imports. The A5 scoring cutover was approved on 2026-07-24; the compat-cache write
|
||||
path is still default-off until the explicit production switch below is set. Do
|
||||
not add OS cron entries: the application scheduler owns both jobs.
|
||||
imports. Since A6 (2026-08) these are the *only* fundamentals sources — the
|
||||
FMP/Finnhub/Alpha Vantage providers, the weekly legacy collector and the A5 parity
|
||||
report are gone, and the cache write path is unconditional. Do not add OS cron
|
||||
entries: the application scheduler owns both jobs.
|
||||
|
||||
## What the deployment adds
|
||||
|
||||
- `Dolt Earnings Import (shadow)` runs daily at 02:30 America/New_York.
|
||||
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York. Its local
|
||||
`fundamental_data` refresh runs only when the A5 switch is enabled.
|
||||
- `Fundamentals Parity Report (read-only)` runs daily at 05:30 America/New_York.
|
||||
- `Dolt Earnings Import` runs daily at 02:30 America/New_York.
|
||||
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York, then refreshes
|
||||
`fundamental_data` — the compat cache scoring reads — from stored snapshots,
|
||||
earnings events and closes.
|
||||
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
|
||||
**Disabling the SEC job stops its SEC network fetch only**; the local cache
|
||||
refresh still runs, because prices and earnings move daily even when no filing
|
||||
does.
|
||||
- Cron expressions are editable in Admin → Schedule.
|
||||
- Every attempt is recorded in `data_import_runs`; failures also create a system
|
||||
event. A failed validation does not promote partial data.
|
||||
@@ -36,14 +40,16 @@ DOLT_EARNINGS_SUBDIR=earnings
|
||||
DOLT_MIN_FREE_DISK_GB=5.0
|
||||
SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com)
|
||||
SEC_REQUEST_SPACING_SECONDS=0.2
|
||||
FUNDAMENTALS_PARITY_REPORT_DIR=/var/lib/signal-platform/reports/fundamentals-parity
|
||||
```
|
||||
|
||||
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
|
||||
path; 8–10 GB gives comfortable growth headroom. The data directory must stay
|
||||
outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
|
||||
The parity-report directory is also persistent and owned by the service user;
|
||||
its small timestamped JSON/CSV bundles form the temporary A5 review trail.
|
||||
|
||||
`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
|
||||
|
||||
@@ -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:
|
||||
|
||||
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`.
|
||||
2. Trigger **SEC Fundamentals Import**. The first run performs the
|
||||
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
|
||||
quality gate should show **New setups paused** with the specific SEC reason.
|
||||
|
||||
## A5 parity observation window
|
||||
|
||||
After both shadow imports are healthy, trigger **Fundamentals Parity Report
|
||||
(read-only)** once in Admin → Jobs. The **A5 Fundamentals Parity** card above
|
||||
the jobs shows the latest coverage/delta summary and provides authenticated JSON
|
||||
and CSV downloads. The canonical server-side bundles are archived at:
|
||||
|
||||
```text
|
||||
/var/lib/signal-platform/reports/fundamentals-parity/
|
||||
```
|
||||
|
||||
The scheduler then generates one report daily at 05:30 New York time, after the
|
||||
02:30 Dolt and 04:00 SEC jobs. Review 5–7 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.
|
||||
## 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
|
||||
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_sec_dolt_cutover_enabled`. An absent value, `false`, or any
|
||||
value other than `true` leaves `fundamental_data` untouched. Before enabling it,
|
||||
confirm the normal PostgreSQL backup containing `fundamental_data` is current.
|
||||
`fundamental_data` is the compat cache scoring reads. The SEC Fundamentals
|
||||
Import rebuilds it every run from data already in PostgreSQL: newest valid
|
||||
snapshots x latest close for `pe_ratio` and `market_cap`, snapshots alone for
|
||||
`revenue_growth`, and `earnings_events` for `earnings_surprise` and
|
||||
`next_earnings_date`. It therefore also runs after an SEC network/validation
|
||||
failure, a `no_op`, a source-lock skip, or with the job disabled — no network
|
||||
access is involved. The job message appends the cache row count and the changed
|
||||
score-input count.
|
||||
|
||||
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.
|
||||
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:
|
||||
Verify the refreshed rows:
|
||||
|
||||
```sql
|
||||
INSERT INTO system_settings (key, value, updated_at)
|
||||
VALUES ('fundamental_data_sec_dolt_cutover_enabled', 'true', now())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = now();
|
||||
```
|
||||
|
||||
Then trigger **SEC Fundamentals Import** once in Admin → Jobs. Once enabled, the
|
||||
same refresh also runs after an SEC network/validation failure or a source-lock
|
||||
skip, because it reads only PostgreSQL snapshots, earnings events, and closes.
|
||||
The job message appends the cache row count and changed score-input count when
|
||||
the import itself completed successfully.
|
||||
|
||||
Verify the switch and refreshed rows:
|
||||
|
||||
```sql
|
||||
SELECT key, value, updated_at
|
||||
FROM system_settings
|
||||
WHERE key = 'fundamental_data_sec_dolt_cutover_enabled';
|
||||
|
||||
SELECT count(*) AS rows,
|
||||
max(fetched_at) AS refreshed_at,
|
||||
count(pe_ratio) AS pe_available,
|
||||
@@ -213,28 +178,26 @@ SELECT dimension, is_stale, count(*)
|
||||
FROM dimension_scores
|
||||
WHERE dimension = 'fundamental'
|
||||
GROUP BY dimension, is_stale;
|
||||
|
||||
SELECT is_stale, count(*)
|
||||
FROM composite_scores
|
||||
GROUP BY is_stale;
|
||||
```
|
||||
|
||||
The first refresh intentionally marks affected fundamental and composite score
|
||||
caches stale. The normal 15:30 near-close scanner recomputes them before using
|
||||
the rankings; until then, reads truthfully expose the stale state. Observe at
|
||||
least several scheduled cycles before A6 removes the legacy providers.
|
||||
|
||||
## Failure and rollback
|
||||
|
||||
- To stop the A5 cache writes without stopping SEC snapshot ingestion, turn off
|
||||
**Use SEC + Dolt for scoring inputs** in Admin → Settings. If the UI is
|
||||
unavailable, set `fundamental_data_sec_dolt_cutover_enabled` back to `false`
|
||||
with the SQL above (changing only the value). This prevents the next local
|
||||
refresh but does not restore rows already replaced. Restore `fundamental_data`
|
||||
from the pre-cutover database backup, or—before A6—manually run the legacy
|
||||
Fundamental Collector if its provider keys and quota are still available.
|
||||
- Disable a failing source-import job in Admin → Jobs only when ingestion itself
|
||||
must stop. Existing promoted snapshots/events remain available.
|
||||
- **There is no provider fallback any more, and no Admin switch that freezes the
|
||||
cache.** Disabling **SEC Fundamentals Import** stops SEC network access only;
|
||||
the 04:00 job still rebuilds `fundamental_data` from the stored snapshots,
|
||||
earnings events and closes.
|
||||
- Restoring `fundamental_data` from the PostgreSQL backup is therefore a
|
||||
*temporary* fix on its own: if the bad values come from the snapshots or from
|
||||
the derivation code, the next scheduled run reproduces them. Fix the cause —
|
||||
restore or repair `fundamental_snapshots` / `earnings_events`, or revert the
|
||||
parser change and re-run `scripts/reparse_fundamentals.py --apply`.
|
||||
- To genuinely freeze the cache while you work, stop the service
|
||||
(`sudo systemctl stop signalplatform.service`) — that stops the scheduler with
|
||||
it. There is no finer-grained control, by design: a silently frozen scoring
|
||||
input is worse than an obvious outage.
|
||||
- Disable a failing source-import job in Admin → Jobs when SEC network access
|
||||
itself must stop. Existing promoted snapshots and events remain available, and
|
||||
the job's runtime message still reports the cache result.
|
||||
- Inspect the job runtime, latest `data_import_runs.validation_json`, service
|
||||
logs, and Admin → System Events before retrying.
|
||||
- `unresolved_filing` is emitted once when a filing enters automatic retry. It
|
||||
@@ -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.
|
||||
PostgreSQL (including `earnings_events`, `fundamental_snapshots`, and import
|
||||
audit rows) must remain covered by the normal production database backup.
|
||||
- Do not proceed to A6 until the activated cache has completed the observation
|
||||
window and the forward earnings calendar remains timely.
|
||||
- If report generation fails, inspect Admin → System Events and verify
|
||||
`FUNDAMENTALS_PARITY_REPORT_DIR` exists and is writable by `deploy`. Existing
|
||||
reports and all live data remain untouched.
|
||||
|
||||
+18
-2
@@ -25,7 +25,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
||||
| 1.5× ATR initial stop | Real exit | Cuts losers fast |
|
||||
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
|
||||
| Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) |
|
||||
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice |
|
||||
| Max **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 |
|
||||
| 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.5–4.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau |
|
||||
| Momentum lookback (6-1, 3-1, 12-7 Novy-Marx, composites) | **Keep residual 12-1** — the others have IC ≈ 0 or weaker t-stats |
|
||||
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 80 × 10** — monotonically worse in both directions |
|
||||
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 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** |
|
||||
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
|
||||
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
|
||||
@@ -146,6 +146,7 @@ knobs.
|
||||
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. −0.048 / t −1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
|
||||
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
|
||||
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
|
||||
| **Minimum effective-risk floor** | ⛔ 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
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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** |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 5–25 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.15–0.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.
|
||||
@@ -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 2019–2025 is the measurement anchor.
|
||||
Seed the portfolio on the first session of every ISO week falling 63–126
|
||||
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 2019–2025 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.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
observation onto historical snapshots. Because the observation is stored in a
|
||||
single slot, a refresh replaces the previously effective record: the snapshot
|
||||
therefore reports the overlay as `pending` until the new effective date, and the
|
||||
live reading additionally carries `fundamental_context` so a just-collected
|
||||
observation is visible immediately rather than appearing to have done nothing.
|
||||
therefore reports the overlay as `pending` until the new effective date.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
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
|
||||
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 1–3 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
|
||||
|
||||
Quadrant alerts default off for new/reset configurations. When enabled they
|
||||
|
||||
+23
-67
@@ -4,7 +4,6 @@ import type {
|
||||
AdminUser,
|
||||
AlertConfig,
|
||||
AlertTestResult,
|
||||
FundamentalsCutoverConfig,
|
||||
PipelineReadiness,
|
||||
RecommendationConfig,
|
||||
ScheduleConfig,
|
||||
@@ -57,18 +56,6 @@ export function updateSetting(key: string, value: string) {
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getFundamentalsCutoverSettings() {
|
||||
return apiClient
|
||||
.get<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateFundamentalsCutoverSettings(enabled: boolean) {
|
||||
return apiClient
|
||||
.put<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover', { enabled })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getRecommendationSettings() {
|
||||
return apiClient
|
||||
.get<RecommendationConfig>('admin/settings/recommendations')
|
||||
@@ -220,14 +207,28 @@ export function backfillTickerNames() {
|
||||
}
|
||||
|
||||
// Jobs
|
||||
export type JobCategory = 'pipeline' | 'pipeline_step' | 'scheduled' | 'manual';
|
||||
export type NextRunSource = 'own_schedule' | 'via_pipeline' | 'manual_only';
|
||||
|
||||
export interface JobStatus {
|
||||
name: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
next_run_at: string | null;
|
||||
via_pipeline?: boolean;
|
||||
registered: boolean;
|
||||
category?: JobCategory;
|
||||
/** Server-assigned ordering; the payload already arrives grouped by it. */
|
||||
sort_order?: [number, number];
|
||||
/** Parent pipelines for a step. Many-to-many: data_collector runs in all four. */
|
||||
pipelines?: string[];
|
||||
/** Step names, for a pipeline row. */
|
||||
steps?: string[];
|
||||
next_run_at: string | null;
|
||||
next_run_source?: NextRunSource;
|
||||
/** For a step: the soonest enabled parent's next run, and which parent. */
|
||||
via_next_run_at?: string | null;
|
||||
via_next_run_job?: string | null;
|
||||
running?: boolean;
|
||||
/** runtime_* is live, in-memory state only — it resets when the app restarts. */
|
||||
runtime_status?: string | null;
|
||||
runtime_processed?: number | null;
|
||||
runtime_total?: number | null;
|
||||
@@ -236,6 +237,13 @@ export interface JobStatus {
|
||||
runtime_started_at?: string | null;
|
||||
runtime_finished_at?: string | null;
|
||||
runtime_message?: string | null;
|
||||
/** last_run_* is persisted and survives restarts. Kept separate from
|
||||
* runtime_* so a stale error cannot pin the status chip or the banner. */
|
||||
last_run_at?: string | null;
|
||||
last_run_status?: string | null;
|
||||
last_run_message?: string | null;
|
||||
last_run_processed?: number | null;
|
||||
last_run_total?: number | null;
|
||||
}
|
||||
|
||||
export interface TriggerJobResponse {
|
||||
@@ -246,40 +254,6 @@ export interface TriggerJobResponse {
|
||||
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 BacktestCadence = 'weekly' | 'daily';
|
||||
|
||||
@@ -306,24 +280,6 @@ export function triggerJob(
|
||||
.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)
|
||||
export interface SystemEvent {
|
||||
id: number;
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface FetchDataResult {
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
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: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched ticker’s composite drops sharply' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin';
|
||||
import type { JobCategory, JobStatus } from '../../api/admin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
function formatNextRun(iso: string | null): string {
|
||||
@@ -10,7 +11,8 @@ function formatNextRun(iso: string | null): string {
|
||||
const mins = Math.round(diffMs / 60_000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
return `in ${hrs}h`;
|
||||
if (hrs < 48) return `in ${hrs}h`;
|
||||
return `in ${Math.round(hrs / 24)}d`;
|
||||
}
|
||||
|
||||
function formatAgo(iso: string | null | undefined): string {
|
||||
@@ -29,85 +31,95 @@ function lastRunColor(status: string | null | undefined): string {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
export function JobControls() {
|
||||
const { data: jobs, isLoading } = useJobs();
|
||||
const toggleJob = useToggleJob();
|
||||
const triggerJob = useTriggerJob();
|
||||
const anyJobRunning = (jobs ?? []).some((job) => job.running);
|
||||
const runningJob = jobs?.find((job) => job.running);
|
||||
const pausedJob = jobs?.find((job) => !job.running && job.runtime_status === 'rate_limited');
|
||||
const runningJobLabel = runningJob?.label;
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
||||
/** The four kinds of job, in the order the API already sorts them. A job whose
|
||||
* category the client does not recognise still renders, under "Other" — better
|
||||
* a stray section than a job that silently vanishes from the admin page. */
|
||||
const SECTIONS: { key: JobCategory; title: string; hint: string }[] = [
|
||||
{
|
||||
key: 'pipeline',
|
||||
title: 'Pipelines',
|
||||
hint: 'own schedule · run their steps in order',
|
||||
},
|
||||
{
|
||||
key: 'pipeline_step',
|
||||
title: 'Pipeline steps',
|
||||
hint: 'no timer of their own · still triggerable individually',
|
||||
},
|
||||
{
|
||||
key: 'scheduled',
|
||||
title: 'Standalone scheduled',
|
||||
hint: 'own schedule · independent of any pipeline',
|
||||
},
|
||||
{ key: 'manual', title: 'Manual only', hint: 'never fires on its own' },
|
||||
];
|
||||
|
||||
/** One consistent answer per job: its own timer, its parent's, or "manual only".
|
||||
* A step has no schedule of its own, so reporting one was the original bug. */
|
||||
function NextRun({ job, labels }: { job: JobStatus; labels: Record<string, string> }) {
|
||||
const muted = 'text-[11px] text-gray-500';
|
||||
if (job.next_run_source === 'manual_only') {
|
||||
return <span className={muted}>manual only</span>;
|
||||
}
|
||||
if (job.next_run_source === 'via_pipeline') {
|
||||
if (!job.via_next_run_at || !job.via_next_run_job) {
|
||||
return <span className={muted}>runs via pipeline</span>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{runningJob && (
|
||||
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-blue-300">
|
||||
Active job: {runningJob.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-100/80">
|
||||
Manual triggers are blocked until this run finishes.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-blue-200">
|
||||
{runningJob.runtime_processed ?? 0}
|
||||
{typeof runningJob.runtime_total === 'number'
|
||||
? ` / ${runningJob.runtime_total}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-700/80 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-400 transition-all duration-500"
|
||||
style={{
|
||||
width: `${
|
||||
typeof runningJob.runtime_progress_pct === 'number'
|
||||
? Math.max(5, Math.min(100, runningJob.runtime_progress_pct))
|
||||
: 30
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{runningJob.runtime_current_ticker && (
|
||||
<div className="mt-1 text-[11px] text-blue-100/80">
|
||||
Current: {runningJob.runtime_current_ticker}
|
||||
</div>
|
||||
)}
|
||||
{runningJob.runtime_message && (
|
||||
<div className="mt-1 text-[11px] text-blue-100/80">
|
||||
{runningJob.runtime_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className={muted}>
|
||||
Next via {labels[job.via_next_run_job] ?? job.via_next_run_job}{' '}
|
||||
{formatNextRun(job.via_next_run_at)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!job.next_run_at) return null;
|
||||
return <span className={muted}>Next run {formatNextRun(job.next_run_at)}</span>;
|
||||
}
|
||||
|
||||
{!runningJob && pausedJob && (
|
||||
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-amber-300">
|
||||
Last run paused: {pausedJob.label}
|
||||
/** Membership, shown rather than nested: a step can belong to several pipelines
|
||||
* (data_collector is in all four), so duplicating rows under each parent would
|
||||
* render Trigger buttons that are not distinct actions. */
|
||||
function Membership({ job, labels }: { job: JobStatus; labels: Record<string, string> }) {
|
||||
const name = (id: string) => labels[id] ?? id;
|
||||
if (job.category === 'pipeline' && job.steps?.length) {
|
||||
return (
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-gray-600">
|
||||
{job.steps.map(name).join(' → ')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-100/90">
|
||||
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'}
|
||||
);
|
||||
}
|
||||
if (job.category === 'pipeline_step' && job.pipelines?.length) {
|
||||
return (
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-gray-600">
|
||||
runs in: {job.pipelines.map(name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-200">
|
||||
{pausedJob.runtime_processed ?? 0}
|
||||
{typeof pausedJob.runtime_total === 'number'
|
||||
? ` / ${pausedJob.runtime_total}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
{jobs?.map((job) => (
|
||||
<div key={job.name} className="glass p-4 glass-hover">
|
||||
interface JobCardProps {
|
||||
job: JobStatus;
|
||||
labels: Record<string, string>;
|
||||
anyJobRunning: boolean;
|
||||
runningJobLabel?: string;
|
||||
onToggle: (job: JobStatus) => void;
|
||||
onTrigger: (job: JobStatus) => void;
|
||||
togglePending: boolean;
|
||||
triggerPending: boolean;
|
||||
}
|
||||
|
||||
function JobCard({
|
||||
job,
|
||||
labels,
|
||||
anyJobRunning,
|
||||
runningJobLabel,
|
||||
onToggle,
|
||||
onTrigger,
|
||||
togglePending,
|
||||
triggerPending,
|
||||
}: JobCardProps) {
|
||||
return (
|
||||
<div className="glass p-4 glass-hover">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status dot */}
|
||||
@@ -122,7 +134,9 @@ export function JobControls() {
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-200">{job.label}</span>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-3">
|
||||
{/* Live state only — a persisted error must not read as the
|
||||
current status forever, so this never consults last_run_*. */}
|
||||
<span
|
||||
className={`text-[11px] font-medium ${
|
||||
job.running
|
||||
@@ -148,26 +162,23 @@ export function JobControls() {
|
||||
? 'Active'
|
||||
: 'Inactive'}
|
||||
</span>
|
||||
{job.via_pipeline ? (
|
||||
<span className="text-[11px] text-gray-500">runs via pipeline</span>
|
||||
) : (
|
||||
job.enabled && job.next_run_at && (
|
||||
<span className="text-[11px] text-gray-500">
|
||||
Next run {formatNextRun(job.next_run_at)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{job.enabled && <NextRun job={job} labels={labels} />}
|
||||
{!job.registered && (
|
||||
<span className="text-[11px] text-red-400">Not registered</span>
|
||||
)}
|
||||
</div>
|
||||
{!job.running && job.runtime_finished_at && (
|
||||
<div className={`mt-1 text-[11px] ${lastRunColor(job.runtime_status)}`}>
|
||||
Last run {formatAgo(job.runtime_finished_at)}
|
||||
{job.runtime_status ? ` · ${job.runtime_status}` : ''}
|
||||
{job.runtime_message ? ` — ${job.runtime_message}` : ''}
|
||||
<Membership job={job} labels={labels} />
|
||||
{/* Persisted, so this survives a deploy — unlike runtime_* above. */}
|
||||
{!job.running && job.last_run_at && (
|
||||
<div className={`mt-1 text-[11px] ${lastRunColor(job.last_run_status)}`}>
|
||||
Last run {formatAgo(job.last_run_at)}
|
||||
{job.last_run_status ? ` · ${job.last_run_status}` : ''}
|
||||
{job.last_run_message ? ` — ${job.last_run_message}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{!job.running && !job.last_run_at && (
|
||||
<div className="mt-1 text-[11px] text-gray-600">No run recorded yet</div>
|
||||
)}
|
||||
{job.running && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-400">
|
||||
@@ -180,7 +191,7 @@ export function JobControls() {
|
||||
<span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-56 rounded-full bg-slate-700/80 overflow-hidden">
|
||||
<div className="h-1.5 w-56 overflow-hidden rounded-full bg-slate-700/80">
|
||||
<div
|
||||
className="h-full bg-blue-400 transition-all duration-500"
|
||||
style={{
|
||||
@@ -203,8 +214,8 @@ export function JobControls() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
|
||||
disabled={toggleJob.isPending}
|
||||
onClick={() => onToggle(job)}
|
||||
disabled={togglePending}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
|
||||
job.enabled
|
||||
? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20'
|
||||
@@ -215,14 +226,14 @@ export function JobControls() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerJob.mutate(job.name)}
|
||||
disabled={triggerJob.isPending || !job.enabled || anyJobRunning}
|
||||
className="btn-primary px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => onTrigger(job)}
|
||||
disabled={triggerPending || !job.enabled || anyJobRunning}
|
||||
className="btn-primary px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span>
|
||||
{job.running
|
||||
? 'Running…'
|
||||
: triggerJob.isPending
|
||||
: triggerPending
|
||||
? 'Triggering…'
|
||||
: anyJobRunning
|
||||
? 'Blocked'
|
||||
@@ -237,7 +248,128 @@ export function JobControls() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobControls() {
|
||||
const { data: jobs, isLoading } = useJobs();
|
||||
const toggleJob = useToggleJob();
|
||||
const triggerJob = useTriggerJob();
|
||||
const all = jobs ?? [];
|
||||
// Job id -> display label, so a step can name its parent pipeline.
|
||||
const labels = Object.fromEntries(all.map((job) => [job.name, job.label]));
|
||||
const anyJobRunning = all.some((job) => job.running);
|
||||
const runningJob = all.find((job) => job.running);
|
||||
const pausedJob = all.find((job) => !job.running && job.runtime_status === 'rate_limited');
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
||||
|
||||
const known = new Set<string>(SECTIONS.map((s) => s.key));
|
||||
const groups: { key: string; title: string; hint: string; jobs: JobStatus[] }[] = [
|
||||
...SECTIONS.map((section) => ({
|
||||
...section,
|
||||
jobs: all.filter((job) => job.category === section.key),
|
||||
})),
|
||||
{
|
||||
key: 'other',
|
||||
title: 'Other',
|
||||
hint: 'uncategorised',
|
||||
jobs: all.filter((job) => !job.category || !known.has(job.category)),
|
||||
},
|
||||
];
|
||||
|
||||
const cardProps = {
|
||||
labels,
|
||||
anyJobRunning,
|
||||
runningJobLabel: runningJob?.label,
|
||||
onToggle: (job: JobStatus) =>
|
||||
toggleJob.mutate({ jobName: job.name, enabled: !job.enabled }),
|
||||
onTrigger: (job: JobStatus) => triggerJob.mutate(job.name),
|
||||
togglePending: toggleJob.isPending,
|
||||
triggerPending: triggerJob.isPending,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{runningJob && (
|
||||
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-blue-300">
|
||||
Active job: {runningJob.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-100/80">
|
||||
Manual triggers are blocked until this run finishes.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-blue-200">
|
||||
{runningJob.runtime_processed ?? 0}
|
||||
{typeof runningJob.runtime_total === 'number'
|
||||
? ` / ${runningJob.runtime_total}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-700/80">
|
||||
<div
|
||||
className="h-full bg-blue-400 transition-all duration-500"
|
||||
style={{
|
||||
width: `${
|
||||
typeof runningJob.runtime_progress_pct === 'number'
|
||||
? Math.max(5, Math.min(100, runningJob.runtime_progress_pct))
|
||||
: 30
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{runningJob.runtime_current_ticker && (
|
||||
<div className="mt-1 text-[11px] text-blue-100/80">
|
||||
Current: {runningJob.runtime_current_ticker}
|
||||
</div>
|
||||
)}
|
||||
{runningJob.runtime_message && (
|
||||
<div className="mt-1 text-[11px] text-blue-100/80">{runningJob.runtime_message}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!runningJob && pausedJob && (
|
||||
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-amber-300">
|
||||
Last run paused: {pausedJob.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-100/90">
|
||||
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-200">
|
||||
{pausedJob.runtime_processed ?? 0}
|
||||
{typeof pausedJob.runtime_total === 'number'
|
||||
? ` / ${pausedJob.runtime_total}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map(
|
||||
(group) =>
|
||||
group.jobs.length > 0 && (
|
||||
<section key={group.key} className="space-y-3">
|
||||
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
{group.title}
|
||||
<span className="ml-2 num text-gray-600">{group.jobs.length}</span>
|
||||
<span className="ml-2 normal-case tracking-normal text-gray-600">
|
||||
{group.hint}
|
||||
</span>
|
||||
</h3>
|
||||
{group.jobs.map((job) => (
|
||||
<JobCard key={job.name} job={job} {...cardProps} />
|
||||
))}
|
||||
</section>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ const DEFAULTS: ScheduleConfig = {
|
||||
schedule_daily_pipeline_cron: '0 2 * * *',
|
||||
schedule_dolt_earnings_cron: '30 2 * * *',
|
||||
schedule_sec_fundamentals_cron: '0 4 * * *',
|
||||
schedule_fundamentals_parity_cron: '30 5 * * *',
|
||||
schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
|
||||
schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
|
||||
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
|
||||
schedule_fundamentals_cron: '0 1 * * mon',
|
||||
schedule_backtest_cron: '0 3 * * sun',
|
||||
schedule_ticker_universe_cron: '0 1 * * *',
|
||||
};
|
||||
|
||||
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
|
||||
@@ -24,25 +24,19 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
|
||||
{
|
||||
key: 'schedule_daily_pipeline_cron',
|
||||
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,
|
||||
},
|
||||
{
|
||||
key: 'schedule_dolt_earnings_cron',
|
||||
label: 'Dolt earnings',
|
||||
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The activated cache refresh uses these local events.',
|
||||
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The fundamentals cache refresh uses these local events.',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'schedule_sec_fundamentals_cron',
|
||||
label: 'SEC fundamentals',
|
||||
hint: 'Import tracked-universe SEC facts daily at 04:00 ET and refresh the scoring cache when the cutover is active.',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'schedule_fundamentals_parity_cron',
|
||||
label: 'Fundamentals parity report',
|
||||
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the bulk imports.',
|
||||
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,
|
||||
},
|
||||
{
|
||||
@@ -64,9 +58,15 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'schedule_fundamentals_cron',
|
||||
label: 'Legacy fundamentals (weekly)',
|
||||
hint: 'Fallback provider chain. Automatically skipped while the SEC + Dolt cutover is active.',
|
||||
key: 'schedule_backtest_cron',
|
||||
label: 'Backtest',
|
||||
hint: 'Replay history and refresh the Track Record report. Default Sunday 03:00 ET. Was a 168h interval, which restarted on every deploy and so could defer indefinitely.',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'schedule_ticker_universe_cron',
|
||||
label: 'Ticker universe sync',
|
||||
hint: 'Refresh the tracked-symbol universe. Default 01:00 ET daily, before the morning pipeline.',
|
||||
mono: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import type { SystemSetting } from '../../lib/types';
|
||||
|
||||
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
|
||||
|
||||
export function SettingsForm() {
|
||||
const { data: settings, isLoading, isError, error } = useSettings();
|
||||
const updateSetting = useUpdateSetting();
|
||||
@@ -34,11 +32,10 @@ export function SettingsForm() {
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
|
||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
|
||||
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
|
||||
const visibleSettings = settings.filter((setting) => !MANAGED_SETTINGS.has(setting.key));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{visibleSettings.map((setting) => (
|
||||
{settings.map((setting) => (
|
||||
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
|
||||
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
|
||||
{setting.key === 'registration' ? (
|
||||
|
||||
@@ -7,7 +7,7 @@ const navItems = [
|
||||
{ to: '/', label: 'Overview', end: true },
|
||||
{ to: '/market', label: 'Market', end: false },
|
||||
{ to: '/signals', label: 'Signals', end: false },
|
||||
{ to: '/regime', label: 'Regime', end: false },
|
||||
{ to: '/regime', label: 'Risk', end: false },
|
||||
];
|
||||
|
||||
export default function MobileNav() {
|
||||
|
||||
@@ -13,7 +13,8 @@ const navItems = [
|
||||
{ to: '/', label: 'Overview', end: true },
|
||||
{ to: '/market', label: 'Market', 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) =>
|
||||
@@ -84,7 +85,7 @@ export default function TopBar() {
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<NavLink
|
||||
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="text-[11px] capitalize text-gray-500 transition-colors group-hover:text-gray-300">
|
||||
{regime.data.label} regime
|
||||
{regime.data.label} trend
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
return useQuery({
|
||||
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() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'pipeline-readiness'],
|
||||
|
||||
@@ -36,7 +36,7 @@ export function regimeHeadline(r: MarketRegime): string {
|
||||
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 {
|
||||
if (label === 'bullish') return direction === 'short';
|
||||
if (label === 'bearish') return direction === 'long';
|
||||
|
||||
@@ -187,21 +187,17 @@ export interface ActivationConfig {
|
||||
exclude_neutral: boolean;
|
||||
}
|
||||
|
||||
export interface FundamentalsCutoverConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Cron schedule for morning / near-close / after-close / intraday + fundamentals
|
||||
export interface ScheduleConfig {
|
||||
schedule_timezone: string;
|
||||
schedule_daily_pipeline_cron: string;
|
||||
schedule_dolt_earnings_cron: string;
|
||||
schedule_sec_fundamentals_cron: string;
|
||||
schedule_fundamentals_parity_cron: string;
|
||||
schedule_near_close_pipeline_cron: string;
|
||||
schedule_after_close_pipeline_cron: string;
|
||||
schedule_intraday_pipeline_cron: string;
|
||||
schedule_fundamentals_cron: string;
|
||||
schedule_backtest_cron: string;
|
||||
schedule_ticker_universe_cron: string;
|
||||
}
|
||||
|
||||
// Runtime sentiment LLM configuration
|
||||
@@ -506,6 +502,9 @@ export interface RegimeFundamentalOverlay {
|
||||
reasoning: string | null;
|
||||
source: 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;
|
||||
}
|
||||
|
||||
@@ -555,6 +554,9 @@ export interface RegimeMonitor {
|
||||
inputs_fresh: boolean;
|
||||
snapshot_age_days?: number;
|
||||
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 };
|
||||
}
|
||||
@@ -892,7 +894,7 @@ export interface TickerUniverseSetting {
|
||||
|
||||
export interface TickerUniverseBootstrapResult {
|
||||
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;
|
||||
total_universe_symbols: number;
|
||||
added: number;
|
||||
|
||||
@@ -5,8 +5,6 @@ import { AlertSettings } from '../components/admin/AlertSettings';
|
||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
import { JobControls } from '../components/admin/JobControls';
|
||||
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
|
||||
import { FundamentalsCutoverSettings } from '../components/admin/FundamentalsCutoverSettings';
|
||||
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
|
||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
||||
@@ -37,7 +35,6 @@ export default function AdminPage() {
|
||||
{activeTab === 'Tickers' && <TickerManagement />}
|
||||
{activeTab === 'Settings' && (
|
||||
<div className="space-y-4">
|
||||
<FundamentalsCutoverSettings />
|
||||
<ActivationSettings />
|
||||
<ExitPolicySettings />
|
||||
<PerformanceSettings />
|
||||
@@ -51,7 +48,6 @@ export default function AdminPage() {
|
||||
{activeTab === 'Jobs' && (
|
||||
<div className="space-y-4">
|
||||
<ScheduleSettings />
|
||||
<FundamentalsParityPanel />
|
||||
<JobControls />
|
||||
<PipelineReadinessPanel />
|
||||
</div>
|
||||
|
||||
@@ -24,11 +24,11 @@ import type {
|
||||
RegimeFundamentalOverlay,
|
||||
RegimeFundamentals,
|
||||
RegimeFundamentalsUpdate,
|
||||
RegimeMonitor,
|
||||
RegimeReading,
|
||||
} from '../lib/types';
|
||||
|
||||
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
|
||||
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
|
||||
const RegimeChart = lazy(() => import('../components/regime/RegimeChart'));
|
||||
|
||||
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' },
|
||||
@@ -53,12 +53,10 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
|
||||
function ScoreGauge({
|
||||
label,
|
||||
reading,
|
||||
divider,
|
||||
footnote,
|
||||
}: {
|
||||
label: string;
|
||||
reading: RegimeReading | undefined;
|
||||
divider?: number;
|
||||
footnote: ReactNode;
|
||||
}) {
|
||||
const score = reading?.score;
|
||||
@@ -66,7 +64,9 @@ function ScoreGauge({
|
||||
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
|
||||
const position = Math.min(100, Math.max(0, score ?? 0));
|
||||
const bands = reading?.bands;
|
||||
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80];
|
||||
// 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 (
|
||||
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -92,10 +92,10 @@ function ScoreGauge({
|
||||
</div>
|
||||
{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">
|
||||
{divider != null && (
|
||||
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
|
||||
)}
|
||||
<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'}`}
|
||||
style={{ left: `${position}%` }}
|
||||
@@ -113,7 +113,7 @@ function ScoreGauge({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -125,30 +125,48 @@ const CAPEX_TONE: Record<CapexState, string> = {
|
||||
unknown: 'text-gray-500',
|
||||
};
|
||||
|
||||
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
|
||||
|
||||
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
||||
const capex = overlay.capex ?? {};
|
||||
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 (
|
||||
<div className="glass border border-white/[0.06] p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Fundamental overlay · context, not scored
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||||
{overlay.source && <span>{overlay.source}</span>}
|
||||
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>}
|
||||
{/* 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.stale && <Badge label="stale" variant="manual" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overlay.pending ? (
|
||||
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
|
||||
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
|
||||
Observations are never backdated, so the reading below appears from that session onward.
|
||||
{/* A pending observation is still shown — it is the freshest read we
|
||||
have, and nothing here is scored. The date says when the stored
|
||||
point-in-time record picks it up. */}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
)}
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
||||
@@ -174,24 +192,20 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{overlay.reasoning && (
|
||||
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
|
||||
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
|
||||
Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even
|
||||
when both were pegged — so they are reported here rather than diluted into a daily score.
|
||||
</p>
|
||||
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
||||
</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 (
|
||||
<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]">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
</tr>
|
||||
</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) => (
|
||||
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
@@ -218,16 +241,53 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
|
||||
</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-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>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
</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 }) {
|
||||
const metrics = report.metrics;
|
||||
return (
|
||||
@@ -278,9 +338,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||
<p>
|
||||
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
||||
{report.reliability.events_detected} detected corrections fall in the test period (
|
||||
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially
|
||||
different headline, and which events flip is usually decided by where the frozen threshold
|
||||
lands rather than by what the score saw. Read the direction, not the ratio.
|
||||
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
|
||||
</p>
|
||||
)}
|
||||
{report.reliability.sensor_coverage_mismatch && (
|
||||
@@ -290,17 +348,12 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||
{report.reliability.sensors_expected} Warning sensors versus{' '}
|
||||
{report.reliability.holdout_full_sensor_share}% of test sessions
|
||||
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`}
|
||||
. The score renormalises over what is available, so the threshold was frozen on a partly
|
||||
different construct than it is measured against.
|
||||
. The threshold was frozen on a partly different construct than it is measured against.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
<p className="text-[11px] leading-relaxed text-gray-600">
|
||||
The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
|
||||
pre-freeze basket history remains exploratory.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -375,7 +428,7 @@ function FundamentalsEditor({
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
||||
<span>
|
||||
@@ -450,14 +503,17 @@ export default function RegimePage() {
|
||||
const isAdmin = useAuthStore((state) => state.role) === 'admin';
|
||||
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
||||
const data = monitor.data;
|
||||
const inputs = data?.inputs;
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" />
|
||||
<Callout variant="info"><strong>Risk thermometer — not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout>
|
||||
<PageHeader
|
||||
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.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 && (
|
||||
<>
|
||||
@@ -467,39 +523,36 @@ export default function RegimePage() {
|
||||
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ScoreGauge
|
||||
label="State · current structural stress"
|
||||
label="State · stress right now"
|
||||
reading={data.state}
|
||||
divider={data.quadrant_config?.state_divider}
|
||||
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 ?? '—'}.</>}
|
||||
footnote={
|
||||
<>
|
||||
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
|
||||
label="Warning · deterioration & divergence"
|
||||
reading={data.warning}
|
||||
divider={data.quadrant_config?.warning_divider}
|
||||
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.</>}
|
||||
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
|
||||
/>
|
||||
</div>
|
||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
||||
<p className="text-xs text-gray-600">
|
||||
Data quality · oldest market input:{' '}
|
||||
{data.data_quality?.oldest_market_input_age_days == null
|
||||
? 'unavailable'
|
||||
: `${data.data_quality.oldest_market_input_age_days}d`}
|
||||
</p>
|
||||
|
||||
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense>
|
||||
<Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<PillarBreakdown title="State" reading={data.state} />
|
||||
<PillarBreakdown title="Warning" reading={data.warning} />
|
||||
</div>
|
||||
{data.basket && (
|
||||
<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>
|
||||
)}
|
||||
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
||||
|
||||
<PillarTable state={data.state} warning={data.warning} />
|
||||
|
||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
||||
|
||||
<MetaStrip data={data} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ interface DataStatusItem {
|
||||
available: boolean;
|
||||
timestamp?: 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
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ function DataFreshnessBar({
|
||||
) : !item.available ? (
|
||||
<span className="text-[10px] text-gray-600">no data</span>
|
||||
) : null}
|
||||
{item.selector && (
|
||||
<button
|
||||
onClick={() => onRefresh(item)}
|
||||
disabled={busy}
|
||||
@@ -146,6 +147,7 @@ function DataFreshnessBar({
|
||||
>
|
||||
<RefreshIcon spinning={pendingLabel === item.label} />
|
||||
</button>
|
||||
)}
|
||||
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
|
||||
</div>
|
||||
))}
|
||||
@@ -226,11 +228,11 @@ export default function TickerDetailPage() {
|
||||
paid: true,
|
||||
},
|
||||
{
|
||||
// Rebuilt for the whole universe by the nightly SEC + Dolt imports —
|
||||
// there is no per-ticker fetch to offer here.
|
||||
label: 'Fundamentals',
|
||||
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
|
||||
timestamp: fundamentals.data?.fetched_at,
|
||||
selector: ['fundamentals'] as FetchSelector,
|
||||
paid: true,
|
||||
},
|
||||
{
|
||||
label: 'S/R Levels',
|
||||
@@ -247,6 +249,7 @@ export default function TickerDetailPage() {
|
||||
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
|
||||
|
||||
const handleRefresh = (item: DataStatusItem) => {
|
||||
if (!item.selector) return;
|
||||
setRefreshingLabel(item.label);
|
||||
ingestion.mutate(
|
||||
{ symbol, sources: item.selector },
|
||||
|
||||
@@ -40,3 +40,21 @@ include = ["app*"]
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
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.
|
||||
@@ -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())
|
||||
@@ -128,11 +128,10 @@ async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
|
||||
"""Return sorted unique symbols and source labels.
|
||||
|
||||
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 (
|
||||
_SEED_UNIVERSES,
|
||||
_fetch_universe_symbols_from_fmp,
|
||||
_fetch_universe_symbols_from_public,
|
||||
_normalise_symbols,
|
||||
)
|
||||
@@ -150,19 +149,11 @@ async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
|
||||
cleaned = _normalise_symbols(public_symbols)
|
||||
if cleaned:
|
||||
src = public_source or "public"
|
||||
else:
|
||||
if public_failures:
|
||||
elif public_failures:
|
||||
print(
|
||||
f" WARNING: public fetch {universe}: "
|
||||
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:
|
||||
cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, []))
|
||||
|
||||
@@ -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
|
||||
under the SEC fair-access throttle. Expect a long run and a lot of network.
|
||||
|
||||
Scope note: this rewrites ``fundamental_snapshots`` only. As of the A5 gate those
|
||||
rows feed the fundamentals API/UI and the parity report; scoring still reads the
|
||||
legacy ``fundamental_data`` table, so a reparse does not move composite scores or
|
||||
backtests until the cutover happens.
|
||||
Scope note: this rewrites ``fundamental_snapshots`` only. Those rows now feed both
|
||||
the fundamentals API/UI *and* — through the nightly ``fundamental_data`` refresh —
|
||||
the fundamental dimension of the composite score, so a reparse does move scores
|
||||
and backtests. Run it deliberately.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,10 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
)
|
||||
|
||||
POLICY_NAMES = (
|
||||
"immediate",
|
||||
"next_session",
|
||||
@@ -107,85 +111,6 @@ def _default_output_path() -> Path:
|
||||
return Path("reports") / f"daily-reentry-matrix-{stamp}.json"
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
"""Production-style percentiles, one deterministic symbol row per period."""
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
if identity in seen:
|
||||
raise ValueError(f"Duplicate universe rank observation: {identity}")
|
||||
seen.add(identity)
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row["ranking_period"])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group,
|
||||
key=lambda row: (float(row[value_key]), str(row["symbol"])),
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
"""Historical equivalent of ``compute_activation_ranks``.
|
||||
|
||||
Every ticker contributes at most once per session. Residual momentum starts
|
||||
only once 252 benchmark closes were point-in-time available; earlier dates
|
||||
use the same raw-momentum fallback as production.
|
||||
"""
|
||||
identities = [(str(row["symbol"]), str(row["date"])) for row in observations]
|
||||
if len(identities) != len(set(identities)):
|
||||
raise ValueError("Universe ranking requires one observation per ticker/date")
|
||||
|
||||
raw_pct = _period_percentiles(observations, "momentum")
|
||||
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": volatility_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
|
||||
|
||||
class PrecomputedDailyEngine:
|
||||
"""Exact date/symbol lookup over the already-ranked production gate."""
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
)
|
||||
|
||||
# Must match Phase A cache when reusing research-cands.pkl
|
||||
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||
|
||||
@@ -104,66 +108,6 @@ def _parse_args() -> argparse.Namespace:
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
for row in observations:
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row["ranking_period"])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
raw_pct = _period_percentiles(observations, "momentum")
|
||||
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": volatility_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
|
||||
|
||||
def _window(arm: dict, name: str) -> dict | None:
|
||||
for row in arm.get("windows") or []:
|
||||
if row.get("window") == name:
|
||||
|
||||
@@ -162,13 +162,13 @@ def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
|
||||
if len(rows) < 90:
|
||||
return None
|
||||
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):
|
||||
d = date.fromisoformat(d[:10])
|
||||
ords.append(d.toordinal())
|
||||
opens.append(float(o))
|
||||
highs.append(float(h))
|
||||
lows.append(float(l))
|
||||
lows.append(float(lo))
|
||||
closes.append(float(c))
|
||||
vols.append(float(v or 0))
|
||||
return (symbol, ords, opens, highs, lows, closes, vols, spy)
|
||||
@@ -275,7 +275,8 @@ def main() -> None:
|
||||
vol_weeks = collected.get("vol_6m") 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]:
|
||||
out: dict[tuple, dict] = {}
|
||||
for wk, recs in weeks_map.items():
|
||||
@@ -290,8 +291,6 @@ def main() -> None:
|
||||
return out
|
||||
|
||||
mom_ix = _index(mom_weeks)
|
||||
vol_ix = _index(vol_weeks)
|
||||
momr_ix = _index(momr_weeks)
|
||||
|
||||
# Per-week membership + extended checks via shared rich filter
|
||||
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)",
|
||||
"",
|
||||
f"| metric | value |",
|
||||
f"|---|---|",
|
||||
"| metric | value |",
|
||||
"|---|---|",
|
||||
f"| mean_ic | {h.get('mean_ic')} |",
|
||||
f"| ic_t_stat | {h.get('ic_t_stat')} |",
|
||||
f"| weeks | {h.get('weeks')} |",
|
||||
|
||||
@@ -162,8 +162,8 @@ def _write_md(path: Path, payload: dict) -> None:
|
||||
row = br.get("row") or br
|
||||
if row:
|
||||
lines.extend([
|
||||
f"| metric | value |",
|
||||
f"|---|---|",
|
||||
"| metric | value |",
|
||||
"|---|---|",
|
||||
f"| mean_ic | {row.get('mean_ic')} |",
|
||||
f"| ic_t_stat | {row.get('ic_t_stat')} |",
|
||||
f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
|
||||
|
||||
@@ -26,7 +26,6 @@ import os
|
||||
import pickle
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -68,6 +68,10 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
)
|
||||
|
||||
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||
|
||||
# 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()}"
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
for row in observations:
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row["ranking_period"])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
raw_pct = _period_percentiles(observations, "momentum")
|
||||
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": volatility_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#
|
||||
# Kept after Tier-1 cleanup:
|
||||
# --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
|
||||
#
|
||||
# Prerequisites: git checkout research branch, .env, deep research.sqlite for
|
||||
@@ -21,8 +20,6 @@ cd "$ROOT"
|
||||
RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}"
|
||||
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
|
||||
WORKERS="${WORKERS:-8}"
|
||||
FMP_LIMIT="${FMP_LIMIT:-250}"
|
||||
FMP_SLEEP="${FMP_SLEEP:-0.35}"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
USE_CORP_PROXY="${USE_CORP_PROXY:-0}"
|
||||
PHASE=""
|
||||
@@ -35,7 +32,6 @@ usage() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssl-check) PHASE=ssl; shift ;;
|
||||
--earnings-only) PHASE=earnings; shift ;;
|
||||
--prod-book-matrix) PHASE=prod_book; shift ;;
|
||||
--corp-proxy) USE_CORP_PROXY=1; shift ;;
|
||||
--workers) WORKERS="$2"; shift 2 ;;
|
||||
@@ -46,7 +42,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -104,7 +100,6 @@ print(json.dumps(ssl_status(), indent=2))
|
||||
print("bootstrap ->", bootstrap_ssl())
|
||||
for url in (
|
||||
"https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&limit=1",
|
||||
"https://financialmodelingprep.com/stable/profile?symbol=AAPL",
|
||||
):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ssl-check"})
|
||||
@@ -118,17 +113,6 @@ PY
|
||||
setup_ssl
|
||||
case "$PHASE" in
|
||||
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)
|
||||
need_file "$RESEARCH_SNAP"
|
||||
log "Production book universe × horizon matrix"
|
||||
|
||||
@@ -8,9 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.exceptions import ValidationError
|
||||
from app.services.admin_service import (
|
||||
get_activation_config,
|
||||
get_fundamentals_cutover_config,
|
||||
update_activation_config,
|
||||
update_fundamentals_cutover_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,18 +76,3 @@ class TestActivationConfig:
|
||||
async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
|
||||
with pytest.raises(ValidationError):
|
||||
await update_activation_config(session, {"min_confidence": 120.0})
|
||||
|
||||
|
||||
class TestFundamentalsCutoverConfig:
|
||||
async def test_defaults_off_when_unset(self, session: AsyncSession):
|
||||
assert await get_fundamentals_cutover_config(session) == {"enabled": False}
|
||||
|
||||
async def test_round_trips_explicit_switch(self, session: AsyncSession):
|
||||
assert await update_fundamentals_cutover_config(session, True) == {
|
||||
"enabled": True
|
||||
}
|
||||
assert await get_fundamentals_cutover_config(session) == {"enabled": True}
|
||||
|
||||
assert await update_fundamentals_cutover_config(session, False) == {
|
||||
"enabled": False
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Admin → Jobs listing: categories, ordering, and next-run coherence.
|
||||
|
||||
The panel used to render 19 jobs as one alphabetical list in which a pipeline
|
||||
step, a cron job and a manual job were indistinguishable, and a triggered job
|
||||
could advertise a next run ten years out.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app import job_catalog
|
||||
from app.scheduler import configure_scheduler, scheduler
|
||||
from app.services.admin_service import _visible_next_run, list_jobs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured_scheduler():
|
||||
scheduler.remove_all_jobs()
|
||||
configure_scheduler()
|
||||
yield
|
||||
scheduler.remove_all_jobs()
|
||||
|
||||
|
||||
def _by_name(jobs: list[dict]) -> dict[str, dict]:
|
||||
return {job["name"]: job for job in jobs}
|
||||
|
||||
|
||||
class TestVisibleNextRun:
|
||||
def test_parked_backstop_is_not_a_schedule(self):
|
||||
"""Paused jobs carry a 520-week interval; triggering one re-arms it."""
|
||||
backstop = datetime.now(timezone.utc) + timedelta(weeks=520)
|
||||
assert _visible_next_run(backstop) is None
|
||||
|
||||
def test_a_real_upcoming_run_passes_through(self):
|
||||
soon = datetime.now(timezone.utc) + timedelta(hours=6)
|
||||
assert _visible_next_run(soon) == soon
|
||||
|
||||
def test_none_stays_none(self):
|
||||
assert _visible_next_run(None) is None
|
||||
|
||||
|
||||
class TestListJobs:
|
||||
async def test_hidden_jobs_are_not_listed_but_stay_valid(self, db_session):
|
||||
jobs = _by_name(await list_jobs(db_session))
|
||||
assert "data_backfill" not in jobs
|
||||
# Still triggerable through the API, and still registered.
|
||||
assert "data_backfill" in job_catalog.VALID_JOB_NAMES
|
||||
assert scheduler.get_job("data_backfill") is not None
|
||||
|
||||
async def test_every_visible_job_has_a_category(self, db_session):
|
||||
jobs = await list_jobs(db_session)
|
||||
assert {j["name"] for j in jobs} == set(
|
||||
job_catalog.VALID_JOB_NAMES - job_catalog.HIDDEN_JOBS
|
||||
)
|
||||
assert all(j["category"] in job_catalog.CATEGORY_ORDER for j in jobs)
|
||||
|
||||
async def test_jobs_arrive_grouped_by_category(self, db_session):
|
||||
"""The frontend renders sections in payload order, so ordering is the
|
||||
API's job — not something each client re-derives."""
|
||||
categories = [j["category"] for j in await list_jobs(db_session)]
|
||||
ranks = [job_catalog.CATEGORY_ORDER.index(c) for c in categories]
|
||||
assert ranks == sorted(ranks)
|
||||
|
||||
async def test_pipeline_steps_defer_their_schedule_to_the_parent(self, db_session):
|
||||
jobs = _by_name(await list_jobs(db_session))
|
||||
step = jobs["rr_scanner"]
|
||||
assert step["category"] == job_catalog.CATEGORY_STEP
|
||||
assert step["next_run_at"] is None
|
||||
assert step["next_run_source"] == "via_pipeline"
|
||||
assert step["pipelines"] == ["near_close_pipeline"]
|
||||
|
||||
async def test_step_reports_the_soonest_enabled_parent(self, db_session):
|
||||
due = datetime.now(timezone.utc) + timedelta(hours=3)
|
||||
scheduler.modify_job("daily_pipeline", next_run_time=due)
|
||||
|
||||
collector = _by_name(await list_jobs(db_session))["data_collector"]
|
||||
assert collector["via_next_run_job"] == "daily_pipeline"
|
||||
assert collector["via_next_run_at"] == due.isoformat()
|
||||
# Runs in all four pipelines — the reason steps are not nested under one.
|
||||
assert set(collector["pipelines"]) == set(job_catalog.PIPELINE_JOBS)
|
||||
|
||||
async def test_manual_jobs_say_so_instead_of_showing_a_date(self, db_session):
|
||||
study = _by_name(await list_jobs(db_session))["event_study"]
|
||||
assert study["category"] == job_catalog.CATEGORY_MANUAL
|
||||
assert study["next_run_source"] == "manual_only"
|
||||
assert study["next_run_at"] is None
|
||||
|
||||
async def test_a_triggered_manual_job_still_shows_no_next_run(self, db_session):
|
||||
"""Regression: triggering re-armed the 520-week backstop, which the panel
|
||||
rendered as a real 'next run in ~87600h'."""
|
||||
scheduler.modify_job("event_study", next_run_time=datetime.now(timezone.utc))
|
||||
scheduler.modify_job("event_study", next_run_time=None)
|
||||
|
||||
study = _by_name(await list_jobs(db_session))["event_study"]
|
||||
assert study["next_run_at"] is None
|
||||
|
||||
async def test_pipelines_report_their_own_schedule_and_steps(self, db_session):
|
||||
pipeline = _by_name(await list_jobs(db_session))["daily_pipeline"]
|
||||
assert pipeline["category"] == job_catalog.CATEGORY_PIPELINE
|
||||
assert pipeline["next_run_source"] == "own_schedule"
|
||||
assert pipeline["steps"] == [
|
||||
step for step, _ in job_catalog.PIPELINE_STEPS["daily_pipeline"]
|
||||
]
|
||||
|
||||
async def test_standalone_jobs_keep_their_own_schedule(self, db_session):
|
||||
backtest = _by_name(await list_jobs(db_session))["backtest"]
|
||||
assert backtest["category"] == job_catalog.CATEGORY_SCHEDULED
|
||||
assert backtest["next_run_source"] == "own_schedule"
|
||||
assert backtest["pipelines"] == []
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
# forward-horizon gate (>= 21d) is satisfied on the fixed clone.
|
||||
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(
|
||||
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)
|
||||
|
||||
assert run.status == STATUS_PROMOTED
|
||||
assert run.status == STATUS_PROMOTED, run.error_details
|
||||
events = await _events(factory)
|
||||
assert events, "no earnings parsed from the real clone"
|
||||
assert any(e.announce_date > date.today() for e in events), "no forward calendar"
|
||||
assert any(e.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"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.run_earnings_research import (
|
||||
_analyse_2a_trades,
|
||||
@@ -41,42 +40,6 @@ def test_dolthub_alignment_allows_fiscal_period_label_after_announcement() -> No
|
||||
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:
|
||||
calendar = [
|
||||
date(2024, 1, 2),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -15,7 +15,6 @@ from app.models.fundamental import FundamentalData
|
||||
from app.models.fundamental_snapshot import FundamentalSnapshot
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import fundamentals_candidate_service as candidates
|
||||
from app.services import fundamentals_derivation as deriv
|
||||
@@ -76,49 +75,9 @@ def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
|
||||
return rows
|
||||
|
||||
|
||||
async def test_default_off_performs_no_candidate_read_or_write(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
ticker = Ticker(symbol="AAA")
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
session.add(
|
||||
FundamentalData(
|
||||
ticker_id=ticker.id,
|
||||
pe_ratio=12,
|
||||
revenue_growth=3,
|
||||
earnings_surprise=1,
|
||||
market_cap=100,
|
||||
fetched_at=NOW,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def should_not_read(*args, **kwargs):
|
||||
raise AssertionError("default-off refresh derived candidates")
|
||||
|
||||
monkeypatch.setattr(candidates, "build_candidates", should_not_read)
|
||||
summary = await refresh_service.refresh_if_enabled(session, today=TODAY)
|
||||
|
||||
stored = await session.scalar(
|
||||
select(FundamentalData).where(FundamentalData.ticker_id == ticker.id)
|
||||
)
|
||||
assert summary == {
|
||||
"enabled": False,
|
||||
"refreshed": 0,
|
||||
"score_inputs_changed": 0,
|
||||
"dimension_scores_staled": 0,
|
||||
"composite_scores_staled": 0,
|
||||
}
|
||||
assert stored.pe_ratio == 12
|
||||
|
||||
|
||||
async def test_activated_refresh_updates_all_fields_and_invalidates_scores(
|
||||
async def test_refresh_updates_all_fields_and_invalidates_scores(
|
||||
session: AsyncSession,
|
||||
):
|
||||
session.add(
|
||||
SystemSetting(key=refresh_service.ACTIVATION_KEY, value="true")
|
||||
)
|
||||
first = Ticker(symbol="AAA", cik="0000000001")
|
||||
second = Ticker(symbol="AAB", cik="0000000001")
|
||||
session.add_all([first, second])
|
||||
@@ -191,7 +150,7 @@ async def test_activated_refresh_updates_all_fields_and_invalidates_scores(
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
summary = await refresh_service.refresh_if_enabled(
|
||||
summary = await refresh_service.refresh(
|
||||
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):
|
||||
row.is_stale = False
|
||||
await session.commit()
|
||||
unchanged = await refresh_service.refresh_if_enabled(
|
||||
unchanged = await refresh_service.refresh(
|
||||
session, now=NOW + timedelta(hours=1), today=TODAY
|
||||
)
|
||||
assert unchanged["score_inputs_changed"] == 0
|
||||
|
||||
@@ -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
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import fundamental_service
|
||||
|
||||
@@ -43,57 +50,36 @@ async def ticker(session: AsyncSession) -> Ticker:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_fundamental_persists_unavailable_fields(
|
||||
async def test_get_fundamental_returns_the_cached_row(
|
||||
session: AsyncSession, ticker: Ticker
|
||||
):
|
||||
"""unavailable_fields dict is serialized to JSON and stored."""
|
||||
fields = {"pe_ratio": "requires paid plan", "revenue_growth": "requires paid plan"}
|
||||
|
||||
record = await fundamental_service.store_fundamental(
|
||||
session,
|
||||
symbol="AAPL",
|
||||
fields = {"pe_ratio": "split guard applied"}
|
||||
session.add(
|
||||
FundamentalData(
|
||||
ticker_id=ticker.id,
|
||||
pe_ratio=None,
|
||||
revenue_growth=None,
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
"""When unavailable_fields is not provided, column defaults to '{}'."""
|
||||
record = await fundamental_service.store_fundamental(
|
||||
session,
|
||||
symbol="AAPL",
|
||||
pe_ratio=25.0,
|
||||
)
|
||||
|
||||
assert json.loads(record.unavailable_fields_json) == {}
|
||||
assert await fundamental_service.get_fundamental(session, symbol="AAPL") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_fundamental_updates_unavailable_fields(
|
||||
session: AsyncSession, ticker: Ticker
|
||||
):
|
||||
"""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) == {}
|
||||
async def test_get_fundamental_rejects_an_unknown_symbol(session: AsyncSession):
|
||||
with pytest.raises(NotFoundError):
|
||||
await fundamental_service.get_fundamental(session, symbol="NOPE")
|
||||
|
||||
@@ -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)
|
||||
@@ -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.fundamental_snapshot import FundamentalSnapshot
|
||||
from app.models.sec_filing_gap import SecFilingGap
|
||||
from app.models.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import fundamentals_quality_service
|
||||
|
||||
@@ -19,12 +18,6 @@ async def test_latest_sec_validation_blocks_deferred_and_no_history_ciks(
|
||||
healthy = Ticker(symbol="HEALTHY", cik="0000000003")
|
||||
db_session.add_all([missing, no_history, healthy])
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
SystemSetting(
|
||||
key="fundamental_data_sec_dolt_cutover_enabled",
|
||||
value="true",
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
DataImportRun(
|
||||
source="sec_facts",
|
||||
@@ -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):
|
||||
ticker = Ticker(symbol="HIST", cik="0000000043")
|
||||
now = datetime.now(timezone.utc)
|
||||
db_session.add_all([
|
||||
ticker,
|
||||
SystemSetting(
|
||||
key="fundamental_data_sec_dolt_cutover_enabled",
|
||||
value="true",
|
||||
),
|
||||
SecFilingGap(
|
||||
cik=ticker.cik,
|
||||
accession="HIST-Q",
|
||||
@@ -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)
|
||||
db_session.add_all([
|
||||
ticker,
|
||||
SystemSetting(
|
||||
key="fundamental_data_sec_dolt_cutover_enabled",
|
||||
value="true",
|
||||
),
|
||||
SecFilingGap(
|
||||
cik=ticker.cik,
|
||||
accession="DATELESS-Q",
|
||||
@@ -157,10 +119,6 @@ async def test_ticker_quality_explains_no_xbrl_block(db_session):
|
||||
ticker = Ticker(symbol="NEWREG", cik="0000000044")
|
||||
db_session.add_all([
|
||||
ticker,
|
||||
SystemSetting(
|
||||
key="fundamental_data_sec_dolt_cutover_enabled",
|
||||
value="true",
|
||||
),
|
||||
DataImportRun(
|
||||
source="sec_facts",
|
||||
status="promoted",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Durable last-run state.
|
||||
|
||||
Job outcomes lived only in an in-memory dict, so every deploy wiped them and
|
||||
Admin → Jobs could only report "Active" with no indication a job had ever run.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import scheduler as sched
|
||||
from app.models.job_run_state import JobRunState
|
||||
from app.services import job_run_store
|
||||
from tests.conftest import _test_session_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory():
|
||||
"""A real, independently committing session.
|
||||
|
||||
_persist_job_run opens its own session and commits, which is what production
|
||||
does; the shared db_session fixture holds an outer transaction that a commit
|
||||
would tear down.
|
||||
"""
|
||||
return _test_session_factory
|
||||
|
||||
|
||||
async def _rows(session) -> dict[str, JobRunState]:
|
||||
result = await session.execute(select(JobRunState))
|
||||
return {row.job_name: row for row in result.scalars().all()}
|
||||
|
||||
|
||||
async def _committed_rows() -> dict[str, JobRunState]:
|
||||
async with _test_session_factory() as session:
|
||||
return await _rows(session)
|
||||
|
||||
|
||||
class TestRecordFinish:
|
||||
async def test_inserts_then_updates_one_row_per_job(self, db_session):
|
||||
await job_run_store.record_finish(
|
||||
db_session,
|
||||
"rr_scanner",
|
||||
{"status": "completed", "finished_at": "2026-08-08T10:00:00+00:00", "processed": 5, "total": 5},
|
||||
)
|
||||
await db_session.flush()
|
||||
await job_run_store.record_finish(
|
||||
db_session,
|
||||
"rr_scanner",
|
||||
{"status": "error", "finished_at": "2026-08-08T12:00:00+00:00", "message": "boom"},
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
rows = await _rows(db_session)
|
||||
assert list(rows) == ["rr_scanner"], "upsert, not append-only history"
|
||||
assert rows["rr_scanner"].status == "error"
|
||||
assert rows["rr_scanner"].message == "boom"
|
||||
|
||||
async def test_missing_finish_time_falls_back_to_now(self, db_session):
|
||||
await job_run_store.record_finish(db_session, "alerts", {"status": "completed"})
|
||||
await db_session.flush()
|
||||
assert (await _rows(db_session))["alerts"].finished_at is not None
|
||||
|
||||
|
||||
class TestPipelinePersistence:
|
||||
"""_run_pipeline is the only write path for steps: they are plain coroutine
|
||||
calls, so they emit no scheduler events for the listener to catch."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enabled(self, monkeypatch, session_factory):
|
||||
async def enabled(db, job_name):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
|
||||
async def test_persists_both_the_step_and_the_orchestrator(self, monkeypatch):
|
||||
async def ok_step():
|
||||
sched._runtime_finish("rr_scanner", "completed", processed=3, total=3)
|
||||
|
||||
monkeypatch.setattr(sched, "ok_step", ok_step, raising=False)
|
||||
await sched._run_pipeline("near_close_pipeline", [("rr_scanner", "ok_step")])
|
||||
|
||||
rows = await _committed_rows()
|
||||
assert rows["rr_scanner"].status == "completed"
|
||||
assert rows["near_close_pipeline"].status == "completed"
|
||||
|
||||
async def test_a_failing_step_still_records_its_error(self, monkeypatch):
|
||||
"""The persist sits after the except that swallows step errors — inside
|
||||
it, exactly the runs worth seeing would be skipped."""
|
||||
|
||||
async def boom():
|
||||
sched._runtime_finish("rr_scanner", "error", processed=0, total=1, message="kaboom")
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
monkeypatch.setattr(sched, "boom", boom, raising=False)
|
||||
await sched._run_pipeline("near_close_pipeline", [("rr_scanner", "boom")])
|
||||
|
||||
rows = await _committed_rows()
|
||||
assert rows["rr_scanner"].status == "error"
|
||||
assert rows["rr_scanner"].message == "kaboom"
|
||||
# The pipeline itself survives a failing step.
|
||||
assert rows["near_close_pipeline"].status == "completed"
|
||||
|
||||
async def test_disabled_pipeline_records_skipped(self, monkeypatch):
|
||||
async def disabled(db, job_name):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
|
||||
await sched._run_pipeline("daily_pipeline", [])
|
||||
|
||||
assert (await _committed_rows())["daily_pipeline"].status == "skipped"
|
||||
|
||||
async def test_persistence_failure_never_breaks_the_pipeline(self, monkeypatch):
|
||||
calls: list[str] = []
|
||||
|
||||
async def exploding_record(db, job_name, runtime):
|
||||
calls.append(job_name)
|
||||
raise RuntimeError("db down")
|
||||
|
||||
async def ok_step():
|
||||
sched._runtime_finish("rr_scanner", "completed", processed=1, total=1)
|
||||
|
||||
monkeypatch.setattr(sched.job_run_store, "record_finish", exploding_record)
|
||||
monkeypatch.setattr(sched, "ok_step", ok_step, raising=False)
|
||||
|
||||
await sched._run_pipeline("near_close_pipeline", [("rr_scanner", "ok_step")])
|
||||
|
||||
assert calls, "persistence was attempted"
|
||||
assert sched.get_job_runtime_snapshot("near_close_pipeline")["status"] == "completed"
|
||||
|
||||
async def test_a_job_that_never_finished_writes_nothing(self):
|
||||
sched._runtime_start("event_study", total=1)
|
||||
await sched._persist_job_run("event_study")
|
||||
assert "event_study" not in await _committed_rows()
|
||||
|
||||
|
||||
class TestListJobsSplitsLiveFromPersisted:
|
||||
async def test_a_stale_error_does_not_pin_the_status_chip(self, db_session):
|
||||
"""runtime_* must stay live-only: the chip and the rate-limit banner read
|
||||
it, so a week-old error there would read as the current state forever."""
|
||||
from app.scheduler import configure_scheduler, scheduler
|
||||
from app.services.admin_service import list_jobs
|
||||
|
||||
scheduler.remove_all_jobs()
|
||||
configure_scheduler()
|
||||
# _job_runtime is module-global and survives across tests; pin the live
|
||||
# row to idle so the assertion is about the split, not about ordering.
|
||||
sched._job_runtime["rr_scanner"] = sched._idle_runtime()
|
||||
await job_run_store.record_finish(
|
||||
db_session,
|
||||
"rr_scanner",
|
||||
{
|
||||
"status": "error",
|
||||
"finished_at": datetime(2026, 8, 1, tzinfo=timezone.utc).isoformat(),
|
||||
"message": "old failure",
|
||||
},
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
job = {j["name"]: j for j in await list_jobs(db_session)}["rr_scanner"]
|
||||
assert job["last_run_status"] == "error"
|
||||
assert job["last_run_message"] == "old failure"
|
||||
assert job["runtime_status"] == "idle"
|
||||
assert job["running"] is False
|
||||
scheduler.remove_all_jobs()
|
||||
|
||||
|
||||
class TestConcurrentWrites:
|
||||
"""Pipelines are separate scheduler jobs that can overlap, and they share
|
||||
step ids — data_collector belongs to all four."""
|
||||
|
||||
async def test_interleaved_first_writes_do_not_collide(self):
|
||||
"""Both sessions SELECT before either INSERTs: select-then-insert lost
|
||||
this race with an IntegrityError, and the caller swallows it."""
|
||||
async with _test_session_factory() as a, _test_session_factory() as b:
|
||||
await job_run_store.record_finish(
|
||||
a, "data_collector",
|
||||
{"status": "completed", "finished_at": "2026-08-08T10:00:00+00:00"},
|
||||
)
|
||||
await job_run_store.record_finish(
|
||||
b, "data_collector",
|
||||
{"status": "completed", "finished_at": "2026-08-08T10:00:01+00:00"},
|
||||
)
|
||||
await a.commit()
|
||||
await b.commit() # must not raise
|
||||
|
||||
rows = await _committed_rows()
|
||||
assert rows["data_collector"].status == "completed"
|
||||
|
||||
async def test_an_older_finish_never_rewinds_the_row(self):
|
||||
"""A slower pipeline finishing an older run last must not overwrite a
|
||||
newer outcome with a stale one."""
|
||||
async with _test_session_factory() as s:
|
||||
await job_run_store.record_finish(
|
||||
s, "alerts",
|
||||
{"status": "completed", "finished_at": "2026-08-08T12:00:00+00:00"},
|
||||
)
|
||||
await s.commit()
|
||||
await job_run_store.record_finish(
|
||||
s, "alerts",
|
||||
{"status": "error", "finished_at": "2026-08-08T09:00:00+00:00", "message": "stale"},
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
row = (await _committed_rows())["alerts"]
|
||||
assert row.finished_at.isoformat().startswith("2026-08-08T12:00")
|
||||
assert row.status == "completed"
|
||||
assert row.message is None
|
||||
|
||||
async def test_a_newer_finish_still_wins(self):
|
||||
async with _test_session_factory() as s:
|
||||
await job_run_store.record_finish(
|
||||
s, "rr_scanner",
|
||||
{"status": "completed", "finished_at": "2026-08-08T09:00:00+00:00"},
|
||||
)
|
||||
await s.commit()
|
||||
await job_run_store.record_finish(
|
||||
s, "rr_scanner",
|
||||
{"status": "error", "finished_at": "2026-08-08T12:00:00+00:00", "message": "boom"},
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
row = (await _committed_rows())["rr_scanner"]
|
||||
assert row.status == "error"
|
||||
assert row.message == "boom"
|
||||
|
||||
|
||||
class TestShutdownDrain:
|
||||
async def test_drains_a_task_queued_after_the_flush_starts(self):
|
||||
"""scheduler.shutdown(wait=False) returns before APScheduler dispatches
|
||||
its completion events, so writes can appear mid-drain. Snapshotting the
|
||||
task set once would miss them and dispose the engine underneath."""
|
||||
import asyncio
|
||||
|
||||
done: list[str] = []
|
||||
|
||||
async def slow_first():
|
||||
await asyncio.sleep(0.02)
|
||||
done.append("first")
|
||||
# Queued only once the first write is already finishing.
|
||||
sched._persist_tasks.add(asyncio.get_running_loop().create_task(late()))
|
||||
|
||||
async def late():
|
||||
await asyncio.sleep(0.02)
|
||||
done.append("late")
|
||||
|
||||
sched._persist_tasks.clear()
|
||||
sched._persist_tasks.add(asyncio.get_running_loop().create_task(slow_first()))
|
||||
|
||||
await sched.flush_job_run_persists(timeout=2.0)
|
||||
|
||||
assert done == ["first", "late"]
|
||||
sched._persist_tasks.clear()
|
||||
|
||||
async def test_returns_promptly_when_there_is_nothing_to_drain(self):
|
||||
sched._persist_tasks.clear()
|
||||
await sched.flush_job_run_persists(timeout=2.0)
|
||||
|
||||
async def test_gives_up_rather_than_hanging_shutdown(self):
|
||||
import asyncio
|
||||
|
||||
async def never():
|
||||
await asyncio.sleep(30)
|
||||
|
||||
sched._persist_tasks.clear()
|
||||
task = asyncio.get_running_loop().create_task(never())
|
||||
sched._persist_tasks.add(task)
|
||||
await sched.flush_job_run_persists(timeout=0.15) # returns, does not hang
|
||||
task.cancel()
|
||||
sched._persist_tasks.clear()
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.services.regime_monitor_service import (
|
||||
breadth_level_score,
|
||||
drawdown_pct,
|
||||
f2_credit_spreads,
|
||||
current_observation,
|
||||
fundamental_overlay,
|
||||
p1_trend_break,
|
||||
p2_death_cross,
|
||||
@@ -238,6 +239,77 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
|
||||
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():
|
||||
"""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"}
|
||||
|
||||
written, _ = await rms._upsert_snapshot(
|
||||
db_session, first, rewrite_existing_v2=True
|
||||
db_session, first, rewrite_existing=True
|
||||
)
|
||||
await db_session.flush()
|
||||
rewritten, persisted = await rms._upsert_snapshot(
|
||||
db_session, changed, rewrite_existing_v2=False
|
||||
db_session, changed, rewrite_existing=False
|
||||
)
|
||||
row = (
|
||||
await db_session.execute(
|
||||
@@ -468,10 +540,10 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
||||
return {}, {}
|
||||
|
||||
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):
|
||||
rewrites.append(rewrite_existing_v2)
|
||||
async def fake_upsert(_db, result, *, rewrite_existing):
|
||||
rewrites.append(rewrite_existing)
|
||||
return True, result
|
||||
|
||||
class FakeDB:
|
||||
@@ -492,6 +564,91 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
||||
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
|
||||
async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch):
|
||||
calls: list[str] = []
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.sec_filing_gap import SecFilingGap
|
||||
from app.models.settings import SystemSetting
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
@@ -524,10 +523,6 @@ async def test_get_trade_setups_hides_active_sec_filing_gap(
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
db_session.add_all([
|
||||
SystemSetting(
|
||||
key="fundamental_data_sec_dolt_cutover_enabled",
|
||||
value="true",
|
||||
),
|
||||
SecFilingGap(
|
||||
cik=ticker.cik,
|
||||
accession="0000000042-26-000001",
|
||||
|
||||
@@ -66,26 +66,11 @@ class TestTradingDayCrons:
|
||||
assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?"
|
||||
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(
|
||||
("key", "hour", "minute"),
|
||||
(
|
||||
("schedule_dolt_earnings_cron", 2, 30),
|
||||
("schedule_sec_fundamentals_cron", 4, 0),
|
||||
("schedule_fundamentals_parity_cron", 5, 30),
|
||||
),
|
||||
)
|
||||
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):
|
||||
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):
|
||||
with pytest.raises(ValidationError):
|
||||
|
||||
+190
-130
@@ -1,20 +1,22 @@
|
||||
"""Unit tests for app.scheduler module."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app import job_catalog
|
||||
from app.scheduler import (
|
||||
_DAILY_PIPELINE_STEPS,
|
||||
_NEAR_CLOSE_PIPELINE_STEPS,
|
||||
_consume_backtest_options,
|
||||
_consume_backtest_target_model,
|
||||
_parse_frequency,
|
||||
_repause_after_manual_run,
|
||||
_resume_tickers,
|
||||
_last_successful,
|
||||
_run_shadow_import,
|
||||
collect_fundamentals,
|
||||
run_fundamentals_parity_report,
|
||||
_run_source_import,
|
||||
run_sec_fundamentals_import,
|
||||
configure_scheduler,
|
||||
get_job_runtime_snapshot,
|
||||
@@ -113,64 +115,119 @@ class TestResumeTickers:
|
||||
|
||||
class TestConfigureScheduler:
|
||||
def test_configure_adds_all_jobs(self):
|
||||
# Remove any existing jobs first
|
||||
# Derived from the catalog, not a fourth hand-maintained copy of the
|
||||
# job list: a job added to the catalog but never registered now fails
|
||||
# here instead of silently rendering "Not registered" in the admin UI.
|
||||
scheduler.remove_all_jobs()
|
||||
configure_scheduler()
|
||||
jobs = scheduler.get_jobs()
|
||||
job_ids = {j.id for j in jobs}
|
||||
assert job_ids == {
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"shadow_book",
|
||||
"ticker_universe_sync",
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"event_study",
|
||||
"backtest",
|
||||
"daily_pipeline",
|
||||
"near_close_pipeline",
|
||||
"after_close_pipeline",
|
||||
"intraday_pipeline",
|
||||
}
|
||||
assert {j.id for j in scheduler.get_jobs()} == set(job_catalog.VALID_JOB_NAMES)
|
||||
|
||||
def test_configure_is_idempotent(self):
|
||||
scheduler.remove_all_jobs()
|
||||
configure_scheduler()
|
||||
configure_scheduler() # Should replace, not duplicate
|
||||
job_ids = [j.id for j in scheduler.get_jobs()]
|
||||
# Each ID should appear exactly once
|
||||
assert sorted(job_ids) == sorted([
|
||||
"after_close_pipeline",
|
||||
"alerts",
|
||||
"backtest",
|
||||
"benchmark_collector",
|
||||
"daily_pipeline",
|
||||
"intraday_pipeline",
|
||||
assert sorted(job_ids) == sorted(job_catalog.VALID_JOB_NAMES)
|
||||
|
||||
def test_independent_jobs_use_cron_not_interval(self):
|
||||
"""Interval countdowns restart on every deploy, so a weekly interval on a
|
||||
frequently-redeployed box can defer forever. Both standalone jobs were
|
||||
migrated to cron; this pins them there."""
|
||||
scheduler.remove_all_jobs()
|
||||
configure_scheduler()
|
||||
for job_id in ("backtest", "ticker_universe_sync"):
|
||||
trigger = type(scheduler.get_job(job_id).trigger).__name__
|
||||
assert trigger == "CronTrigger", f"{job_id} regressed to {trigger}"
|
||||
|
||||
|
||||
class TestJobCatalog:
|
||||
def test_pipeline_members_are_derived_from_step_lists(self):
|
||||
derived = {
|
||||
step
|
||||
for steps in job_catalog.PIPELINE_STEPS.values()
|
||||
for step, _ in steps
|
||||
}
|
||||
assert job_catalog.PIPELINE_MEMBERS == derived
|
||||
# ...and reproduces the set that used to be maintained by hand, so the
|
||||
# derivation is behaviour-preserving rather than merely self-consistent.
|
||||
assert job_catalog.PIPELINE_MEMBERS == {
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"market_regime",
|
||||
"near_close_pipeline",
|
||||
"regime_monitor",
|
||||
"event_study",
|
||||
"outcome_evaluator",
|
||||
"rr_scanner",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"rr_scanner",
|
||||
"shadow_book",
|
||||
"ticker_universe_sync",
|
||||
])
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
}
|
||||
|
||||
def test_categories_partition_every_job_exactly_once(self):
|
||||
buckets = [
|
||||
job_catalog.PIPELINE_JOBS,
|
||||
job_catalog.PIPELINE_STEP_JOBS,
|
||||
job_catalog.SCHEDULED_JOBS,
|
||||
job_catalog.MANUAL_JOBS,
|
||||
]
|
||||
flat = [name for bucket in buckets for name in bucket]
|
||||
assert len(flat) == len(set(flat)), "a job is in two categories"
|
||||
assert set(flat) == set(job_catalog.VALID_JOB_NAMES)
|
||||
assert all(name in job_catalog.JOB_CATEGORY for name in flat)
|
||||
|
||||
def test_every_job_has_a_label_and_a_unique_sort_order(self):
|
||||
names = job_catalog.VALID_JOB_NAMES
|
||||
assert set(job_catalog.JOB_LABELS) == set(names)
|
||||
assert len({job_catalog.sort_order(n) for n in names}) == len(names)
|
||||
|
||||
def test_multi_pipeline_members_report_every_parent(self):
|
||||
"""Membership is many-to-many — the reason the UI groups into sections
|
||||
rather than nesting steps under one parent."""
|
||||
by_member = job_catalog.PIPELINES_BY_MEMBER
|
||||
assert set(by_member["data_collector"]) == set(job_catalog.PIPELINE_JOBS)
|
||||
assert set(by_member["alerts"]) == {"daily_pipeline", "near_close_pipeline"}
|
||||
assert set(by_member["outcome_evaluator"]) == {
|
||||
"intraday_pipeline",
|
||||
"after_close_pipeline",
|
||||
}
|
||||
assert "backtest" not in by_member
|
||||
|
||||
def test_every_job_has_a_runtime_row_before_it_first_runs(self):
|
||||
"""The old private _JOB_NAMES list held 16 of 19, so three jobs showed no
|
||||
last-run line until their first run in a given process."""
|
||||
assert set(get_job_runtime_snapshot()) == set(job_catalog.VALID_JOB_NAMES)
|
||||
|
||||
|
||||
class TestRepauseListener:
|
||||
def _configured(self):
|
||||
scheduler.remove_all_jobs()
|
||||
configure_scheduler()
|
||||
|
||||
def test_manual_job_is_repaused_after_running(self):
|
||||
"""Triggering a paused job re-arms its 520-week backstop, which used to
|
||||
surface as a "next run in ~87600h"."""
|
||||
self._configured()
|
||||
scheduler.modify_job("event_study", next_run_time=datetime.now(timezone.utc))
|
||||
_repause_after_manual_run(SimpleNamespace(job_id="event_study"))
|
||||
assert scheduler.get_job("event_study").next_run_time is None
|
||||
|
||||
def test_pipeline_step_is_repaused_after_running(self):
|
||||
self._configured()
|
||||
scheduler.modify_job("rr_scanner", next_run_time=datetime.now(timezone.utc))
|
||||
_repause_after_manual_run(SimpleNamespace(job_id="rr_scanner"))
|
||||
assert scheduler.get_job("rr_scanner").next_run_time is None
|
||||
|
||||
def test_cron_jobs_are_left_alone(self):
|
||||
# Set an explicit next run first: an unstarted scheduler leaves the
|
||||
# attribute unset, so comparing None to None would prove nothing.
|
||||
self._configured()
|
||||
due = datetime.now(timezone.utc)
|
||||
scheduler.modify_job("daily_pipeline", next_run_time=due)
|
||||
_repause_after_manual_run(SimpleNamespace(job_id="daily_pipeline"))
|
||||
assert scheduler.get_job("daily_pipeline").next_run_time == due
|
||||
|
||||
def test_unknown_job_is_ignored(self):
|
||||
self._configured()
|
||||
_repause_after_manual_run(SimpleNamespace(job_id="not_a_job"))
|
||||
|
||||
|
||||
class _SessionContext:
|
||||
@@ -181,42 +238,7 @@ class _SessionContext:
|
||||
return None
|
||||
|
||||
|
||||
class TestFundamentalCollector:
|
||||
@staticmethod
|
||||
def _session_factory():
|
||||
return _SessionContext()
|
||||
|
||||
async def test_skips_legacy_provider_when_cutover_is_active(self, monkeypatch):
|
||||
async def enabled(db, job_name):
|
||||
return True
|
||||
|
||||
async def cutover_enabled(db):
|
||||
return True
|
||||
|
||||
async def unexpected_ticker_lookup(db):
|
||||
raise AssertionError("legacy ticker lookup must not run after cutover")
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.is_enabled",
|
||||
cutover_enabled,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler._get_fundamental_priority_tickers",
|
||||
unexpected_ticker_lookup,
|
||||
)
|
||||
|
||||
await collect_fundamentals()
|
||||
|
||||
runtime = get_job_runtime_snapshot("fundamental_collector")
|
||||
assert runtime["status"] == "skipped"
|
||||
assert runtime["processed"] == 0
|
||||
assert runtime["total"] == 0
|
||||
assert runtime["message"] == "SEC + Dolt fundamentals cutover is active"
|
||||
|
||||
|
||||
class TestShadowImportJobs:
|
||||
class TestSourceImportJobs:
|
||||
@staticmethod
|
||||
def _session_factory():
|
||||
return _SessionContext()
|
||||
@@ -234,7 +256,7 @@ class TestShadowImportJobs:
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
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")
|
||||
assert runtime["status"] == "completed"
|
||||
@@ -254,7 +276,7 @@ class TestShadowImportJobs:
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
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")
|
||||
assert runtime["status"] == "error"
|
||||
@@ -276,7 +298,7 @@ class TestShadowImportJobs:
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
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")
|
||||
assert runtime["status"] == STATUS_DEFERRED
|
||||
@@ -294,7 +316,7 @@ class TestShadowImportJobs:
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
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")
|
||||
assert runtime["status"] == "skipped"
|
||||
@@ -311,14 +333,15 @@ class TestShadowImportJobs:
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
|
||||
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")
|
||||
assert runtime["status"] == "skipped"
|
||||
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 = []
|
||||
events = []
|
||||
|
||||
async def enabled(db, job_name):
|
||||
return True
|
||||
@@ -329,29 +352,40 @@ class TestShadowImportJobs:
|
||||
async def refreshed(db):
|
||||
calls.append(db)
|
||||
return {
|
||||
"enabled": True,
|
||||
"refreshed": 511,
|
||||
"score_inputs_changed": 2,
|
||||
"dimension_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._is_job_enabled", enabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", unavailable)
|
||||
monkeypatch.setattr("app.scheduler._record_system_event", record)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh",
|
||||
refreshed,
|
||||
)
|
||||
|
||||
await run_sec_fundamentals_import()
|
||||
await asyncio.sleep(0) # let the fire-and-forget event task run
|
||||
|
||||
assert len(calls) == 1
|
||||
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
||||
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):
|
||||
return True
|
||||
|
||||
@@ -362,7 +396,6 @@ class TestShadowImportJobs:
|
||||
|
||||
async def refreshed(db):
|
||||
return {
|
||||
"enabled": True,
|
||||
"refreshed": 511,
|
||||
"score_inputs_changed": 2,
|
||||
"dimension_scores_staled": 2,
|
||||
@@ -373,7 +406,7 @@ class TestShadowImportJobs:
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", imported)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh",
|
||||
refreshed,
|
||||
)
|
||||
|
||||
@@ -385,52 +418,79 @@ class TestShadowImportJobs:
|
||||
"no_op · abcdef123456 · cache 511 · 2 score inputs changed"
|
||||
)
|
||||
|
||||
async def test_disabled_sec_job_does_not_run_local_refresh(self, monkeypatch):
|
||||
async def disabled(db, job_name):
|
||||
return False
|
||||
async def test_source_locked_sec_run_still_reports_the_cache_refresh(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""A skipped import keeps its skip status but shows the cache advanced."""
|
||||
|
||||
async def should_not_run(*args, **kwargs):
|
||||
raise AssertionError("disabled SEC job ran work")
|
||||
async def enabled(db, job_name):
|
||||
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._is_job_enabled", disabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", should_not_run)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", locked)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
|
||||
should_not_run,
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh",
|
||||
refreshed,
|
||||
)
|
||||
|
||||
await run_sec_fundamentals_import()
|
||||
|
||||
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
||||
assert runtime["status"] == "skipped"
|
||||
assert runtime["message"] == "Disabled"
|
||||
|
||||
|
||||
async def test_fundamentals_parity_job_surfaces_report_summary(monkeypatch):
|
||||
async def enabled(db, job_name):
|
||||
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"},
|
||||
assert runtime["message"] == (
|
||||
"Another import for this source is already running · "
|
||||
"cache 511 · 0 score inputs changed"
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", TestShadowImportJobs._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
async def test_disabled_sec_job_still_refreshes_local_cache(self, monkeypatch):
|
||||
"""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(
|
||||
"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["message"] == "511 tickers · 12 material score changes"
|
||||
assert runtime["message"] == (
|
||||
"Import disabled · cache 511 · 2 score inputs changed"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ httpx transport (no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -10,7 +10,6 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.exceptions import ProviderError
|
||||
from app.models.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
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):
|
||||
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_fmp", _fake_fmp)
|
||||
|
||||
symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500")
|
||||
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):
|
||||
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_fmp", _fake_fmp)
|
||||
|
||||
symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500")
|
||||
assert "AAPL" in symbols
|
||||
|
||||
Reference in New Issue
Block a user