Compare commits
37
Commits
+4
-18
@@ -18,16 +18,9 @@ OPENAI_API_KEY=
|
|||||||
OPENAI_MODEL=gpt-4o-mini
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
OPENAI_SENTIMENT_BATCH_SIZE=5
|
OPENAI_SENTIMENT_BATCH_SIZE=5
|
||||||
|
|
||||||
# Fundamentals Provider — Financial Modeling Prep
|
# Dolt bulk data — local clone of post-no-preference/earnings. Together with the
|
||||||
FMP_API_KEY=
|
# SEC EDGAR block below this is the ONLY fundamentals source; there is no
|
||||||
|
# provider-API fallback.
|
||||||
# Fundamentals Provider — Finnhub (optional fallback)
|
|
||||||
FINNHUB_API_KEY=
|
|
||||||
|
|
||||||
# Fundamentals Provider — Alpha Vantage (optional fallback)
|
|
||||||
ALPHA_VANTAGE_API_KEY=
|
|
||||||
|
|
||||||
# Dolt bulk data — local clone of post-no-preference/earnings (workstream A).
|
|
||||||
# DOLT_BINARY: path to the dolt CLI (set the full path in dev if it's not on PATH,
|
# DOLT_BINARY: path to the dolt CLI (set the full path in dev if it's not on PATH,
|
||||||
# e.g. Windows: C:\Program Files\Dolt\bin\dolt.exe). DOLT_DATA_DIR holds the
|
# e.g. Windows: C:\Program Files\Dolt\bin\dolt.exe). DOLT_DATA_DIR holds the
|
||||||
# clones; in PRODUCTION it MUST be outside the deploy tree (deploy is
|
# clones; in PRODUCTION it MUST be outside the deploy tree (deploy is
|
||||||
@@ -52,21 +45,14 @@ SEC_REQUEST_SPACING_SECONDS=0.2
|
|||||||
SEC_MAX_RETRIES=4
|
SEC_MAX_RETRIES=4
|
||||||
SEC_REQUEST_TIMEOUT_SECONDS=30.0
|
SEC_REQUEST_TIMEOUT_SECONDS=30.0
|
||||||
|
|
||||||
# A5 read-only parity report archive. In production keep this outside the
|
# AI/Tech Risk Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
||||||
# rsync deployment tree, e.g. /var/lib/signal-platform/reports/fundamentals-parity.
|
|
||||||
FUNDAMENTALS_PARITY_REPORT_DIR=reports/fundamentals-parity
|
|
||||||
|
|
||||||
# Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
|
||||||
# Optional: without it the volatility (V1) and credit (C1) pillars show as n/a.
|
# Optional: without it the volatility (V1) and credit (C1) pillars show as n/a.
|
||||||
FRED_API_KEY=
|
FRED_API_KEY=
|
||||||
|
|
||||||
# Scheduled Jobs
|
# Scheduled Jobs
|
||||||
DATA_COLLECTOR_FREQUENCY=daily
|
DATA_COLLECTOR_FREQUENCY=daily
|
||||||
SENTIMENT_POLL_INTERVAL_MINUTES=30
|
SENTIMENT_POLL_INTERVAL_MINUTES=30
|
||||||
FUNDAMENTAL_FETCH_FREQUENCY=daily
|
|
||||||
RR_SCAN_FREQUENCY=daily
|
RR_SCAN_FREQUENCY=daily
|
||||||
FUNDAMENTAL_RATE_LIMIT_RETRIES=3
|
|
||||||
FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS=15
|
|
||||||
|
|
||||||
# Scoring Defaults
|
# Scoring Defaults
|
||||||
DEFAULT_WATCHLIST_AUTO_SIZE=10
|
DEFAULT_WATCHLIST_AUTO_SIZE=10
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
- run: pip install ruff
|
- run: pip install ruff
|
||||||
- run: ruff check app/
|
# Whole repo, not just app/: tests/ and scripts/ drifted to 11 findings
|
||||||
|
# while unchecked. Rules are pinned in pyproject.toml, so the unpinned
|
||||||
|
# ruff above cannot change what this enforces.
|
||||||
|
- run: ruff check .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
needs: lint
|
needs: lint
|
||||||
|
|||||||
@@ -54,3 +54,6 @@ reports/.cache/
|
|||||||
# Runtime A5 parity bundles are generated on the production server. Research
|
# Runtime A5 parity bundles are generated on the production server. Research
|
||||||
# conclusions belong in docs/research, not as an ever-growing artifact archive.
|
# conclusions belong in docs/research, not as an ever-growing artifact archive.
|
||||||
reports/fundamentals-parity/
|
reports/fundamentals-parity/
|
||||||
|
|
||||||
|
# Calibration harness raw-pull cache (Alpaca/FRED); regenerable, not a record.
|
||||||
|
.calib-cache/
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ indicators.
|
|||||||
|
|
||||||
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
|
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
|
||||||
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
||||||
3. **Market Regime** + **Regime Monitor** — breadth/trend and the v3 risk thermometer; feed no trades.
|
3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v4 risk thermometer; feed no trades.
|
||||||
4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
||||||
|
|
||||||
**Near-close** (~15:30 ET Mon–Fri) — the only full-universe qualifying observation:
|
**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
|
### Other jobs
|
||||||
|
|
||||||
Fundamentals (weekly, early Monday ET) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs).
|
Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET, also refreshes the fundamentals cache scoring reads) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs).
|
||||||
|
|
||||||
### From score to "top pick"
|
### From score to "top pick"
|
||||||
|
|
||||||
@@ -255,18 +255,11 @@ A systematic single-variable sweep (offline prod snapshot, production gate/rank/
|
|||||||
| ATR trail multiple {1.5–4.0} | **Keep 3.0** | Return+Sharpe peak; ≤2.0 whipsaws out the momentum right tail; ≥2.5 is a plateau |
|
| ATR trail multiple {1.5–4.0} | **Keep 3.0** | Return+Sharpe peak; ≤2.0 whipsaws out the momentum right tail; ≥2.5 is a plateau |
|
||||||
| SPY 200d-MA regime overlay (block entries / go flat) | **Reject** | Halves return (315%→138%) with zero drawdown benefit — the ATR trail already manages downside, and the filter blocks the recovery-phase entries that make the money |
|
| SPY 200d-MA regime overlay (block entries / go flat) | **Reject** | Halves return (315%→138%) with zero drawdown benefit — the ATR trail already manages downside, and the filter blocks the recovery-phase entries that make the money |
|
||||||
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
|
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
|
||||||
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep cutoff 80; capacity reopened** | The older weekly replay favored 80 × 10, but its no-cap-pressure conclusion is superseded by 519 book-full rejections versus 472 trades under the current daily gate-reset control |
|
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep 80 × 10** | Monotonically worse in both directions from 80; the 10-slot cap never binds (<10 concurrent) |
|
||||||
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
|
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
|
||||||
| Post-stop re-entry: immediate, fixed 2–5 sessions, gate resets, confirmation filters | **Keep normal gate reset for the 10-position production book** | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5; rerun before changing portfolio capacity |
|
| Post-stop re-entry: immediate, fixed 2–5 sessions, gate resets, confirmation filters | **Keep normal gate reset for the 10-position production book** | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5; rerun before changing portfolio capacity |
|
||||||
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
|
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
|
||||||
|
|
||||||
> **Capacity correction (2026-08-05):** the table's older weekly conclusion
|
|
||||||
> that the ten-slot cap never binds is superseded. Under the current daily
|
|
||||||
> gate-reset Phase A control, 472 trades were admitted and 519 qualified entries
|
|
||||||
> were rejected because the book was full (52.4% of admitted+blocked
|
|
||||||
> opportunities). Cutoff 80 remains the signal setting; portfolio capacity is
|
|
||||||
> reopened in the focused capacity-bracket study.
|
|
||||||
|
|
||||||
Two findings future sessions must not re-litigate:
|
Two findings future sessions must not re-litigate:
|
||||||
|
|
||||||
- **The "inverse-vol sizing win" (July 2026) was mis-attributed — do not resurrect.** The diagnostic sized `notional = equity × 1% / vol_6m`, and the 20% notional cap bound on 95% of entries, so it actually measured "~5 positions × 20% notional each" — a concentration/risk-appetite bump economically equivalent to raising risk to 1.5%, not vol-managed sizing. Genuine inverse-vol sizing (risk budget × median-vol/vol) cuts max drawdown to −18.2% but costs ~58pp total return at flat Sharpe: a risk-preference trade, not edge.
|
- **The "inverse-vol sizing win" (July 2026) was mis-attributed — do not resurrect.** The diagnostic sized `notional = equity × 1% / vol_6m`, and the 20% notional cap bound on 95% of entries, so it actually measured "~5 positions × 20% notional each" — a concentration/risk-appetite bump economically equivalent to raising risk to 1.5%, not vol-managed sizing. Genuine inverse-vol sizing (risk budget × median-vol/vol) cuts max drawdown to −18.2% but costs ~58pp total return at flat Sharpe: a risk-preference trade, not edge.
|
||||||
@@ -308,13 +301,13 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
| Charts | Canvas 2D candlestick chart with S/R overlays |
|
| Charts | Canvas 2D candlestick chart with S/R overlays |
|
||||||
| Routing | React Router v6 (SPA) |
|
| Routing | React Router v6 (SPA) |
|
||||||
| HTTP | Axios with JWT interceptor |
|
| HTTP | Axios with JWT interceptor |
|
||||||
| Data providers | Alpaca (OHLCV); OpenAI / Gemini / DeepSeek / xAI (sentiment, pluggable); Fundamentals chain: FMP → Finnhub → Alpha Vantage; FRED (regime); Telegram (alerts) |
|
| Data providers | Alpaca (OHLCV); OpenAI / Gemini / DeepSeek / xAI (sentiment, pluggable); SEC EDGAR Company Facts + DoltHub earnings (fundamentals, bulk import); FRED (regime); Telegram (alerts) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- Ticker registry with full cascade delete
|
- Ticker registry with full cascade delete
|
||||||
- Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint
|
- Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint — free public sources (Wikipedia / NASDAQ Trader), then the cached snapshot, then a built-in seed list. The seeds are representative, not complete, so a *fresh* install bootstrapped while the public source is unreachable gets a partial universe; a warm instance falls through to its cache.
|
||||||
- OHLCV price storage with upsert and validation
|
- OHLCV price storage with upsert and validation
|
||||||
- Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross
|
- Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross
|
||||||
- Structural Support/Resistance detection with rejection/recency strength, ATR-adaptive merging and a hard cap; persisted for charts and alerts
|
- Structural Support/Resistance detection with rejection/recency strength, ATR-adaptive merging and a hard cap; persisted for charts and alerts
|
||||||
@@ -358,7 +351,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
|
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
|
||||||
| `/market` | Market — watchlist + rankings tabs | Authenticated |
|
| `/market` | Market — watchlist + rankings tabs | Authenticated |
|
||||||
| `/signals` | Signals — scanner + track record tabs | Authenticated |
|
| `/signals` | Signals — scanner + track record tabs | Authenticated |
|
||||||
| `/regime` | Market Regime | Authenticated |
|
| `/regime` | AI/Tech Risk Monitor | Authenticated |
|
||||||
| `/ticker/:symbol` | Ticker Detail | Authenticated |
|
| `/ticker/:symbol` | Ticker Detail | Authenticated |
|
||||||
| `/admin` | Admin Panel | Admin only |
|
| `/admin` | Admin Panel | Admin only |
|
||||||
|
|
||||||
@@ -590,18 +583,12 @@ Configure in `.env` (copy from `.env.example`):
|
|||||||
| `OPENAI_API_KEY` | For sentiment (OpenAI path) | — | OpenAI API key |
|
| `OPENAI_API_KEY` | For sentiment (OpenAI path) | — | OpenAI API key |
|
||||||
| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name |
|
| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name |
|
||||||
| `OPENAI_SENTIMENT_BATCH_SIZE` | No | `5` | Micro-batch size for sentiment collector |
|
| `OPENAI_SENTIMENT_BATCH_SIZE` | No | `5` | Micro-batch size for sentiment collector |
|
||||||
| `FMP_API_KEY` | Optional (fundamentals) | — | Financial Modeling Prep API key (first provider in chain) |
|
| `FRED_API_KEY` | Optional (risk monitor) | — | FRED key for the AI/Tech risk monitor (VIX, credit spreads) |
|
||||||
| `FINNHUB_API_KEY` | Optional (fundamentals) | — | Finnhub API key (fallback provider) |
|
|
||||||
| `ALPHA_VANTAGE_API_KEY` | Optional (fundamentals) | — | Alpha Vantage API key (fallback provider) |
|
|
||||||
| `FRED_API_KEY` | Optional (regime) | — | FRED key for the regime monitor (VIX, credit spreads) |
|
|
||||||
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
|
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
|
||||||
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
|
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
|
||||||
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) |
|
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) |
|
||||||
| `SENTIMENT_POLL_INTERVAL_MINUTES` | No | `30` | Sentiment polling interval |
|
| `SENTIMENT_POLL_INTERVAL_MINUTES` | No | `30` | Sentiment polling interval |
|
||||||
| `FUNDAMENTAL_FETCH_FREQUENCY` | No | `weekly` | Fundamentals fetch cadence |
|
|
||||||
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
|
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
|
||||||
| `FUNDAMENTAL_RATE_LIMIT_RETRIES` | No | `3` | Retries per ticker on fundamentals rate-limit |
|
|
||||||
| `FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS` | No | `15` | Base backoff seconds for fundamentals retry (exponential) |
|
|
||||||
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
|
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
|
||||||
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
|
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
|
||||||
| `DB_POOL_SIZE` | No | `5` | Database connection pool size |
|
| `DB_POOL_SIZE` | No | `5` | Database connection pool size |
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Retire the legacy fundamentals settings (A6)
|
||||||
|
|
||||||
|
Revision ID: 029
|
||||||
|
Revises: 028
|
||||||
|
Create Date: 2026-08-07 00:00:00.000000
|
||||||
|
|
||||||
|
A6 removed the FMP/Finnhub/Alpha Vantage providers, the weekly
|
||||||
|
``fundamental_collector`` job and the A5 parity report. Five SystemSetting rows
|
||||||
|
are left over. They are NOT all deleted, because the deploy runs migrations
|
||||||
|
before restarting the service: for a short window — and for the whole of any
|
||||||
|
rollback — pre-A6 code is still live, and it reads absent rows permissively
|
||||||
|
(cutover absent -> disabled; ``job_<name>_enabled`` absent -> enabled). Deleting
|
||||||
|
both would hand a rolled-back process a re-armed legacy collector writing over
|
||||||
|
the SEC/Dolt cache.
|
||||||
|
|
||||||
|
So the two rows that carry behavior become tombstones pinned to the safe value,
|
||||||
|
and only the inert ones are deleted. The tombstones are dropped in a later
|
||||||
|
release once the rollback window has closed; ``SettingsForm`` hides them
|
||||||
|
meanwhile.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "029"
|
||||||
|
down_revision: Union[str, None] = "028"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Behavior-bearing under pre-A6 code -> pin to the safe value, keep the row.
|
||||||
|
_TOMBSTONES: dict[str, str] = {
|
||||||
|
"fundamental_data_sec_dolt_cutover_enabled": "true",
|
||||||
|
"job_fundamental_collector_enabled": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inert either way: an absent cron falls back to a default for a job that no
|
||||||
|
# longer registers, and the parity report never wrote anything.
|
||||||
|
_OBSOLETE: tuple[str, ...] = (
|
||||||
|
"schedule_fundamentals_cron",
|
||||||
|
"schedule_fundamentals_parity_cron",
|
||||||
|
"job_fundamentals_parity_report_enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
_settings = sa.table(
|
||||||
|
"system_settings",
|
||||||
|
sa.column("id", sa.Integer),
|
||||||
|
sa.column("key", sa.String),
|
||||||
|
sa.column("value", sa.Text),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
now = sa.func.now()
|
||||||
|
|
||||||
|
for key, pinned in _TOMBSTONES.items():
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(_settings.c.value).where(_settings.c.key == key)
|
||||||
|
).fetchone()
|
||||||
|
old_value = row[0] if row is not None else None
|
||||||
|
print(f"a6_tombstone {key}: {old_value!r} -> {pinned!r}", flush=True)
|
||||||
|
if row is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(_settings).values(key=key, value=pinned, updated_at=now)
|
||||||
|
)
|
||||||
|
elif old_value != pinned:
|
||||||
|
conn.execute(
|
||||||
|
sa.update(_settings)
|
||||||
|
.where(_settings.c.key == key)
|
||||||
|
.values(value=pinned, updated_at=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print the value before deleting — a bare DELETE cannot be undone from the
|
||||||
|
# migration output.
|
||||||
|
for key in _OBSOLETE:
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(_settings.c.value).where(_settings.c.key == key)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
print(f"a6_delete {key}: absent", flush=True)
|
||||||
|
continue
|
||||||
|
print(f"a6_delete {key}: {row[0]!r}", flush=True)
|
||||||
|
conn.execute(sa.delete(_settings).where(_settings.c.key == key))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""No-op.
|
||||||
|
|
||||||
|
The deleted rows configured jobs this revision's code no longer registers,
|
||||||
|
and the tombstones already hold the values pre-A6 code needs. Recreating
|
||||||
|
them would restore nothing useful; the printed values above cover recovery.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Drop the A6 rollback tombstones
|
||||||
|
|
||||||
|
Revision ID: 030
|
||||||
|
Revises: 029
|
||||||
|
Create Date: 2026-08-07 00:00:00.000000
|
||||||
|
|
||||||
|
Migration ``029`` kept two SystemSetting rows alive as rollback tombstones,
|
||||||
|
pinned to the values a pre-A6 process needed to behave safely. A6 is deployed
|
||||||
|
and healthy, and the provider keys are gone from the production ``.env`` — which
|
||||||
|
makes the legacy collector inert regardless of any settings row — so the
|
||||||
|
tombstones have no remaining job.
|
||||||
|
|
||||||
|
Nothing in the current codebase reads either key.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "030"
|
||||||
|
down_revision: Union[str, None] = "029"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
# The safe values 029 pinned. Kept here so downgrade restores real protection
|
||||||
|
# rather than leaving a rolled-back process reading absent rows permissively.
|
||||||
|
_TOMBSTONES: dict[str, str] = {
|
||||||
|
"fundamental_data_sec_dolt_cutover_enabled": "true",
|
||||||
|
"job_fundamental_collector_enabled": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings = sa.table(
|
||||||
|
"system_settings",
|
||||||
|
sa.column("id", sa.Integer),
|
||||||
|
sa.column("key", sa.String),
|
||||||
|
sa.column("value", sa.Text),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for key in _TOMBSTONES:
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(_settings.c.value).where(_settings.c.key == key)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
print(f"a6_tombstone_drop {key}: absent", flush=True)
|
||||||
|
continue
|
||||||
|
print(f"a6_tombstone_drop {key}: {row[0]!r}", flush=True)
|
||||||
|
conn.execute(sa.delete(_settings).where(_settings.c.key == key))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Restore the tombstones at their safe values.
|
||||||
|
|
||||||
|
Unlike 029's no-op downgrade, this one is meaningful: going back past this
|
||||||
|
revision implies going back toward code that still reads these keys.
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
now = sa.func.now()
|
||||||
|
for key, pinned in _TOMBSTONES.items():
|
||||||
|
exists = conn.execute(
|
||||||
|
sa.select(_settings.c.id).where(_settings.c.key == key)
|
||||||
|
).fetchone()
|
||||||
|
if exists is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(_settings).values(key=key, value=pinned, updated_at=now)
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Record delisting on tickers instead of deleting them
|
||||||
|
|
||||||
|
Revision ID: 032
|
||||||
|
Revises: 031
|
||||||
|
Create Date: 2026-08-11 00:00:00.000000
|
||||||
|
|
||||||
|
Until now the only way to retire a symbol was ``delete_ticker`` (or
|
||||||
|
``bootstrap_universe(prune_missing=True)``), both of which cascade through
|
||||||
|
OHLCV, setups and scores. That destroys exactly the history four research
|
||||||
|
documents already apologise for: today's tracked universe projected backward
|
||||||
|
is survivorship-biased, and hard-deleting every delisted name is what causes
|
||||||
|
it. Keeping the rows preserves the option to fix that later — it does not fix
|
||||||
|
it by itself, which needs the replay to model a delisting as an exit event.
|
||||||
|
|
||||||
|
``delisted_on`` is the effective date (from SEC Form 25/25-NSE/15 where we can
|
||||||
|
confirm it, else the day it was marked); ``delisted_reason`` is a short code
|
||||||
|
for how we learned. NULL in both means actively traded — the live signal path
|
||||||
|
filters on that, while list and admin views keep showing the row so the
|
||||||
|
delisting is visible rather than silently absent.
|
||||||
|
|
||||||
|
Nullable and reversible by design: clearing ``delisted_on`` un-retires a
|
||||||
|
symbol, which is what makes automatic marking safe where a delete would not be.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "032"
|
||||||
|
down_revision: Union[str, None] = "031"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("tickers", sa.Column("delisted_on", sa.Date(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"tickers", sa.Column("delisted_reason", sa.String(length=32), nullable=True)
|
||||||
|
)
|
||||||
|
# The live path filters "actively traded" on every universe scan; the index
|
||||||
|
# keeps that predicate cheap as delisted rows accumulate.
|
||||||
|
op.create_index("ix_tickers_delisted_on", "tickers", ["delisted_on"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_tickers_delisted_on", table_name="tickers")
|
||||||
|
op.drop_column("tickers", "delisted_reason")
|
||||||
|
op.drop_column("tickers", "delisted_on")
|
||||||
+1
-21
@@ -28,15 +28,6 @@ class Settings(BaseSettings):
|
|||||||
deepseek_api_key: str = ""
|
deepseek_api_key: str = ""
|
||||||
xai_api_key: str = ""
|
xai_api_key: str = ""
|
||||||
|
|
||||||
# Fundamentals Provider — Financial Modeling Prep
|
|
||||||
fmp_api_key: str = ""
|
|
||||||
|
|
||||||
# Fundamentals Provider — Finnhub (optional fallback)
|
|
||||||
finnhub_api_key: str = ""
|
|
||||||
|
|
||||||
# Fundamentals Provider — Alpha Vantage (optional fallback)
|
|
||||||
alpha_vantage_api_key: str = ""
|
|
||||||
|
|
||||||
# Dolt bulk-data — local clone of post-no-preference/earnings (workstream A).
|
# Dolt bulk-data — local clone of post-no-preference/earnings (workstream A).
|
||||||
# dolt_binary: full path when not on PATH (dev/Windows install). dolt_data_dir
|
# dolt_binary: full path when not on PATH (dev/Windows install). dolt_data_dir
|
||||||
# holds the clones; in production it MUST be outside the deploy tree (deploy is
|
# holds the clones; in production it MUST be outside the deploy tree (deploy is
|
||||||
@@ -61,11 +52,7 @@ class Settings(BaseSettings):
|
|||||||
sec_max_retries: int = 4
|
sec_max_retries: int = 4
|
||||||
sec_request_timeout_seconds: float = 30.0
|
sec_request_timeout_seconds: float = 30.0
|
||||||
|
|
||||||
# A5 read-only comparison artifacts. Production must keep this outside the
|
# AI/Tech Risk Monitor — FRED (VIX level + HY credit spreads). Optional: without it
|
||||||
# rsync deployment tree so the 5-7 day review window survives deploys.
|
|
||||||
fundamentals_parity_report_dir: str = "reports/fundamentals-parity"
|
|
||||||
|
|
||||||
# Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it
|
|
||||||
# the volatility (P5) and credit-spread (F2) signals are reported as n/a.
|
# the volatility (P5) and credit-spread (F2) signals are reported as n/a.
|
||||||
fred_api_key: str = ""
|
fred_api_key: str = ""
|
||||||
|
|
||||||
@@ -86,15 +73,8 @@ class Settings(BaseSettings):
|
|||||||
# the score window is 7 days).
|
# the score window is 7 days).
|
||||||
sentiment_fresh_hours: int = 120
|
sentiment_fresh_hours: int = 120
|
||||||
sentiment_top_composite: int = 30
|
sentiment_top_composite: int = 30
|
||||||
fundamental_fetch_frequency: str = "weekly" # quarterly-ish data; weekly conserves API quota
|
|
||||||
rr_scan_frequency: str = "daily" # legacy label; qualifying scan is cron near-close
|
rr_scan_frequency: str = "daily" # legacy label; qualifying scan is cron near-close
|
||||||
# alerts_frequency removed: alerts fire only via morning + near-close pipelines
|
# alerts_frequency removed: alerts fire only via morning + near-close pipelines
|
||||||
fundamental_rate_limit_retries: int = 3
|
|
||||||
fundamental_rate_limit_backoff_seconds: int = 15
|
|
||||||
# Pause between tickers in the bulk fundamentals job. Free tiers throttle
|
|
||||||
# hard (Finnhub ~60 calls/min, ~3 calls/ticker → ~3s/ticker); without
|
|
||||||
# spacing the job bursts straight into 429s. 0 disables.
|
|
||||||
fundamental_request_spacing_seconds: float = 3.0
|
|
||||||
|
|
||||||
# Scoring Defaults
|
# Scoring Defaults
|
||||||
default_watchlist_auto_size: int = 10
|
default_watchlist_auto_size: int = 10
|
||||||
|
|||||||
@@ -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.database import async_session_factory, engine
|
||||||
from app.middleware import register_exception_handlers
|
from app.middleware import register_exception_handlers
|
||||||
from app.models.user import User
|
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.admin import router as admin_router
|
||||||
from app.routers.auth import router as auth_router
|
from app.routers.auth import router as auth_router
|
||||||
from app.routers.health import router as health_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)
|
scheduler.shutdown(wait=False)
|
||||||
logger.info("Scheduler stopped")
|
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()
|
await engine.dispose()
|
||||||
logger.info("Shutting down")
|
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.signal_context_snapshot import SignalContextSnapshot
|
||||||
from app.models.system_event import SystemEvent
|
from app.models.system_event import SystemEvent
|
||||||
from app.models.sec_filing_gap import SecFilingGap
|
from app.models.sec_filing_gap import SecFilingGap
|
||||||
|
from app.models.job_run_state import JobRunState
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Ticker",
|
"Ticker",
|
||||||
@@ -42,4 +43,5 @@ __all__ = [
|
|||||||
"SignalContextSnapshot",
|
"SignalContextSnapshot",
|
||||||
"SystemEvent",
|
"SystemEvent",
|
||||||
"SecFilingGap",
|
"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):
|
class RegimeSnapshot(Base):
|
||||||
"""Daily point-in-time snapshot of the AI/Tech Regime Monitor.
|
"""Daily point-in-time snapshot of the AI/Tech Risk Monitor.
|
||||||
|
|
||||||
One row per calendar date (unique). ``breakdown_json`` holds the full
|
One row per calendar date (unique). ``breakdown_json`` holds the full
|
||||||
``breakdown_json`` is authoritative for v2 State, Warning, source dates,
|
``breakdown_json`` is authoritative for v2 State, Warning, source dates,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import String, DateTime
|
from sqlalchemy import Date, String, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -21,6 +21,13 @@ class Ticker(Base):
|
|||||||
cik: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
cik: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||||
sic: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
sic: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||||
sic_description: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
sic_description: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||||
|
# Delisting is recorded, never deleted: the rows carry the price history that
|
||||||
|
# makes a backtest less survivorship-biased, and a delete cascades it away.
|
||||||
|
# NULL == actively traded. The live signal path filters on this (see
|
||||||
|
# ticker_service.active_only); list/admin views keep the row and show it.
|
||||||
|
delisted_on: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||||
|
# How we learned: "form_25" (SEC confirmed), "manual" (operator).
|
||||||
|
delisted_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
recommendation: str | None = None # "buy" | "hold" | "avoid" — actionable LLM view
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FundamentalData:
|
|
||||||
"""Fundamental metrics returned by fundamental providers."""
|
|
||||||
|
|
||||||
ticker: str
|
|
||||||
pe_ratio: float | None
|
|
||||||
revenue_growth: float | None
|
|
||||||
earnings_surprise: float | None
|
|
||||||
market_cap: float | None
|
|
||||||
fetched_at: datetime
|
|
||||||
next_earnings_date: date | None = None
|
|
||||||
unavailable_fields: dict[str, str] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Provider Protocols
|
# Provider Protocols
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -81,9 +67,5 @@ class SentimentProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class FundamentalProvider(Protocol):
|
# No fundamentals provider protocol: since A6 fundamentals come only from the
|
||||||
"""Protocol for fundamental data providers."""
|
# batch SEC/Dolt imports, never from a request-time provider call.
|
||||||
|
|
||||||
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
|
||||||
"""Fetch fundamental data for a ticker."""
|
|
||||||
...
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from app.schemas.admin import (
|
|||||||
AlertConfigUpdate,
|
AlertConfigUpdate,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
DataCleanupRequest,
|
DataCleanupRequest,
|
||||||
FundamentalsCutoverConfigUpdate,
|
|
||||||
JobTriggerRequest,
|
JobTriggerRequest,
|
||||||
JobToggle,
|
JobToggle,
|
||||||
RecommendationConfigUpdate,
|
RecommendationConfigUpdate,
|
||||||
@@ -138,27 +137,6 @@ async def list_settings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
|
|
||||||
async def get_fundamentals_cutover_settings(
|
|
||||||
_admin: User = Depends(require_admin),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
config = await admin_service.get_fundamentals_cutover_config(db)
|
|
||||||
return APIEnvelope(status="success", data=config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
|
|
||||||
async def update_fundamentals_cutover_settings(
|
|
||||||
body: FundamentalsCutoverConfigUpdate,
|
|
||||||
_admin: User = Depends(require_admin),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
config = await admin_service.update_fundamentals_cutover_config(
|
|
||||||
db, body.enabled
|
|
||||||
)
|
|
||||||
return APIEnvelope(status="success", data=config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/settings/recommendations", response_model=APIEnvelope)
|
@router.get("/admin/settings/recommendations", response_model=APIEnvelope)
|
||||||
async def get_recommendation_settings(
|
async def get_recommendation_settings(
|
||||||
_admin: User = Depends(require_admin),
|
_admin: User = Depends(require_admin),
|
||||||
@@ -475,36 +453,6 @@ async def toggle_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/fundamentals-parity", response_model=APIEnvelope)
|
|
||||||
async def get_fundamentals_parity_report(
|
|
||||||
_admin: User = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Latest read-only A5 source/score comparison, or null before first run."""
|
|
||||||
return APIEnvelope(
|
|
||||||
status="success", data=admin_service.get_fundamentals_parity_report()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/fundamentals-parity/csv", response_model=APIEnvelope)
|
|
||||||
async def get_fundamentals_parity_csv(
|
|
||||||
_admin: User = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Latest flattened A5 report for an authenticated browser download."""
|
|
||||||
artifact = admin_service.get_fundamentals_parity_csv()
|
|
||||||
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
|
|
||||||
return APIEnvelope(status="success", data=data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/fundamentals-parity/json", response_model=APIEnvelope)
|
|
||||||
async def get_fundamentals_parity_json(
|
|
||||||
_admin: User = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Canonical A5 JSON artifact for an authenticated browser download."""
|
|
||||||
artifact = admin_service.get_fundamentals_parity_json()
|
|
||||||
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
|
|
||||||
return APIEnvelope(status="success", data=data)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# System events (operational warnings / errors)
|
# System events (operational warnings / errors)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from app.models.sr_level import SRLevel
|
|||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
|
||||||
from app.services.rr_scanner_service import (
|
from app.services.rr_scanner_service import (
|
||||||
resolve_activation_ranks_for_symbol,
|
resolve_activation_ranks_for_symbol,
|
||||||
scan_ticker,
|
scan_ticker,
|
||||||
@@ -31,7 +30,6 @@ from app.services.rr_scanner_service import (
|
|||||||
from app.services.sentiment_provider_service import build_sentiment_provider
|
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||||
from app.schemas.common import APIEnvelope
|
from app.schemas.common import APIEnvelope
|
||||||
from app.services import (
|
from app.services import (
|
||||||
fundamental_service,
|
|
||||||
ingestion_service,
|
ingestion_service,
|
||||||
scoring_service,
|
scoring_service,
|
||||||
sentiment_service,
|
sentiment_service,
|
||||||
@@ -185,33 +183,13 @@ async def fetch_symbol(
|
|||||||
sources_out["sentiment"] = {"status": "error", "message": str(exc)}
|
sources_out["sentiment"] = {"status": "error", "message": str(exc)}
|
||||||
|
|
||||||
# --- Fundamentals ---
|
# --- Fundamentals ---
|
||||||
|
# No per-ticker fetch exists any more: fundamental_data is rebuilt for the
|
||||||
|
# whole universe by the nightly SEC + Dolt imports, from local PostgreSQL.
|
||||||
|
# The source key is still accepted so older clients get a truthful answer.
|
||||||
if "fundamentals" in requested:
|
if "fundamentals" in requested:
|
||||||
if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key:
|
|
||||||
try:
|
|
||||||
fundamentals_provider = build_fundamental_provider_chain()
|
|
||||||
# Manual single fetch: take whatever we can get (a lone 429 on a
|
|
||||||
# fallback shouldn't fail the whole refresh).
|
|
||||||
fdata = await fundamentals_provider.fetch_fundamentals(
|
|
||||||
symbol_upper, allow_partial=True
|
|
||||||
)
|
|
||||||
await fundamental_service.store_fundamental(
|
|
||||||
db,
|
|
||||||
symbol=symbol_upper,
|
|
||||||
pe_ratio=fdata.pe_ratio,
|
|
||||||
revenue_growth=fdata.revenue_growth,
|
|
||||||
earnings_surprise=fdata.earnings_surprise,
|
|
||||||
market_cap=fdata.market_cap,
|
|
||||||
next_earnings_date=fdata.next_earnings_date,
|
|
||||||
unavailable_fields=fdata.unavailable_fields,
|
|
||||||
)
|
|
||||||
sources_out["fundamentals"] = {"status": "ok", "message": None}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Fundamentals fetch failed for %s: %s", symbol_upper, exc)
|
|
||||||
sources_out["fundamentals"] = {"status": "error", "message": str(exc)}
|
|
||||||
else:
|
|
||||||
sources_out["fundamentals"] = {
|
sources_out["fundamentals"] = {
|
||||||
"status": "skipped",
|
"status": "skipped",
|
||||||
"message": "No fundamentals provider key configured",
|
"message": "Fundamentals refresh nightly from the SEC + Dolt imports",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derived pipeline: S/R levels (free, always) ---
|
# --- Derived pipeline: S/R levels (free, always) ---
|
||||||
|
|||||||
+33
-1
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.dependencies import get_db, require_access
|
from app.dependencies import get_db, require_access
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.common import APIEnvelope
|
from app.schemas.common import APIEnvelope
|
||||||
from app.schemas.ticker import TickerCreate, TickerResponse
|
from app.schemas.ticker import TickerCreate, TickerDelistingUpdate, TickerResponse
|
||||||
from app.services import ticker_service
|
from app.services import ticker_service
|
||||||
|
|
||||||
router = APIRouter(tags=["tickers"])
|
router = APIRouter(tags=["tickers"])
|
||||||
@@ -51,3 +51,35 @@ async def delete_ticker(
|
|||||||
"""Delete a ticker and all associated data."""
|
"""Delete a ticker and all associated data."""
|
||||||
await ticker_service.delete_ticker(db, symbol)
|
await ticker_service.delete_ticker(db, symbol)
|
||||||
return APIEnvelope(status="success", data=None)
|
return APIEnvelope(status="success", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickers/{symbol}/delisting", response_model=APIEnvelope)
|
||||||
|
async def mark_ticker_delisted(
|
||||||
|
symbol: str,
|
||||||
|
body: TickerDelistingUpdate,
|
||||||
|
_user: User = Depends(require_access),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Retire a symbol: excluded from signals, price history kept.
|
||||||
|
|
||||||
|
The non-destructive alternative to DELETE, which cascades the history away.
|
||||||
|
"""
|
||||||
|
changed = await ticker_service.mark_delisted(
|
||||||
|
db, symbol, delisted_on=body.delisted_on, reason=ticker_service.REASON_MANUAL
|
||||||
|
)
|
||||||
|
return APIEnvelope(status="success", data={"changed": changed})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tickers/{symbol}/delisting", response_model=APIEnvelope)
|
||||||
|
async def clear_ticker_delisting(
|
||||||
|
symbol: str,
|
||||||
|
_user: User = Depends(require_access),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Un-retire a symbol wrongly marked delisted.
|
||||||
|
|
||||||
|
Automatic marking is only defensible because this exists: a false positive
|
||||||
|
costs one row update rather than the price history a delete would take.
|
||||||
|
"""
|
||||||
|
changed = await ticker_service.clear_delisted(db, symbol)
|
||||||
|
return APIEnvelope(status="success", data={"changed": changed})
|
||||||
|
|||||||
+225
-343
@@ -1,9 +1,9 @@
|
|||||||
"""APScheduler job definitions and FastAPI lifespan integration.
|
"""APScheduler job definitions and FastAPI lifespan integration.
|
||||||
|
|
||||||
Defines four scheduled jobs:
|
Defines the scheduled jobs, among them:
|
||||||
- Data Collector (OHLCV fetch for all tickers)
|
- Data Collector (OHLCV fetch for all tickers)
|
||||||
- Sentiment Collector (sentiment for all tickers)
|
- Sentiment Collector (sentiment for all tickers)
|
||||||
- Fundamental Collector (fundamentals for all tickers)
|
- Dolt Earnings / SEC Fundamentals imports (bulk fundamentals sources)
|
||||||
- R:R Scanner (trade setup scan for all tickers)
|
- R:R Scanner (trade setup scan for all tickers)
|
||||||
|
|
||||||
Each job processes tickers independently, logs errors as structured JSON,
|
Each job processes tickers independently, logs errors as structured JSON,
|
||||||
@@ -18,29 +18,28 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import date, datetime, timedelta, timezone
|
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.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy import and_, case, func, or_, select
|
from sqlalchemy import and_, case, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app import job_catalog
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.models.fundamental import FundamentalData
|
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.sentiment import SentimentScore
|
from app.models.sentiment import SentimentScore
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.exceptions import ProviderError
|
from app.exceptions import ProviderError
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
|
||||||
from app.providers.protocol import SentimentData
|
from app.providers.protocol import SentimentData
|
||||||
|
from app.services import job_run_store
|
||||||
from app.services import (
|
from app.services import (
|
||||||
fundamental_service,
|
|
||||||
ingestion_service,
|
ingestion_service,
|
||||||
pipeline_run,
|
pipeline_run,
|
||||||
sentiment_service,
|
sentiment_service,
|
||||||
settings_store,
|
settings_store,
|
||||||
shadow_book_service,
|
shadow_book_service,
|
||||||
fundamentals_parity_service,
|
|
||||||
fundamental_data_refresh_service,
|
fundamental_data_refresh_service,
|
||||||
)
|
)
|
||||||
from app.services.data_import import (
|
from app.services.data_import import (
|
||||||
@@ -67,6 +66,7 @@ from app.services.event_study_service import run_and_store as run_event_study_an
|
|||||||
from app.services.outcome_service import evaluate_pending_setups
|
from app.services.outcome_service import evaluate_pending_setups
|
||||||
from app.services.rr_scanner_service import scan_all_tickers
|
from app.services.rr_scanner_service import scan_all_tickers
|
||||||
from app.services.sentiment_provider_service import build_sentiment_provider
|
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||||
|
from app.services import ticker_service
|
||||||
from app.services.ticker_universe_service import bootstrap_universe
|
from app.services.ticker_universe_service import bootstrap_universe
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -88,36 +88,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
|
# Track last successful ticker per job for rate-limit resume
|
||||||
_last_successful: dict[str, str | None] = {
|
_last_successful: dict[str, str | None] = {
|
||||||
"data_collector": None,
|
"data_collector": None,
|
||||||
"data_backfill": None,
|
"data_backfill": None,
|
||||||
"sentiment_collector": None,
|
"sentiment_collector": None,
|
||||||
"fundamental_collector": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Jobs whose per-run progress is surfaced to Admin → Jobs. (outcome_evaluator is
|
# Seeded from the catalog rather than a private list. The old literal held 16 of
|
||||||
# created lazily on first run via _runtime_start.)
|
# the 19 jobs -- benchmark_collector, outcome_evaluator and shadow_book were
|
||||||
_JOB_NAMES = [
|
# missing, so they had no runtime row (and so no "last run" line in Admin → Jobs)
|
||||||
"data_collector",
|
# until their first run in a given process.
|
||||||
"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",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _idle_runtime() -> dict[str, object]:
|
def _idle_runtime() -> dict[str, object]:
|
||||||
@@ -134,7 +156,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_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||||
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE
|
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE
|
||||||
|
|
||||||
@@ -261,7 +285,14 @@ def _runtime_finish(
|
|||||||
processed: int,
|
processed: int,
|
||||||
total: int | None,
|
total: int | None,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
|
emit_event: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Finalize a job's runtime row, optionally raising a durable event.
|
||||||
|
|
||||||
|
``emit_event=False`` is for a *re-finalize* that only rewords an outcome an
|
||||||
|
earlier call already reported. The dedup key includes the message, so a
|
||||||
|
reworded error would otherwise land in Admin → System Events twice.
|
||||||
|
"""
|
||||||
runtime = _job_runtime.get(job_name, {})
|
runtime = _job_runtime.get(job_name, {})
|
||||||
runtime.update({
|
runtime.update({
|
||||||
"running": False,
|
"running": False,
|
||||||
@@ -275,7 +306,7 @@ def _runtime_finish(
|
|||||||
})
|
})
|
||||||
_job_runtime[job_name] = runtime
|
_job_runtime[job_name] = runtime
|
||||||
# Durable event for error / rate-limit finishes (badge + Admin → Jobs panel).
|
# Durable event for error / rate-limit finishes (badge + Admin → Jobs panel).
|
||||||
if status in ("error", "rate_limited"):
|
if emit_event and status in ("error", "rate_limited"):
|
||||||
severity = "error" if status == "error" else "warning"
|
severity = "error" if status == "error" else "warning"
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -292,6 +323,67 @@ def _runtime_finish(
|
|||||||
pass
|
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]:
|
def get_job_runtime_snapshot(job_name: str | None = None) -> dict[str, dict[str, object]] | dict[str, object]:
|
||||||
if job_name is not None:
|
if job_name is not None:
|
||||||
return dict(_job_runtime.get(job_name, {}))
|
return dict(_job_runtime.get(job_name, {}))
|
||||||
@@ -305,8 +397,10 @@ async def _is_job_enabled(db: AsyncSession, job_name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_all_tickers(db: AsyncSession) -> list[str]:
|
async def _get_all_tickers(db: AsyncSession) -> list[str]:
|
||||||
"""Return all tracked ticker symbols sorted alphabetically."""
|
"""Return all actively-traded ticker symbols sorted alphabetically."""
|
||||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
result = await db.execute(
|
||||||
|
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||||
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@@ -321,8 +415,10 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
|
|||||||
latest_date = func.max(OHLCVRecord.date)
|
latest_date = func.max(OHLCVRecord.date)
|
||||||
missing_first = case((latest_date.is_(None), 0), else_=1)
|
missing_first = case((latest_date.is_(None), 0), else_=1)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
ticker_service.active_only(
|
||||||
select(Ticker.symbol)
|
select(Ticker.symbol)
|
||||||
.outerjoin(OHLCVRecord, OHLCVRecord.ticker_id == Ticker.id)
|
.outerjoin(OHLCVRecord, OHLCVRecord.ticker_id == Ticker.id)
|
||||||
|
)
|
||||||
.group_by(Ticker.id, Ticker.symbol)
|
.group_by(Ticker.id, Ticker.symbol)
|
||||||
.order_by(missing_first.asc(), latest_date.asc(), Ticker.symbol.asc())
|
.order_by(missing_first.asc(), latest_date.asc(), Ticker.symbol.asc())
|
||||||
)
|
)
|
||||||
@@ -466,23 +562,6 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
|
|||||||
return priority_syms + filler_syms
|
return priority_syms + filler_syms
|
||||||
|
|
||||||
|
|
||||||
async def _get_fundamental_priority_tickers(db: AsyncSession) -> list[str]:
|
|
||||||
"""Return symbols prioritized for fundamentals refresh.
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
1) Tickers with no fundamentals snapshot yet
|
|
||||||
2) Tickers with existing fundamentals, oldest fetched_at first
|
|
||||||
3) Alphabetical tiebreaker
|
|
||||||
"""
|
|
||||||
missing_first = case((FundamentalData.fetched_at.is_(None), 0), else_=1)
|
|
||||||
result = await db.execute(
|
|
||||||
select(Ticker.symbol)
|
|
||||||
.outerjoin(FundamentalData, FundamentalData.ticker_id == Ticker.id)
|
|
||||||
.order_by(missing_first.asc(), FundamentalData.fetched_at.asc(), Ticker.symbol.asc())
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
def _resume_tickers(symbols: list[str], job_name: str) -> list[str]:
|
def _resume_tickers(symbols: list[str], job_name: str) -> list[str]:
|
||||||
"""Reorder tickers to resume after the last successful one (rate-limit resume).
|
"""Reorder tickers to resume after the last successful one (rate-limit resume).
|
||||||
|
|
||||||
@@ -588,6 +667,26 @@ async def collect_ohlcv(
|
|||||||
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
|
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
|
||||||
_log_event(logging.INFO, "ticker_collected", job=job_name, ticker=symbol, status=result.status, records=result.records_ingested)
|
_log_event(logging.INFO, "ticker_collected", job=job_name, ticker=symbol, status=result.status, records=result.records_ingested)
|
||||||
if result.status == "stale":
|
if result.status == "stale":
|
||||||
|
# "No new bars" cannot distinguish a delisting from a halt
|
||||||
|
# or a rename, so ask SEC before warning again. A confirmed
|
||||||
|
# delisting retires the symbol (keeping its history) and
|
||||||
|
# ends the alert; anything unproven keeps warning.
|
||||||
|
delisted_on = await ticker_service.confirm_delisting(
|
||||||
|
db, symbol, last_bar=result.last_date
|
||||||
|
)
|
||||||
|
if delisted_on is not None:
|
||||||
|
await _record_system_event(
|
||||||
|
severity="info",
|
||||||
|
source=job_name,
|
||||||
|
code="ticker_delisted",
|
||||||
|
message=(
|
||||||
|
f"{symbol} delisted on {delisted_on} (SEC Form 25/15). "
|
||||||
|
"Retired from signals; price history retained."
|
||||||
|
),
|
||||||
|
symbol=symbol,
|
||||||
|
dedup_key=f"ticker_delisted:{symbol}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
await _record_system_event(
|
await _record_system_event(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
source=job_name,
|
source=job_name,
|
||||||
@@ -816,149 +915,16 @@ async def collect_sentiment() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Job: Fundamental Collector
|
# Jobs: bulk fundamentals source imports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def collect_fundamentals() -> None:
|
async def _run_source_import(job_name: str, importer: SourceImporter) -> bool:
|
||||||
"""Fetch fundamentals for all tracked tickers via FMP.
|
|
||||||
|
|
||||||
Processes each ticker independently. On rate limit, records last
|
|
||||||
successful ticker for resume.
|
|
||||||
"""
|
|
||||||
job_name = "fundamental_collector"
|
|
||||||
_log_event(logging.INFO, "job_start", job=job_name)
|
|
||||||
_runtime_start(job_name)
|
|
||||||
processed = 0
|
|
||||||
total: int | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
if not await _is_job_enabled(db, job_name):
|
|
||||||
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
|
||||||
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
|
|
||||||
return
|
|
||||||
if await fundamental_data_refresh_service.is_enabled(db):
|
|
||||||
message = "SEC + Dolt fundamentals cutover is active"
|
|
||||||
_log_event(
|
|
||||||
logging.INFO,
|
|
||||||
"job_skipped",
|
|
||||||
job=job_name,
|
|
||||||
reason="sec_dolt_cutover_active",
|
|
||||||
)
|
|
||||||
_runtime_finish(
|
|
||||||
job_name,
|
|
||||||
"skipped",
|
|
||||||
processed=0,
|
|
||||||
total=0,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
symbols = await _get_fundamental_priority_tickers(db)
|
|
||||||
if not symbols:
|
|
||||||
_log_event(logging.INFO, "job_complete", job=job_name, tickers=0)
|
|
||||||
_runtime_finish(job_name, "completed", processed=0, total=0, message="No tickers")
|
|
||||||
return
|
|
||||||
|
|
||||||
total = len(symbols)
|
|
||||||
_runtime_progress(job_name, processed=0, total=total)
|
|
||||||
|
|
||||||
if not (settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key):
|
|
||||||
_log_event(logging.WARNING, "job_skipped", job=job_name, reason="no fundamentals provider keys configured")
|
|
||||||
_runtime_finish(job_name, "skipped", processed=0, total=total, message="No fundamentals provider keys configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
provider = build_fundamental_provider_chain()
|
|
||||||
except Exception as exc:
|
|
||||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
|
||||||
_runtime_finish(job_name, "error", processed=0, total=total, message=str(exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
max_retries = max(0, settings.fundamental_rate_limit_retries)
|
|
||||||
base_backoff = max(1, settings.fundamental_rate_limit_backoff_seconds)
|
|
||||||
spacing = max(0.0, settings.fundamental_request_spacing_seconds)
|
|
||||||
|
|
||||||
async def _store(symbol: str, data) -> None:
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
await fundamental_service.store_fundamental(
|
|
||||||
db,
|
|
||||||
symbol=symbol,
|
|
||||||
pe_ratio=data.pe_ratio,
|
|
||||||
revenue_growth=data.revenue_growth,
|
|
||||||
earnings_surprise=data.earnings_surprise,
|
|
||||||
market_cap=data.market_cap,
|
|
||||||
next_earnings_date=data.next_earnings_date,
|
|
||||||
unavailable_fields=data.unavailable_fields,
|
|
||||||
)
|
|
||||||
|
|
||||||
for symbol in symbols:
|
|
||||||
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
|
|
||||||
attempt = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
data = await provider.fetch_fundamentals(symbol)
|
|
||||||
await _store(symbol, data)
|
|
||||||
_last_successful[job_name] = symbol
|
|
||||||
processed += 1
|
|
||||||
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
|
|
||||||
_log_event(logging.INFO, "ticker_collected", job=job_name, ticker=symbol)
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "rate" in msg or "429" in msg:
|
|
||||||
if attempt < max_retries:
|
|
||||||
wait_seconds = base_backoff * (2 ** attempt)
|
|
||||||
attempt += 1
|
|
||||||
_log_event(logging.WARNING, "rate_limited_retry", job=job_name, ticker=symbol, attempt=attempt, max_retries=max_retries, wait_seconds=wait_seconds, processed=processed)
|
|
||||||
_runtime_progress(
|
|
||||||
job_name,
|
|
||||||
processed=processed,
|
|
||||||
total=total,
|
|
||||||
current_ticker=symbol,
|
|
||||||
message=f"Rate-limited at {symbol}; retry {attempt}/{max_retries} in {wait_seconds}s",
|
|
||||||
)
|
|
||||||
await asyncio.sleep(wait_seconds)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Retries exhausted: store whatever partial data we can
|
|
||||||
# still get (e.g. FMP market cap) and move on, rather than
|
|
||||||
# aborting the whole run and leaving every later ticker
|
|
||||||
# untouched.
|
|
||||||
_log_event(logging.WARNING, "rate_limited_partial", job=job_name, ticker=symbol, processed=processed)
|
|
||||||
try:
|
|
||||||
data = await provider.fetch_fundamentals(symbol, allow_partial=True)
|
|
||||||
await _store(symbol, data)
|
|
||||||
processed += 1
|
|
||||||
except Exception as exc2:
|
|
||||||
_log_job_error(job_name, symbol, exc2)
|
|
||||||
break
|
|
||||||
_log_job_error(job_name, symbol, exc)
|
|
||||||
break
|
|
||||||
|
|
||||||
if spacing:
|
|
||||||
await asyncio.sleep(spacing)
|
|
||||||
|
|
||||||
_last_successful[job_name] = None
|
|
||||||
_log_event(logging.INFO, "job_complete", job=job_name, tickers=processed)
|
|
||||||
_runtime_finish(job_name, "completed", processed=processed, total=total, message=f"Processed {processed} tickers")
|
|
||||||
except Exception as exc:
|
|
||||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
|
||||||
_runtime_finish(job_name, "error", processed=processed, total=total, message=str(exc))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Jobs: shadow fundamentals sources
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
|
|
||||||
"""Run an importer and return whether its scheduled job was enabled.
|
"""Run an importer and return whether its scheduled job was enabled.
|
||||||
|
|
||||||
The SEC wrapper uses the return value to run its activated local cache step
|
The SEC wrapper uses the return value only to word its runtime message: its
|
||||||
after deferred, failed, no-op, promoted, or source-locked attempts while honoring
|
local cache step runs after deferred, failed, no-op, promoted, source-locked
|
||||||
the job-level disable switch.
|
and disabled attempts alike.
|
||||||
"""
|
"""
|
||||||
_log_event(logging.INFO, "job_start", job=job_name)
|
_log_event(logging.INFO, "job_start", job=job_name)
|
||||||
_runtime_start(job_name, total=1)
|
_runtime_start(job_name, total=1)
|
||||||
@@ -968,7 +934,7 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
|
|||||||
if not await _is_job_enabled(db, job_name):
|
if not await _is_job_enabled(db, job_name):
|
||||||
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||||
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
||||||
return
|
return False
|
||||||
|
|
||||||
run = await run_import(importer)
|
run = await run_import(importer)
|
||||||
if run is None:
|
if run is None:
|
||||||
@@ -1015,25 +981,26 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def run_dolt_earnings_import() -> None:
|
async def run_dolt_earnings_import() -> None:
|
||||||
"""Pull and import the Dolt earnings calendar/results feed in shadow."""
|
"""Pull and import the Dolt earnings calendar/results feed."""
|
||||||
await _run_shadow_import("dolt_earnings_import", DoltEarningsImporter())
|
await _run_source_import("dolt_earnings_import", DoltEarningsImporter())
|
||||||
|
|
||||||
|
|
||||||
async def run_sec_fundamentals_import() -> None:
|
async def run_sec_fundamentals_import() -> None:
|
||||||
"""Import SEC facts, then run the activated local compat-cache refresh.
|
"""Import SEC facts, then refresh the local compat cache.
|
||||||
|
|
||||||
The refresh is deliberately separate from the network import result. Once
|
The refresh is deliberately independent of the network import: it reads only
|
||||||
activated it therefore still runs from stored snapshots/earnings/prices when
|
stored snapshots, earnings events and closes, so it runs identically when SEC
|
||||||
SEC is unavailable, unchanged, or another SEC import owns the source lock.
|
is unavailable, unchanged, or owned by another import — and also when the
|
||||||
|
job's ingestion is switched off in Admin → Jobs. Disabling the job stops
|
||||||
|
SEC network access, not the cache; prices and earnings move daily even when
|
||||||
|
no filing does, and `fundamental_data` feeds scoring.
|
||||||
"""
|
"""
|
||||||
job_name = "sec_fundamentals_import"
|
job_name = "sec_fundamentals_import"
|
||||||
job_enabled = await _run_shadow_import(job_name, SecFundamentalsImporter())
|
import_ran = await _run_source_import(job_name, SecFundamentalsImporter())
|
||||||
if not job_enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
summary = await fundamental_data_refresh_service.refresh_if_enabled(db)
|
summary = await fundamental_data_refresh_service.refresh(db)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
_runtime_finish(
|
_runtime_finish(
|
||||||
job_name, "error", processed=0, total=1, message="Cancelled"
|
job_name, "error", processed=0, total=1, message="Cancelled"
|
||||||
@@ -1051,78 +1018,37 @@ async def run_sec_fundamentals_import() -> None:
|
|||||||
_runtime_finish(job_name, "error", processed=0, total=1, message=message)
|
_runtime_finish(job_name, "error", processed=0, total=1, message=message)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not summary["enabled"]:
|
|
||||||
_log_event(
|
|
||||||
logging.INFO,
|
|
||||||
"fundamental_data_refresh_skipped",
|
|
||||||
job=job_name,
|
|
||||||
reason="cutover_disabled",
|
|
||||||
setting=fundamental_data_refresh_service.ACTIVATION_KEY,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
_log_event(
|
_log_event(
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
"fundamental_data_refresh_complete",
|
"fundamental_data_refresh_complete",
|
||||||
job=job_name,
|
job=job_name,
|
||||||
**summary,
|
**summary,
|
||||||
)
|
)
|
||||||
runtime = get_job_runtime_snapshot(job_name)
|
|
||||||
if runtime.get("status") == "completed":
|
|
||||||
import_message = runtime.get("message") or "import completed"
|
|
||||||
cache_message = (
|
cache_message = (
|
||||||
f"cache {summary['refreshed']} · "
|
f"cache {summary['refreshed']} · "
|
||||||
f"{summary['score_inputs_changed']} score inputs changed"
|
f"{summary['score_inputs_changed']} score inputs changed"
|
||||||
)
|
)
|
||||||
|
# Every outcome carries the cache summary — including deferred, failed and
|
||||||
|
# source-locked ones. The import status is what varies; the refresh always
|
||||||
|
# happened, and Admin → Jobs is the only place an operator sees that.
|
||||||
|
#
|
||||||
|
# This only rewords what _run_source_import already finalized, so it must not
|
||||||
|
# emit a second durable event: the dedup key includes the message, and a
|
||||||
|
# failure would otherwise show up twice in Admin → System Events.
|
||||||
|
runtime = get_job_runtime_snapshot(job_name)
|
||||||
|
if import_ran:
|
||||||
|
status = str(runtime.get("status") or "completed")
|
||||||
|
import_message = runtime.get("message") or "import completed"
|
||||||
|
processed = 1 if status == "completed" else 0
|
||||||
|
else:
|
||||||
|
status, import_message, processed = "completed", "Import disabled", 1
|
||||||
_runtime_finish(
|
_runtime_finish(
|
||||||
job_name,
|
job_name,
|
||||||
"completed",
|
status,
|
||||||
processed=1,
|
processed=processed,
|
||||||
total=1,
|
total=1,
|
||||||
message=f"{import_message} · {cache_message}",
|
message=f"{import_message} · {cache_message}",
|
||||||
)
|
emit_event=False,
|
||||||
|
|
||||||
|
|
||||||
async def run_fundamentals_parity_report() -> None:
|
|
||||||
"""Generate the A5 comparison bundle without mutating live fundamentals/scores."""
|
|
||||||
job_name = "fundamentals_parity_report"
|
|
||||||
_log_event(logging.INFO, "job_start", job=job_name)
|
|
||||||
_runtime_start(job_name, total=1)
|
|
||||||
try:
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
if not await _is_job_enabled(db, job_name):
|
|
||||||
_runtime_finish(
|
|
||||||
job_name, "skipped", processed=0, total=1, message="Disabled"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
report, artifacts = await fundamentals_parity_service.generate_and_store(
|
|
||||||
db, settings.fundamentals_parity_report_dir
|
|
||||||
)
|
|
||||||
summary = report["summary"]
|
|
||||||
message = (
|
|
||||||
f"{summary['universe_count']} tickers · "
|
|
||||||
f"{summary['fundamental_score_material_changes']} material score changes"
|
|
||||||
)
|
|
||||||
_runtime_finish(job_name, "completed", processed=1, total=1, message=message)
|
|
||||||
_log_event(
|
|
||||||
logging.INFO,
|
|
||||||
"job_complete",
|
|
||||||
job=job_name,
|
|
||||||
generated_at=report["generated_at"],
|
|
||||||
json_path=artifacts["json"],
|
|
||||||
csv_path=artifacts["csv"],
|
|
||||||
)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
_runtime_finish(job_name, "error", processed=0, total=1, message="Cancelled")
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
|
||||||
_log_event(
|
|
||||||
logging.ERROR,
|
|
||||||
"job_error",
|
|
||||||
job=job_name,
|
|
||||||
error_type=type(exc).__name__,
|
|
||||||
message=str(exc),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1247,7 +1173,7 @@ async def dispatch_alerts_job() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Job: Market Regime
|
# Job: Market Trend (SPY)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -1306,7 +1232,7 @@ async def collect_benchmark() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Job: Regime Monitor
|
# Job: AI/Tech Risk Monitor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -1490,54 +1416,14 @@ async def sync_ticker_universe() -> None:
|
|||||||
# the intraday partial one (covers a long weekend / holiday gap).
|
# the intraday partial one (covers a long weekend / holiday gap).
|
||||||
_FINAL_REFETCH_DAYS = 5
|
_FINAL_REFETCH_DAYS = 5
|
||||||
|
|
||||||
_DAILY_PIPELINE_STEPS = [
|
# Step lists live in app.job_catalog so the runner, the admin API's pipeline
|
||||||
("data_collector", "collect_ohlcv"),
|
# membership and the UI's grouping all read one definition. Re-exported here
|
||||||
("benchmark_collector", "collect_benchmark"),
|
# under their original names: _run_pipeline and the scheduler_configured log
|
||||||
("sentiment_collector", "collect_sentiment"),
|
# payload refer to them directly.
|
||||||
("market_regime", "compute_market_regime"),
|
_DAILY_PIPELINE_STEPS = job_catalog._DAILY_PIPELINE_STEPS
|
||||||
# Observational only — display/alerts; not trade selection.
|
_NEAR_CLOSE_PIPELINE_STEPS = job_catalog._NEAR_CLOSE_PIPELINE_STEPS
|
||||||
("regime_monitor", "compute_regime_monitor"),
|
_AFTER_CLOSE_PIPELINE_STEPS = job_catalog._AFTER_CLOSE_PIPELINE_STEPS
|
||||||
# Alerts after regime so quadrant changes reach Telegram in the morning.
|
_INTRADAY_PIPELINE_STEPS = job_catalog._INTRADAY_PIPELINE_STEPS
|
||||||
# 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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Warn if near-close fetch+scan+alert drifts past this — entries leave the close
|
# Warn if near-close fetch+scan+alert drifts past this — entries leave the close
|
||||||
# and the stale_close floor quietly becomes the ceiling.
|
# and the stale_close floor quietly becomes the ceiling.
|
||||||
@@ -1560,6 +1446,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
|||||||
if not await _is_job_enabled(db, job_name):
|
if not await _is_job_enabled(db, job_name):
|
||||||
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||||
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
|
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
|
||||||
|
await _persist_job_run(job_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
total = len(steps)
|
total = len(steps)
|
||||||
@@ -1575,6 +1462,11 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
|||||||
await funcs[func_name]()
|
await funcs[func_name]()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("%s step %s failed", job_name, step_name)
|
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
|
done += 1
|
||||||
_runtime_finish(job_name, "completed", processed=done, total=total, message="Pipeline complete")
|
_runtime_finish(job_name, "completed", processed=done, total=total, message="Pipeline complete")
|
||||||
_log_event(logging.INFO, "job_complete", job=job_name)
|
_log_event(logging.INFO, "job_complete", job=job_name)
|
||||||
@@ -1583,10 +1475,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))
|
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||||
finally:
|
finally:
|
||||||
pipeline_run.release(token)
|
pipeline_run.release(token)
|
||||||
|
await _persist_job_run(job_name)
|
||||||
|
|
||||||
|
|
||||||
async def run_daily_pipeline() -> None:
|
async def run_daily_pipeline() -> None:
|
||||||
"""Morning flow: OHLCV → benchmark → sentiment → market regime (no scan)."""
|
"""Morning flow: OHLCV → benchmark → sentiment → trend/risk (no scan)."""
|
||||||
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
|
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
|
||||||
|
|
||||||
|
|
||||||
@@ -1666,19 +1559,22 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
|
|||||||
"schedule_timezone": "America/New_York",
|
"schedule_timezone": "America/New_York",
|
||||||
# Morning data/display refresh (no qualifying R:R scan).
|
# Morning data/display refresh (no qualifying R:R scan).
|
||||||
"schedule_daily_pipeline_cron": "0 2 * * *",
|
"schedule_daily_pipeline_cron": "0 2 * * *",
|
||||||
# Bulk source imports. The SEC job writes the legacy compat cache only after
|
# Bulk source imports. The SEC job also refreshes the fundamental_data compat
|
||||||
# the explicit, default-off A5 cutover setting is enabled.
|
# cache that scoring reads — locally, from stored snapshots/earnings/closes.
|
||||||
"schedule_dolt_earnings_cron": "30 2 * * *",
|
"schedule_dolt_earnings_cron": "30 2 * * *",
|
||||||
"schedule_sec_fundamentals_cron": "0 4 * * *",
|
"schedule_sec_fundamentals_cron": "0 4 * * *",
|
||||||
"schedule_fundamentals_parity_cron": "30 5 * * *",
|
|
||||||
# Fetch in-progress bars → scan → Telegram (manual MOC window).
|
# Fetch in-progress bars → scan → Telegram (manual MOC window).
|
||||||
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
|
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
|
||||||
# Fetch final bars → outcome eval (must not run on the partial near-close bar).
|
# Fetch final bars → outcome eval (must not run on the partial near-close bar).
|
||||||
"schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
|
"schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
|
||||||
# Hourly mid-session price + outcome (10:00–15:00 ET Mon–Fri).
|
# Hourly mid-session price + outcome (10:00–15:00 ET Mon–Fri).
|
||||||
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
|
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
|
||||||
# Weekly fundamentals early Monday NY.
|
# Both were interval jobs until 2026-08-08 and hit exactly the pitfall
|
||||||
"schedule_fundamentals_cron": "0 1 * * mon",
|
# 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
|
# job id -> schedule setting key
|
||||||
@@ -1686,11 +1582,11 @@ _CRON_JOBS: dict[str, str] = {
|
|||||||
"daily_pipeline": "schedule_daily_pipeline_cron",
|
"daily_pipeline": "schedule_daily_pipeline_cron",
|
||||||
"dolt_earnings_import": "schedule_dolt_earnings_cron",
|
"dolt_earnings_import": "schedule_dolt_earnings_cron",
|
||||||
"sec_fundamentals_import": "schedule_sec_fundamentals_cron",
|
"sec_fundamentals_import": "schedule_sec_fundamentals_cron",
|
||||||
"fundamentals_parity_report": "schedule_fundamentals_parity_cron",
|
|
||||||
"near_close_pipeline": "schedule_near_close_pipeline_cron",
|
"near_close_pipeline": "schedule_near_close_pipeline_cron",
|
||||||
"after_close_pipeline": "schedule_after_close_pipeline_cron",
|
"after_close_pipeline": "schedule_after_close_pipeline_cron",
|
||||||
"intraday_pipeline": "schedule_intraday_pipeline_cron",
|
"intraday_pipeline": "schedule_intraday_pipeline_cron",
|
||||||
"fundamental_collector": "schedule_fundamentals_cron",
|
"backtest": "schedule_backtest_cron",
|
||||||
|
"ticker_universe_sync": "schedule_ticker_universe_cron",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1756,8 +1652,12 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
(scan_rr, "rr_scanner", "R:R Scanner"),
|
(scan_rr, "rr_scanner", "R:R Scanner"),
|
||||||
(run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"),
|
(run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"),
|
||||||
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
||||||
(compute_market_regime, "market_regime", "Market Regime"),
|
# Labels only -- the ids are persisted (pipeline steps, cron config, run
|
||||||
(compute_regime_monitor, "regime_monitor", "Regime Monitor"),
|
# history), so they stay. "Market Regime"/"Regime Monitor" read as the
|
||||||
|
# same job and had it backwards besides: the SPY guard is the one that
|
||||||
|
# changes what a setup shows, while the monitor is observational.
|
||||||
|
(compute_market_regime, "market_regime", "Market Trend (SPY)"),
|
||||||
|
(compute_regime_monitor, "regime_monitor", "AI/Tech Risk Monitor"),
|
||||||
]
|
]
|
||||||
for fn, job_id, job_name in _members:
|
for fn, job_id, job_name in _members:
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
@@ -1779,7 +1679,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
"schedule_dolt_earnings_cron",
|
"schedule_dolt_earnings_cron",
|
||||||
),
|
),
|
||||||
id="dolt_earnings_import",
|
id="dolt_earnings_import",
|
||||||
name="Dolt Earnings Import (shadow)",
|
name="Dolt Earnings Import",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
@@ -1793,17 +1693,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
name="SEC Fundamentals Import",
|
name="SEC Fundamentals Import",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.add_job(
|
|
||||||
run_fundamentals_parity_report,
|
|
||||||
_cron_trigger(
|
|
||||||
cfg["schedule_fundamentals_parity_cron"],
|
|
||||||
tz,
|
|
||||||
"schedule_fundamentals_parity_cron",
|
|
||||||
),
|
|
||||||
id="fundamentals_parity_report",
|
|
||||||
name="Fundamentals Parity Report (read-only)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
run_near_close_pipeline,
|
run_near_close_pipeline,
|
||||||
_cron_trigger(
|
_cron_trigger(
|
||||||
@@ -1831,17 +1720,13 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
_cron_trigger(cfg["schedule_intraday_pipeline_cron"], tz, "schedule_intraday_pipeline_cron"),
|
_cron_trigger(cfg["schedule_intraday_pipeline_cron"], tz, "schedule_intraday_pipeline_cron"),
|
||||||
id="intraday_pipeline", name="Intraday Pipeline", replace_existing=True,
|
id="intraday_pipeline", name="Intraday Pipeline", replace_existing=True,
|
||||||
)
|
)
|
||||||
# Fundamentals — quarterly-ish data; weekly by default (conserves API quota).
|
|
||||||
# Its own early cron so the slow, rate-limited fetch finishes before the day.
|
|
||||||
scheduler.add_job(
|
|
||||||
collect_fundamentals,
|
|
||||||
_cron_trigger(cfg["schedule_fundamentals_cron"], tz, "schedule_fundamentals_cron"),
|
|
||||||
id="fundamental_collector", name="Fundamental Collector", replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Independent interval jobs (own cadence, no ordering dependency)
|
# Independent 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(
|
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,
|
id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
|
||||||
)
|
)
|
||||||
# Alerts auto-fire only via near_close_pipeline (scan → alert before MOC).
|
# Alerts auto-fire only via near_close_pipeline (scan → alert before MOC).
|
||||||
@@ -1852,7 +1737,8 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
replace_existing=True, next_run_time=None,
|
replace_existing=True, next_run_time=None,
|
||||||
)
|
)
|
||||||
scheduler.add_job(
|
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,
|
id="backtest", name="Backtest", replace_existing=True,
|
||||||
)
|
)
|
||||||
# Deep history backfill: manual only (never auto-fires); triggered from
|
# Deep history backfill: manual only (never auto-fires); triggered from
|
||||||
@@ -1879,9 +1765,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
},
|
},
|
||||||
dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]},
|
dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]},
|
||||||
sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]},
|
sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]},
|
||||||
fundamentals_parity_report={
|
|
||||||
"cron": cfg["schedule_fundamentals_parity_cron"]
|
|
||||||
},
|
|
||||||
near_close_pipeline={
|
near_close_pipeline={
|
||||||
"cron": cfg["schedule_near_close_pipeline_cron"],
|
"cron": cfg["schedule_near_close_pipeline_cron"],
|
||||||
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
|
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
|
||||||
@@ -1894,7 +1777,6 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
"cron": cfg["schedule_intraday_pipeline_cron"],
|
"cron": cfg["schedule_intraday_pipeline_cron"],
|
||||||
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS],
|
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS],
|
||||||
},
|
},
|
||||||
fundamental_collector={"cron": cfg["schedule_fundamentals_cron"]},
|
|
||||||
independent=["ticker_universe_sync", "backtest"],
|
independent=["ticker_universe_sync", "backtest"],
|
||||||
manual_only=["alerts", "data_backfill", "event_study"],
|
manual_only=["alerts", "data_backfill", "event_study"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ class ActivationConfigUpdate(BaseModel):
|
|||||||
exclude_neutral: bool | None = None
|
exclude_neutral: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class FundamentalsCutoverConfigUpdate(BaseModel):
|
|
||||||
"""Switch the legacy fundamentals cache from quota APIs to SEC/Dolt."""
|
|
||||||
enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConfigUpdate(BaseModel):
|
class ScheduleConfigUpdate(BaseModel):
|
||||||
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field
|
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field
|
||||||
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
|
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
|
||||||
@@ -85,11 +80,11 @@ class ScheduleConfigUpdate(BaseModel):
|
|||||||
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
|
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||||
schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120)
|
schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120)
|
||||||
schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
||||||
schedule_fundamentals_parity_cron: str | None = Field(default=None, max_length=120)
|
|
||||||
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||||
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||||
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
|
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
|
||||||
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
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):
|
class PerformanceConfigUpdate(BaseModel):
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
"""Ticker request/response schemas."""
|
"""Ticker request/response schemas."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -14,5 +14,16 @@ class TickerResponse(BaseModel):
|
|||||||
symbol: str
|
symbol: str
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
# NULL == actively traded. Delisted symbols stay in the registry with their
|
||||||
|
# history and are excluded from signals — the date is what makes that
|
||||||
|
# visible instead of the row silently disappearing.
|
||||||
|
delisted_on: date | None = None
|
||||||
|
delisted_reason: str | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TickerDelistingUpdate(BaseModel):
|
||||||
|
delisted_on: date = Field(
|
||||||
|
..., description="Effective date the symbol stopped trading"
|
||||||
|
)
|
||||||
|
|||||||
+99
-122
@@ -7,6 +7,7 @@ from passlib.hash import bcrypt
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app import job_catalog
|
||||||
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
||||||
from app.models.fundamental import FundamentalData
|
from app.models.fundamental import FundamentalData
|
||||||
from app.models.ohlcv import OHLCVRecord
|
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.ticker import Ticker
|
||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import fundamental_data_refresh_service, settings_store
|
from app.services import job_run_store, settings_store
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -159,28 +160,6 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
|
|||||||
return setting
|
return setting
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fundamentals source cutover
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_fundamentals_cutover_config(db: AsyncSession) -> dict[str, bool]:
|
|
||||||
"""Return the explicit A5 cache-cutover switch (default off)."""
|
|
||||||
return {"enabled": await fundamental_data_refresh_service.is_enabled(db)}
|
|
||||||
|
|
||||||
|
|
||||||
async def update_fundamentals_cutover_config(
|
|
||||||
db: AsyncSession, enabled: bool
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
"""Activate or pause SEC/Dolt writes to the legacy fundamentals cache."""
|
|
||||||
await settings_store.upsert_setting(
|
|
||||||
db,
|
|
||||||
fundamental_data_refresh_service.ACTIVATION_KEY,
|
|
||||||
"true" if enabled else "false",
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return await get_fundamentals_cutover_config(db)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Activation thresholds
|
# Activation thresholds
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -628,94 +607,110 @@ async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
|
|||||||
# Job control (placeholder — scheduler is Task 12.1)
|
# Job control (placeholder — scheduler is Task 12.1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
VALID_JOB_NAMES = {
|
# Job identity, labels and pipeline membership now live in app.job_catalog, which
|
||||||
"data_collector",
|
# derives PIPELINE_MEMBERS from the pipeline step lists instead of restating them.
|
||||||
"data_backfill",
|
# Re-exported here because callers (routers, tests) import them from this module.
|
||||||
"benchmark_collector",
|
VALID_JOB_NAMES = job_catalog.VALID_JOB_NAMES
|
||||||
"sentiment_collector",
|
JOB_LABELS = job_catalog.JOB_LABELS
|
||||||
"fundamental_collector",
|
PIPELINE_MEMBERS = job_catalog.PIPELINE_MEMBERS
|
||||||
"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_LABELS = {
|
# Anything further out than this is a parked backstop, not a schedule: pipeline
|
||||||
"data_collector": "Data Collector (OHLCV)",
|
# steps and manual jobs are registered on a 520-week interval, and triggering one
|
||||||
"data_backfill": "Data Backfill (deep history)",
|
# re-arms it. Belt-and-braces behind the category rule in _next_run_fields.
|
||||||
"benchmark_collector": "Benchmark Collector",
|
_NEXT_RUN_HORIZON_DAYS = 365
|
||||||
"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)",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Jobs driven by a pipeline (in order) rather than their own auto timer.
|
|
||||||
PIPELINE_MEMBERS = {
|
def _visible_next_run(next_run: datetime | None) -> datetime | None:
|
||||||
"data_collector",
|
"""Drop a next-run that is really the parked backstop."""
|
||||||
"benchmark_collector",
|
if next_run is None:
|
||||||
"sentiment_collector",
|
return None
|
||||||
"rr_scanner",
|
horizon = datetime.now(next_run.tzinfo) + timedelta(days=_NEXT_RUN_HORIZON_DAYS)
|
||||||
"outcome_evaluator",
|
return None if next_run > horizon else next_run
|
||||||
"alerts",
|
|
||||||
"market_regime",
|
|
||||||
"regime_monitor",
|
def _own_next_run(scheduler, name: str) -> datetime | None:
|
||||||
"shadow_book",
|
# 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]:
|
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
|
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 = []
|
jobs_out = []
|
||||||
for name in sorted(VALID_JOB_NAMES):
|
for name in visible:
|
||||||
# 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
|
|
||||||
job = scheduler.get_job(name)
|
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)
|
runtime = get_job_runtime_snapshot(name)
|
||||||
|
last = last_runs.get(name)
|
||||||
|
|
||||||
jobs_out.append({
|
jobs_out.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"label": JOB_LABELS.get(name, name),
|
"label": JOB_LABELS.get(name, name),
|
||||||
"enabled": enabled,
|
"enabled": enabled_map.get(name, True),
|
||||||
"next_run_at": next_run,
|
"category": job_catalog.JOB_CATEGORY.get(name),
|
||||||
"via_pipeline": name in PIPELINE_MEMBERS,
|
"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,
|
"registered": job is not None,
|
||||||
"running": bool(runtime.get("running", False)),
|
"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_status": runtime.get("status"),
|
||||||
"runtime_processed": runtime.get("processed"),
|
"runtime_processed": runtime.get("processed"),
|
||||||
"runtime_total": runtime.get("total"),
|
"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_started_at": runtime.get("started_at"),
|
||||||
"runtime_finished_at": runtime.get("finished_at"),
|
"runtime_finished_at": runtime.get("finished_at"),
|
||||||
"runtime_message": runtime.get("message"),
|
"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
|
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"
|
key = f"job_{job_name}_enabled"
|
||||||
return await update_setting(db, key, str(enabled).lower())
|
return await update_setting(db, key, str(enabled).lower())
|
||||||
|
|
||||||
|
|
||||||
def get_fundamentals_parity_report() -> dict | None:
|
|
||||||
"""Return the latest compact A5 summary, if the job has run."""
|
|
||||||
from app.config import settings
|
|
||||||
from app.services.fundamentals_parity_service import load_latest
|
|
||||||
|
|
||||||
report = load_latest(settings.fundamentals_parity_report_dir)
|
|
||||||
if report is not None:
|
|
||||||
report.pop("rows", None) # full per-ticker data is download-only
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
def get_fundamentals_parity_csv() -> tuple[str, str] | None:
|
|
||||||
"""Return the latest A5 CSV filename and content for authenticated download."""
|
|
||||||
from app.config import settings
|
|
||||||
from app.services.fundamentals_parity_service import load_latest_csv
|
|
||||||
|
|
||||||
return load_latest_csv(settings.fundamentals_parity_report_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def get_fundamentals_parity_json() -> tuple[str, str] | None:
|
|
||||||
"""Return the canonical A5 JSON artifact for authenticated download."""
|
|
||||||
from app.config import settings
|
|
||||||
from app.services.fundamentals_parity_service import load_latest_json
|
|
||||||
|
|
||||||
return load_latest_json(settings.fundamentals_parity_report_dir)
|
|
||||||
|
|||||||
@@ -860,7 +860,7 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
|
|||||||
else:
|
else:
|
||||||
metrics = f"State {x:.0f} · Warning {y:.0f}"
|
metrics = f"State {x:.0f} · Warning {y:.0f}"
|
||||||
text = (
|
text = (
|
||||||
f"🧭 <b>Regime quadrant change</b>\n"
|
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
|
||||||
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
||||||
f"{metrics}\n"
|
f"{metrics}\n"
|
||||||
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
||||||
|
|||||||
+134
-448
@@ -1320,7 +1320,6 @@ def _replay_candidates_for_period(
|
|||||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
include_short_candidates: bool = False,
|
include_short_candidates: bool = False,
|
||||||
include_universe_rank_observations: bool = False,
|
include_universe_rank_observations: bool = False,
|
||||||
outcome_horizon_sessions: int = HORIZON,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Slim picklable replay used by local event studies.
|
"""Slim picklable replay used by local event studies.
|
||||||
|
|
||||||
@@ -1344,13 +1343,10 @@ def _replay_candidates_for_period(
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
cadence = validate_backtest_cadence(cadence)
|
cadence = validate_backtest_cadence(cadence)
|
||||||
replay_horizon = int(outcome_horizon_sessions)
|
|
||||||
if replay_horizon < 0:
|
|
||||||
raise ValueError('outcome_horizon_sessions must be non-negative')
|
|
||||||
candidates: list[dict] = []
|
candidates: list[dict] = []
|
||||||
for i in range(
|
for i in range(
|
||||||
MIN_LOOKBACK - 1,
|
MIN_LOOKBACK - 1,
|
||||||
len(bars) - replay_horizon,
|
len(bars) - HORIZON,
|
||||||
backtest_step_sessions(cadence),
|
backtest_step_sessions(cadence),
|
||||||
):
|
):
|
||||||
if bars[i].date < start_date:
|
if bars[i].date < start_date:
|
||||||
@@ -1464,6 +1460,21 @@ def _mp_context():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _rollback_quietly(db: AsyncSession, context: str) -> None:
|
||||||
|
"""Discard a failed unit of work so later statements on this session survive.
|
||||||
|
|
||||||
|
Every DB call in ``run_backtest`` is best-effort — one unreadable ticker must
|
||||||
|
not abort the whole replay. But swallowing the exception alone leaves asyncpg
|
||||||
|
in "current transaction is aborted": every later statement then fails the same
|
||||||
|
way until the first unguarded one (the report write) surfaces it as the job
|
||||||
|
error, long after the real cause. Same guard as ``price_service``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Session rollback after %s also failed", context)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_columns(db: AsyncSession, symbol: str) -> tuple | None:
|
async def _fetch_columns(db: AsyncSession, symbol: str) -> tuple | None:
|
||||||
"""Read one ticker's OHLCV and detach it to primitive column arrays in the
|
"""Read one ticker's OHLCV and detach it to primitive column arrays in the
|
||||||
event loop (safe ORM access), ready to hand to a worker. None if no data."""
|
event loop (safe ORM access), ready to hand to a worker. None if no data."""
|
||||||
@@ -1705,7 +1716,14 @@ def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) -
|
|||||||
# the QUALIFIED setups at their detection close, best momentum first while
|
# the QUALIFIED setups at their detection close, best momentum first while
|
||||||
# slots and cash allow.
|
# slots and cash allow.
|
||||||
SIM_STARTING_CAPITAL = 10_000.0
|
SIM_STARTING_CAPITAL = 10_000.0
|
||||||
SIM_MAX_POSITIONS = 10
|
# Headroom, not a target: the count cap should never bind. The capacity study
|
||||||
|
# (reports/portfolio-construction-prod505-capacity-bracket-daily-v1) showed a book
|
||||||
|
# that never hits the count cap earns +1.1pp CAGR over the old 10 (51 cohorts of 175
|
||||||
|
# better, 2 worse) at unchanged drawdown, because the blocked entries were as good as
|
||||||
|
# the taken ones — capacity costs trade COUNT, not trade quality. The real ceiling is
|
||||||
|
# cash plus SIM_NOTIONAL_CAP, which saturates the book near 12 positions, so 15/20/None
|
||||||
|
# are the same experiment. Judge any future change here on CAGR, never on EV per trade.
|
||||||
|
SIM_MAX_POSITIONS = 15
|
||||||
SIM_RISK_PER_TRADE = 0.01 # fraction of equity risked per position (entry→stop)
|
SIM_RISK_PER_TRADE = 0.01 # fraction of equity risked per position (entry→stop)
|
||||||
SIM_NOTIONAL_CAP = 0.20 # max fraction of equity per position (no margin)
|
SIM_NOTIONAL_CAP = 0.20 # max fraction of equity per position (no margin)
|
||||||
_EULER_MASCHERONI = 0.5772156649015329
|
_EULER_MASCHERONI = 0.5772156649015329
|
||||||
@@ -1946,7 +1964,6 @@ def _make_gate_reset_reentry_fn(
|
|||||||
cadence: str,
|
cadence: str,
|
||||||
qualified_fn: Callable[[dict], bool] | None = None,
|
qualified_fn: Callable[[dict], bool] | None = None,
|
||||||
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
|
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
|
||||||
evaluation_horizon_sessions: int = HORIZON,
|
|
||||||
) -> Callable[[str, int, dict, Any], dict | None]:
|
) -> Callable[[str, int, dict, Any], dict | None]:
|
||||||
"""Build the production post-stop gate-reset callback.
|
"""Build the production post-stop gate-reset callback.
|
||||||
|
|
||||||
@@ -1964,18 +1981,11 @@ def _make_gate_reset_reentry_fn(
|
|||||||
|
|
||||||
evaluation_ords: dict[str, set[int]] = {}
|
evaluation_ords: dict[str, set[int]] = {}
|
||||||
step_sessions = backtest_step_sessions(cadence)
|
step_sessions = backtest_step_sessions(cadence)
|
||||||
evaluation_horizon = int(evaluation_horizon_sessions)
|
|
||||||
if evaluation_horizon < 0:
|
|
||||||
raise ValueError('evaluation_horizon_sessions must be non-negative')
|
|
||||||
for symbol, columns in prices.items():
|
for symbol, columns in prices.items():
|
||||||
ordinals = columns[0]
|
ordinals = columns[0]
|
||||||
evaluation_ords[symbol] = {
|
evaluation_ords[symbol] = {
|
||||||
int(ordinals[index])
|
int(ordinals[index])
|
||||||
for index in range(
|
for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions)
|
||||||
MIN_LOOKBACK - 1,
|
|
||||||
len(ordinals) - evaluation_horizon,
|
|
||||||
step_sessions,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
|
qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
|
||||||
@@ -2022,7 +2032,7 @@ def _simulate_portfolio(
|
|||||||
*,
|
*,
|
||||||
qualified_fn: Callable[[dict], bool] | None = None,
|
qualified_fn: Callable[[dict], bool] | None = None,
|
||||||
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
|
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
|
||||||
max_positions: int | None = SIM_MAX_POSITIONS,
|
max_positions: int = SIM_MAX_POSITIONS,
|
||||||
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
||||||
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
|
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
|
||||||
cost_per_side: float = COST_PER_SIDE,
|
cost_per_side: float = COST_PER_SIDE,
|
||||||
@@ -2046,12 +2056,6 @@ def _simulate_portfolio(
|
|||||||
corr_lookback: int = 120,
|
corr_lookback: int = 120,
|
||||||
corr_action: str = "skip",
|
corr_action: str = "skip",
|
||||||
corr_min_overlap: int = 60,
|
corr_min_overlap: int = 60,
|
||||||
min_initial_risk_fraction: float | None = None,
|
|
||||||
weekly_top_n_rebalance: bool = False,
|
|
||||||
daily_rank_map: dict[tuple[str, str], dict[str, float | None]] | None = None,
|
|
||||||
measurement_start_date: date | None = None,
|
|
||||||
hard_end_date: date | None = None,
|
|
||||||
include_capacity_diagnostics: bool = False,
|
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Replay the qualified setups as ONE capital-constrained book and report
|
"""Replay the qualified setups as ONE capital-constrained book and report
|
||||||
portfolio economics from the daily equity curve (return, CAGR, drawdown,
|
portfolio economics from the daily equity curve (return, CAGR, drawdown,
|
||||||
@@ -2101,20 +2105,6 @@ def _simulate_portfolio(
|
|||||||
raise ValueError("corr_action must be 'skip' or 'half_size'")
|
raise ValueError("corr_action must be 'skip' or 'half_size'")
|
||||||
if vol_target is not None and vol_target <= 0:
|
if vol_target is not None and vol_target <= 0:
|
||||||
raise ValueError("vol_target must be positive when set")
|
raise ValueError("vol_target must be positive when set")
|
||||||
if max_positions is not None and int(max_positions) <= 0:
|
|
||||||
raise ValueError("max_positions must be positive or None")
|
|
||||||
if min_initial_risk_fraction is not None and not (
|
|
||||||
0.0 < float(min_initial_risk_fraction) < 1.0
|
|
||||||
):
|
|
||||||
raise ValueError("min_initial_risk_fraction must be between 0 and 1")
|
|
||||||
if weekly_top_n_rebalance and (
|
|
||||||
max_positions is None or daily_rank_map is None
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"weekly_top_n_rebalance requires max_positions and daily_rank_map"
|
|
||||||
)
|
|
||||||
if weekly_top_n_rebalance and fill_mode != FILL_MODE_CLOSE:
|
|
||||||
raise ValueError("weekly_top_n_rebalance requires fill_mode=close")
|
|
||||||
clamp_lo, clamp_hi = float(vol_clamp[0]), float(vol_clamp[1])
|
clamp_lo, clamp_hi = float(vol_clamp[0]), float(vol_clamp[1])
|
||||||
if clamp_lo <= 0 or clamp_hi < clamp_lo:
|
if clamp_lo <= 0 or clamp_hi < clamp_lo:
|
||||||
raise ValueError("vol_clamp must satisfy 0 < lo <= hi")
|
raise ValueError("vol_clamp must satisfy 0 < lo <= hi")
|
||||||
@@ -2126,26 +2116,8 @@ def _simulate_portfolio(
|
|||||||
|
|
||||||
entries_by_ord: dict[int, list[dict]] = defaultdict(list)
|
entries_by_ord: dict[int, list[dict]] = defaultdict(list)
|
||||||
start_ord = start_date.toordinal() if start_date is not None else None
|
start_ord = start_date.toordinal() if start_date is not None else None
|
||||||
measurement_start_ord = (
|
|
||||||
measurement_start_date.toordinal()
|
|
||||||
if measurement_start_date is not None
|
|
||||||
else start_ord
|
|
||||||
)
|
|
||||||
hard_end_ord = hard_end_date.toordinal() if hard_end_date is not None else None
|
|
||||||
# Explicit simulator/holdout end dates are exclusive split boundaries.
|
# Explicit simulator/holdout end dates are exclusive split boundaries.
|
||||||
end_ord = end_date.toordinal() if end_date is not None else None
|
end_ord = end_date.toordinal() if end_date is not None else None
|
||||||
if (
|
|
||||||
start_ord is not None
|
|
||||||
and measurement_start_ord is not None
|
|
||||||
and measurement_start_ord < start_ord
|
|
||||||
):
|
|
||||||
raise ValueError("measurement_start_date cannot precede start_date")
|
|
||||||
if (
|
|
||||||
hard_end_ord is not None
|
|
||||||
and measurement_start_ord is not None
|
|
||||||
and hard_end_ord <= measurement_start_ord
|
|
||||||
):
|
|
||||||
raise ValueError("hard_end_date must follow measurement_start_date")
|
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
if not qualified_fn(c) or c.get("direction") != "long":
|
if not qualified_fn(c) or c.get("direction") != "long":
|
||||||
continue
|
continue
|
||||||
@@ -2154,8 +2126,6 @@ def _simulate_portfolio(
|
|||||||
continue
|
continue
|
||||||
if end_ord is not None and entry_ord >= end_ord:
|
if end_ord is not None and entry_ord >= end_ord:
|
||||||
continue # holdout/validation: entries strictly before the split
|
continue # holdout/validation: entries strictly before the split
|
||||||
if hard_end_ord is not None and entry_ord >= hard_end_ord:
|
|
||||||
continue
|
|
||||||
if not c.get("entry") or not c.get("stop"):
|
if not c.get("entry") or not c.get("stop"):
|
||||||
continue
|
continue
|
||||||
entries_by_ord[entry_ord].append(c)
|
entries_by_ord[entry_ord].append(c)
|
||||||
@@ -2168,12 +2138,7 @@ def _simulate_portfolio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
first_ord = start_ord if start_ord is not None else min(entries_by_ord)
|
first_ord = start_ord if start_ord is not None else min(entries_by_ord)
|
||||||
full_calendar = sorted({o for cols in prices.values() for o in cols[0]})
|
calendar = sorted({o for cols in prices.values() for o in cols[0] if o >= first_ord})
|
||||||
calendar = [
|
|
||||||
o
|
|
||||||
for o in full_calendar
|
|
||||||
if o >= first_ord and (hard_end_ord is None or o < hard_end_ord)
|
|
||||||
]
|
|
||||||
if not calendar:
|
if not calendar:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2181,7 +2146,6 @@ def _simulate_portfolio(
|
|||||||
# fill lag). Prevents trailing flat-cash after the last resolvable entry —
|
# fill lag). Prevents trailing flat-cash after the last resolvable entry —
|
||||||
# the clear-air train-window bug — for train, validation, and full-period
|
# the clear-air train-window bug — for train, validation, and full-period
|
||||||
# books alike (including max-hold sweeps out to 90 days).
|
# books alike (including max-hold sweeps out to 90 days).
|
||||||
if hard_end_ord is None:
|
|
||||||
last_signal_ord = max(entries_by_ord)
|
last_signal_ord = max(entries_by_ord)
|
||||||
resolve_pad = hold_days + (1 if fill_mode in DELAYED_FILL_MODES else 0)
|
resolve_pad = hold_days + (1 if fill_mode in DELAYED_FILL_MODES else 0)
|
||||||
cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1
|
cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1
|
||||||
@@ -2189,31 +2153,13 @@ def _simulate_portfolio(
|
|||||||
if not calendar:
|
if not calendar:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
weekly_rebalance_ords: set[int] = set()
|
|
||||||
for index, session_ord in enumerate(full_calendar):
|
|
||||||
session_date = date.fromordinal(session_ord)
|
|
||||||
iso = session_date.isocalendar()
|
|
||||||
if index + 1 < len(full_calendar):
|
|
||||||
next_iso = date.fromordinal(full_calendar[index + 1]).isocalendar()
|
|
||||||
if (iso.year, iso.week) != (next_iso.year, next_iso.week):
|
|
||||||
weekly_rebalance_ords.add(session_ord)
|
|
||||||
elif session_date.weekday() == 4:
|
|
||||||
weekly_rebalance_ords.add(session_ord)
|
|
||||||
|
|
||||||
cash = SIM_STARTING_CAPITAL
|
cash = SIM_STARTING_CAPITAL
|
||||||
positions: dict[str, dict] = {}
|
positions: dict[str, dict] = {}
|
||||||
curve: list[tuple[int, float]] = []
|
curve: list[tuple[int, float]] = []
|
||||||
trades: list[dict] = []
|
trades: list[dict] = []
|
||||||
skipped_full = 0
|
skipped_full = 0
|
||||||
measurement_skipped_full = 0
|
|
||||||
skipped_cooldown = 0
|
skipped_cooldown = 0
|
||||||
skipped_corr = 0
|
skipped_corr = 0
|
||||||
skipped_min_initial_risk = 0
|
|
||||||
measurement_skipped_min_initial_risk = 0
|
|
||||||
opened_positions = 0
|
|
||||||
measurement_opened_positions = 0
|
|
||||||
weekly_rank_rejected_entries = 0
|
|
||||||
measurement_weekly_rank_rejected_entries = 0
|
|
||||||
skipped_missing_fill = 0
|
skipped_missing_fill = 0
|
||||||
skipped_gap_cap = 0
|
skipped_gap_cap = 0
|
||||||
cooldown_until_index: dict[str, int] = {}
|
cooldown_until_index: dict[str, int] = {}
|
||||||
@@ -2228,12 +2174,6 @@ def _simulate_portfolio(
|
|||||||
vol_scalars: list[float] = []
|
vol_scalars: list[float] = []
|
||||||
overnight_slippage_pct: list[float] = []
|
overnight_slippage_pct: list[float] = []
|
||||||
pending_delayed: list[dict] = []
|
pending_delayed: list[dict] = []
|
||||||
measurement_start_equity: float | None = None
|
|
||||||
measurement_start_position_count: int | None = None
|
|
||||||
capacity_samples: list[dict[str, float | int]] = []
|
|
||||||
weekly_rebalance_events: list[dict] = []
|
|
||||||
rebalance_exit_index: dict[str, tuple[int, int]] = {}
|
|
||||||
rebalance_reentry_events: list[dict] = []
|
|
||||||
|
|
||||||
def _bar(sym: str, o: int):
|
def _bar(sym: str, o: int):
|
||||||
idx = index_of.get(sym, {}).get(o)
|
idx = index_of.get(sym, {}).get(o)
|
||||||
@@ -2303,13 +2243,6 @@ def _simulate_portfolio(
|
|||||||
cost = proceeds * cost_rate
|
cost = proceeds * cost_rate
|
||||||
cash += proceeds - cost
|
cash += proceeds - cost
|
||||||
risk = pos["entry"] - pos["initial_stop"]
|
risk = pos["entry"] - pos["initial_stop"]
|
||||||
initial_risk_dollars = pos["shares"] * risk
|
|
||||||
net_pnl = (
|
|
||||||
proceeds
|
|
||||||
- pos["shares"] * pos["entry"]
|
|
||||||
- cost
|
|
||||||
- pos["entry_cost"]
|
|
||||||
)
|
|
||||||
trades.append({
|
trades.append({
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
"entry_ord": pos["entry_ord"],
|
"entry_ord": pos["entry_ord"],
|
||||||
@@ -2318,13 +2251,8 @@ def _simulate_portfolio(
|
|||||||
"initial_stop": pos["initial_stop"],
|
"initial_stop": pos["initial_stop"],
|
||||||
"active_stop": pos["stop"],
|
"active_stop": pos["stop"],
|
||||||
"fill": fill,
|
"fill": fill,
|
||||||
"shares": pos["shares"],
|
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"],
|
||||||
"initial_risk_dollars": initial_risk_dollars,
|
|
||||||
"pnl": net_pnl,
|
|
||||||
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
|
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
|
||||||
"net_r": net_pnl / initial_risk_dollars
|
|
||||||
if initial_risk_dollars > 0
|
|
||||||
else 0.0,
|
|
||||||
"hold": pos["bars_held"],
|
"hold": pos["bars_held"],
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"stop_refreshes": pos["stop_refreshes"],
|
"stop_refreshes": pos["stop_refreshes"],
|
||||||
@@ -2339,13 +2267,6 @@ def _simulate_portfolio(
|
|||||||
|
|
||||||
cooldown_sessions = max(0, int(reentry_cooldown_sessions))
|
cooldown_sessions = max(0, int(reentry_cooldown_sessions))
|
||||||
for calendar_index, o in enumerate(calendar):
|
for calendar_index, o in enumerate(calendar):
|
||||||
in_measurement = (
|
|
||||||
measurement_start_ord is None or o >= measurement_start_ord
|
|
||||||
)
|
|
||||||
if in_measurement and measurement_start_equity is None:
|
|
||||||
measurement_start_equity = _marked_equity()
|
|
||||||
measurement_start_position_count = len(positions)
|
|
||||||
|
|
||||||
# 1) exits on today's bars (stop intraday, target intraday, time at close)
|
# 1) exits on today's bars (stop intraday, target intraday, time at close)
|
||||||
for sym in list(positions):
|
for sym in list(positions):
|
||||||
pos = positions[sym]
|
pos = positions[sym]
|
||||||
@@ -2459,82 +2380,6 @@ def _simulate_portfolio(
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
weekly_selected_entries: list[dict] | None = None
|
|
||||||
if weekly_top_n_rebalance and o in weekly_rebalance_ords:
|
|
||||||
assert max_positions is not None
|
|
||||||
assert daily_rank_map is not None
|
|
||||||
asof = date.fromordinal(o).isoformat()
|
|
||||||
protected: set[str] = set()
|
|
||||||
ranked_pool: list[tuple[float, int, str, dict | None]] = []
|
|
||||||
for sym in positions:
|
|
||||||
rank_row = daily_rank_map.get((sym, asof))
|
|
||||||
current_rank = (
|
|
||||||
rank_row.get("strategy_rank") if rank_row is not None else None
|
|
||||||
)
|
|
||||||
if current_rank is None or _bar(sym, o) is None:
|
|
||||||
protected.add(sym)
|
|
||||||
continue
|
|
||||||
ranked_pool.append((float(current_rank), 0, sym, None))
|
|
||||||
|
|
||||||
entrants_by_symbol: dict[str, dict] = {}
|
|
||||||
for candidate in signal_todays:
|
|
||||||
sym = str(candidate["symbol"])
|
|
||||||
if sym in positions or sym in entrants_by_symbol:
|
|
||||||
continue
|
|
||||||
entrants_by_symbol[sym] = candidate
|
|
||||||
eligible_entrants = 0
|
|
||||||
for sym, candidate in entrants_by_symbol.items():
|
|
||||||
rank_row = daily_rank_map.get((sym, asof))
|
|
||||||
current_rank = (
|
|
||||||
rank_row.get("strategy_rank") if rank_row is not None else None
|
|
||||||
)
|
|
||||||
if current_rank is None:
|
|
||||||
continue
|
|
||||||
eligible_entrants += 1
|
|
||||||
ranked_pool.append((float(current_rank), 1, sym, candidate))
|
|
||||||
|
|
||||||
available_slots = max(0, int(max_positions) - len(protected))
|
|
||||||
ranked_pool.sort(key=lambda row: (-row[0], row[1], row[2]))
|
|
||||||
selected = ranked_pool[:available_slots]
|
|
||||||
selected_holding_symbols = {
|
|
||||||
sym for _rank, kind, sym, _candidate in selected if kind == 0
|
|
||||||
}
|
|
||||||
weekly_selected_entries = [
|
|
||||||
candidate
|
|
||||||
for _rank, kind, _sym, candidate in selected
|
|
||||||
if kind == 1 and candidate is not None
|
|
||||||
]
|
|
||||||
selected_entrant_symbols = {
|
|
||||||
str(candidate["symbol"]) for candidate in weekly_selected_entries
|
|
||||||
}
|
|
||||||
rejected_now = max(0, eligible_entrants - len(selected_entrant_symbols))
|
|
||||||
weekly_rank_rejected_entries += rejected_now
|
|
||||||
if in_measurement:
|
|
||||||
measurement_weekly_rank_rejected_entries += rejected_now
|
|
||||||
|
|
||||||
exited_symbols: list[str] = []
|
|
||||||
for sym in list(positions):
|
|
||||||
if sym in protected or sym in selected_holding_symbols:
|
|
||||||
continue
|
|
||||||
bar = _bar(sym, o)
|
|
||||||
if bar is None:
|
|
||||||
continue
|
|
||||||
_close_trade(sym, float(bar.close), "weekly_rebalance")
|
|
||||||
rebalance_exit_index[sym] = (calendar_index, o)
|
|
||||||
exited_symbols.append(sym)
|
|
||||||
|
|
||||||
weekly_rebalance_events.append({
|
|
||||||
"ord": o,
|
|
||||||
"fresh_entrant_pool": len(entrants_by_symbol),
|
|
||||||
"rank_eligible_entrant_pool": eligible_entrants,
|
|
||||||
"selected_entrants": len(selected_entrant_symbols),
|
|
||||||
"replacements": len(exited_symbols),
|
|
||||||
"exited_symbols": sorted(exited_symbols),
|
|
||||||
"selected_entrant_symbols": sorted(selected_entrant_symbols),
|
|
||||||
"measurement": in_measurement,
|
|
||||||
})
|
|
||||||
equity = _marked_equity()
|
|
||||||
|
|
||||||
if fill_mode in DELAYED_FILL_MODES:
|
if fill_mode in DELAYED_FILL_MODES:
|
||||||
fill_candidates = sorted(
|
fill_candidates = sorted(
|
||||||
pending_delayed,
|
pending_delayed,
|
||||||
@@ -2543,11 +2388,7 @@ def _simulate_portfolio(
|
|||||||
)
|
)
|
||||||
pending_delayed = []
|
pending_delayed = []
|
||||||
else:
|
else:
|
||||||
fill_candidates = (
|
fill_candidates = signal_todays
|
||||||
weekly_selected_entries
|
|
||||||
if weekly_selected_entries is not None
|
|
||||||
else signal_todays
|
|
||||||
)
|
|
||||||
|
|
||||||
def _corr_scale_for(sym: str, asof_idx: int) -> float | None:
|
def _corr_scale_for(sym: str, asof_idx: int) -> float | None:
|
||||||
"""1.0 ok, 0.5 half-size, None = skip. Missing history → uncorrelated."""
|
"""1.0 ok, 0.5 half-size, None = skip. Missing history → uncorrelated."""
|
||||||
@@ -2592,21 +2433,15 @@ def _simulate_portfolio(
|
|||||||
corr_scale: float,
|
corr_scale: float,
|
||||||
fill_bar: Any | None,
|
fill_bar: Any | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
nonlocal cash, equity, skipped_full, measurement_skipped_full
|
nonlocal cash, equity, skipped_full, skipped_cooldown, post_stop_events
|
||||||
nonlocal skipped_cooldown, post_stop_events
|
|
||||||
nonlocal skipped_min_initial_risk
|
|
||||||
nonlocal measurement_skipped_min_initial_risk
|
|
||||||
nonlocal opened_positions, measurement_opened_positions
|
|
||||||
sym = c["symbol"]
|
sym = c["symbol"]
|
||||||
if sym in positions:
|
if sym in positions:
|
||||||
return
|
return
|
||||||
if calendar_index < cooldown_until_index.get(sym, -1):
|
if calendar_index < cooldown_until_index.get(sym, -1):
|
||||||
skipped_cooldown += 1
|
skipped_cooldown += 1
|
||||||
return
|
return
|
||||||
if max_positions is not None and len(positions) >= max_positions:
|
if len(positions) >= max_positions:
|
||||||
skipped_full += 1
|
skipped_full += 1
|
||||||
if in_measurement:
|
|
||||||
measurement_skipped_full += 1
|
|
||||||
return
|
return
|
||||||
risk_ps = entry - stop
|
risk_ps = entry - stop
|
||||||
if risk_ps <= 0 or entry <= 0:
|
if risk_ps <= 0 or entry <= 0:
|
||||||
@@ -2623,16 +2458,6 @@ def _simulate_portfolio(
|
|||||||
(equity * SIM_NOTIONAL_CAP) / entry,
|
(equity * SIM_NOTIONAL_CAP) / entry,
|
||||||
max(cash, 0.0) / (entry * (1.0 + cost_rate)),
|
max(cash, 0.0) / (entry * (1.0 + cost_rate)),
|
||||||
)
|
)
|
||||||
initial_risk_dollars = shares * risk_ps
|
|
||||||
if (
|
|
||||||
min_initial_risk_fraction is not None
|
|
||||||
and initial_risk_dollars
|
|
||||||
< equity * float(min_initial_risk_fraction)
|
|
||||||
):
|
|
||||||
skipped_min_initial_risk += 1
|
|
||||||
if in_measurement:
|
|
||||||
measurement_skipped_min_initial_risk += 1
|
|
||||||
return
|
|
||||||
if shares * entry < 1.0:
|
if shares * entry < 1.0:
|
||||||
return
|
return
|
||||||
entry_cost = shares * entry * cost_rate
|
entry_cost = shares * entry * cost_rate
|
||||||
@@ -2672,21 +2497,6 @@ def _simulate_portfolio(
|
|||||||
"vol_scalar": scalar,
|
"vol_scalar": scalar,
|
||||||
"corr_scale": corr_scale,
|
"corr_scale": corr_scale,
|
||||||
}
|
}
|
||||||
opened_positions += 1
|
|
||||||
if in_measurement:
|
|
||||||
measurement_opened_positions += 1
|
|
||||||
prior_rebalance_exit = rebalance_exit_index.pop(sym, None)
|
|
||||||
if prior_rebalance_exit is not None:
|
|
||||||
prior_exit_index, prior_exit_ord = prior_rebalance_exit
|
|
||||||
rebalance_reentry_events.append({
|
|
||||||
"symbol": sym,
|
|
||||||
"exit_ord": prior_exit_ord,
|
|
||||||
"exit_calendar_index": prior_exit_index,
|
|
||||||
"reentry_calendar_index": calendar_index,
|
|
||||||
"wait_sessions": calendar_index - prior_exit_index,
|
|
||||||
"reentry_ord": entry_ord,
|
|
||||||
"measurement": in_measurement,
|
|
||||||
})
|
|
||||||
# next_open only: fill is at the open, so the rest of the bar can stop out.
|
# next_open only: fill is at the open, so the rest of the bar can stop out.
|
||||||
# stale_close fills at the close — same-day stop after entry does not apply.
|
# stale_close fills at the close — same-day stop after entry does not apply.
|
||||||
# bars_held stays 0 on the fill day (matches close-fill cadence).
|
# bars_held stays 0 on the fill day (matches close-fill cadence).
|
||||||
@@ -2788,25 +2598,7 @@ def _simulate_portfolio(
|
|||||||
# Queue today's signals for the next session's fill.
|
# Queue today's signals for the next session's fill.
|
||||||
pending_delayed.extend(signal_todays)
|
pending_delayed.extend(signal_todays)
|
||||||
|
|
||||||
marked_equity = _marked_equity()
|
curve.append((o, _marked_equity()))
|
||||||
if in_measurement and include_capacity_diagnostics:
|
|
||||||
gross_notional = sum(
|
|
||||||
pos["shares"] * pos["last_close"] for pos in positions.values()
|
|
||||||
)
|
|
||||||
capacity_samples.append({
|
|
||||||
"positions": len(positions),
|
|
||||||
"cash_pct": cash / marked_equity * 100.0
|
|
||||||
if marked_equity > 0
|
|
||||||
else 0.0,
|
|
||||||
"gross_exposure_pct": gross_notional / marked_equity * 100.0
|
|
||||||
if marked_equity > 0
|
|
||||||
else 0.0,
|
|
||||||
"at_capacity": int(
|
|
||||||
max_positions is not None
|
|
||||||
and len(positions) >= max_positions
|
|
||||||
),
|
|
||||||
})
|
|
||||||
curve.append((o, marked_equity))
|
|
||||||
|
|
||||||
# Close whatever is still open at its last mark so final equity is realized.
|
# Close whatever is still open at its last mark so final equity is realized.
|
||||||
for sym in list(positions):
|
for sym in list(positions):
|
||||||
@@ -2814,57 +2606,45 @@ def _simulate_portfolio(
|
|||||||
final_equity = cash
|
final_equity = cash
|
||||||
curve[-1] = (calendar[-1], final_equity)
|
curve[-1] = (calendar[-1], final_equity)
|
||||||
|
|
||||||
metric_start_ord = (
|
total_return_pct = (final_equity / SIM_STARTING_CAPITAL - 1.0) * 100.0
|
||||||
measurement_start_ord if measurement_start_ord is not None else calendar[0]
|
years = (calendar[-1] - calendar[0]) / 365.25
|
||||||
)
|
|
||||||
metric_curve = [(day_ord, eq) for day_ord, eq in curve if day_ord >= metric_start_ord]
|
|
||||||
if not metric_curve:
|
|
||||||
return None
|
|
||||||
metric_base_equity = (
|
|
||||||
measurement_start_equity
|
|
||||||
if measurement_start_date is not None and measurement_start_equity is not None
|
|
||||||
else SIM_STARTING_CAPITAL
|
|
||||||
)
|
|
||||||
total_return_pct = (final_equity / metric_base_equity - 1.0) * 100.0
|
|
||||||
years = (calendar[-1] - metric_start_ord) / 365.25
|
|
||||||
cagr_pct = (
|
cagr_pct = (
|
||||||
((final_equity / metric_base_equity) ** (1.0 / years) - 1.0) * 100.0
|
((final_equity / SIM_STARTING_CAPITAL) ** (1.0 / years) - 1.0) * 100.0
|
||||||
if years > 0.25 and final_equity > 0
|
if years > 0.25 and final_equity > 0
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
peak = float("-inf")
|
peak = float("-inf")
|
||||||
max_dd = 0.0
|
max_dd = 0.0
|
||||||
drawdown_equities = (
|
for _, eq in curve:
|
||||||
[metric_base_equity, *(eq for _, eq in metric_curve)]
|
|
||||||
if measurement_start_date is not None
|
|
||||||
else [eq for _, eq in metric_curve]
|
|
||||||
)
|
|
||||||
for eq in drawdown_equities:
|
|
||||||
peak = max(peak, eq)
|
peak = max(peak, eq)
|
||||||
if peak > 0:
|
if peak > 0:
|
||||||
max_dd = max(max_dd, (peak - eq) / peak)
|
max_dd = max(max_dd, (peak - eq) / peak)
|
||||||
|
|
||||||
return_equities = (
|
rets = [b / a - 1.0 for (_, a), (_, b) in zip(curve, curve[1:]) if a > 0]
|
||||||
[metric_base_equity, *(eq for _, eq in metric_curve)]
|
|
||||||
if measurement_start_date is not None
|
|
||||||
else [eq for _, eq in metric_curve]
|
|
||||||
)
|
|
||||||
rets = [
|
|
||||||
b / a - 1.0
|
|
||||||
for a, b in zip(return_equities, return_equities[1:])
|
|
||||||
if a > 0
|
|
||||||
]
|
|
||||||
diag = sharpe_diagnostics(rets)
|
diag = sharpe_diagnostics(rets)
|
||||||
sharpe = diag["sharpe"]
|
sharpe = diag["sharpe"]
|
||||||
|
|
||||||
|
# Sortino: the same numerator as Sharpe over downside deviation about a zero
|
||||||
|
# target. The denominator divides by len(rets) — the full-sample lower partial
|
||||||
|
# moment — NOT by the count of down days, which would shrink the denominator
|
||||||
|
# and inflate the ratio. n >= 3 matches sharpe_diagnostics so the two appear
|
||||||
|
# together or not at all. No down days is +inf, reported as None.
|
||||||
|
sortino = None
|
||||||
|
downside = [r for r in rets if r < 0.0]
|
||||||
|
if len(rets) >= 3 and downside:
|
||||||
|
mean_ret = sum(rets) / len(rets)
|
||||||
|
dd = math.sqrt(sum(r * r for r in downside) / len(rets))
|
||||||
|
if dd > 0:
|
||||||
|
sortino = round(mean_ret / dd * math.sqrt(252.0), 2)
|
||||||
|
|
||||||
# Per-calendar-year returns off the equity curve — shows whether every year
|
# Per-calendar-year returns off the equity curve — shows whether every year
|
||||||
# contributed or one exceptional stretch carried the result.
|
# contributed or one exceptional stretch carried the result.
|
||||||
yearly: list[dict] = []
|
yearly: list[dict] = []
|
||||||
year_start_eq = metric_base_equity
|
year_start_eq = curve[0][1]
|
||||||
cur_year = date.fromordinal(metric_start_ord).year
|
cur_year = date.fromordinal(curve[0][0]).year
|
||||||
last_eq = metric_base_equity
|
last_eq = curve[0][1]
|
||||||
for o, eq in metric_curve:
|
for o, eq in curve:
|
||||||
y = date.fromordinal(o).year
|
y = date.fromordinal(o).year
|
||||||
if y != cur_year:
|
if y != cur_year:
|
||||||
yearly.append({
|
yearly.append({
|
||||||
@@ -2883,29 +2663,56 @@ def _simulate_portfolio(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
metric_trades = [
|
# Gain-to-Pain off the same curve, on MONTHLY returns: Schwager's ratio is
|
||||||
trade for trade in trades if trade["entry_ord"] >= metric_start_ord
|
# defined monthly and the daily variant is not comparable to published
|
||||||
]
|
# figures. Distinct loop variables from the yearly pass above — that one exits
|
||||||
pnls = [t["pnl"] for t in metric_trades]
|
# with last_eq at final equity, so reusing its names silently corrupts the
|
||||||
|
# first month. The monthly series itself is not emitted: 36-120 floats per
|
||||||
|
# strategy per lookback would bloat the single stored report blob.
|
||||||
|
monthly: list[float] = []
|
||||||
|
month_start_eq = curve[0][1]
|
||||||
|
month_last_eq = curve[0][1]
|
||||||
|
cur_month = date.fromordinal(curve[0][0]).replace(day=1)
|
||||||
|
for o, eq in curve:
|
||||||
|
m = date.fromordinal(o).replace(day=1)
|
||||||
|
if m != cur_month:
|
||||||
|
if month_start_eq > 0:
|
||||||
|
monthly.append(month_last_eq / month_start_eq - 1.0)
|
||||||
|
cur_month = m
|
||||||
|
month_start_eq = month_last_eq
|
||||||
|
month_last_eq = eq
|
||||||
|
if month_start_eq > 0:
|
||||||
|
monthly.append(month_last_eq / month_start_eq - 1.0)
|
||||||
|
|
||||||
|
# Schwager: SUM OF ALL monthly returns over the absolute sum of the negative
|
||||||
|
# ones. Not sum(positive)/|sum(negative)| — that is profit-factor-shaped and
|
||||||
|
# sits exactly 1.0 higher for every input, since sum(all) = sum(pos) - |sum(neg)|.
|
||||||
|
monthly_pain = -sum(r for r in monthly if r < 0.0)
|
||||||
|
gain_to_pain = round(sum(monthly) / monthly_pain, 2) if monthly_pain > 0 else None
|
||||||
|
|
||||||
|
pnls = [t["pnl"] for t in trades]
|
||||||
wins = sum(1 for p in pnls if p > 0)
|
wins = sum(1 for p in pnls if p > 0)
|
||||||
|
# Dollar-based, over closed-trade P&L. Distinct from the R-based profit_factor
|
||||||
|
# in _robustness_stats; the two never share an object.
|
||||||
|
gross_win = sum(p for p in pnls if p > 0)
|
||||||
|
gross_loss = -sum(p for p in pnls if p < 0)
|
||||||
|
profit_factor = round(gross_win / gross_loss, 2) if gross_loss > 0 else None
|
||||||
reason_counts = {
|
reason_counts = {
|
||||||
reason: sum(1 for t in metric_trades if t["reason"] == reason)
|
reason: sum(1 for t in trades if t["reason"] == reason)
|
||||||
for reason in sorted({t["reason"] for t in metric_trades})
|
for reason in sorted({t["reason"] for t in trades})
|
||||||
}
|
}
|
||||||
spy_pct = None
|
spy_pct = None
|
||||||
if spy_closes:
|
if spy_closes:
|
||||||
from app.services.benchmark_service import benchmark_return_pct
|
from app.services.benchmark_service import benchmark_return_pct
|
||||||
|
|
||||||
spy_pct = benchmark_return_pct(
|
spy_pct = benchmark_return_pct(
|
||||||
spy_closes,
|
spy_closes, date.fromordinal(calendar[0]), date.fromordinal(calendar[-1])
|
||||||
date.fromordinal(metric_start_ord),
|
|
||||||
date.fromordinal(calendar[-1]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
curve_payload: list[dict] | None = None
|
curve_payload: list[dict] | None = None
|
||||||
benchmark_payload: list[dict] | None = None
|
benchmark_payload: list[dict] | None = None
|
||||||
if include_curve:
|
if include_curve:
|
||||||
curve_base = metric_base_equity
|
curve_base = curve[0][1] if curve else SIM_STARTING_CAPITAL
|
||||||
curve_payload = [
|
curve_payload = [
|
||||||
{
|
{
|
||||||
"date": date.fromordinal(o).isoformat(),
|
"date": date.fromordinal(o).isoformat(),
|
||||||
@@ -2914,12 +2721,12 @@ def _simulate_portfolio(
|
|||||||
if curve_base > 0
|
if curve_base > 0
|
||||||
else None,
|
else None,
|
||||||
}
|
}
|
||||||
for o, eq in metric_curve
|
for o, eq in curve
|
||||||
]
|
]
|
||||||
if spy_closes:
|
if spy_closes:
|
||||||
benchmark_payload = []
|
benchmark_payload = []
|
||||||
base_spy = None
|
base_spy = None
|
||||||
for o, _ in metric_curve:
|
for o, _ in curve:
|
||||||
d = date.fromordinal(o)
|
d = date.fromordinal(o)
|
||||||
close = spy_closes.get(d)
|
close = spy_closes.get(d)
|
||||||
if close is None or close <= 0:
|
if close is None or close <= 0:
|
||||||
@@ -2938,176 +2745,42 @@ def _simulate_portfolio(
|
|||||||
calmar = float(cagr_pct) / max_dd_pct
|
calmar = float(cagr_pct) / max_dd_pct
|
||||||
result = {
|
result = {
|
||||||
"starting_capital": SIM_STARTING_CAPITAL,
|
"starting_capital": SIM_STARTING_CAPITAL,
|
||||||
"measurement_start_equity": round(metric_base_equity, 2),
|
|
||||||
"measurement_start_positions": measurement_start_position_count or 0,
|
|
||||||
"cost_per_side_pct": round(cost_rate * 100.0, 3),
|
"cost_per_side_pct": round(cost_rate * 100.0, 3),
|
||||||
"fill_mode": fill_mode,
|
"fill_mode": fill_mode,
|
||||||
"final_equity": round(final_equity, 2),
|
"final_equity": round(final_equity, 2),
|
||||||
"total_return_pct": round(total_return_pct, 1),
|
"total_return_pct": round(total_return_pct, 1),
|
||||||
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
||||||
"max_drawdown_pct": round(max_dd_pct, 1),
|
"max_drawdown_pct": round(max_dd_pct, 1),
|
||||||
|
# calmar IS MAR here (CAGR / max drawdown) — one field, two names.
|
||||||
"calmar": round(calmar, 2) if calmar is not None else None,
|
"calmar": round(calmar, 2) if calmar is not None else None,
|
||||||
|
# Emitted unconditionally even when None: the UI treats an ABSENT key as
|
||||||
|
# "report predates these metrics", so presence is a contract.
|
||||||
|
"sortino": sortino,
|
||||||
|
"gain_to_pain": gain_to_pain,
|
||||||
|
"profit_factor": profit_factor,
|
||||||
"sharpe": sharpe,
|
"sharpe": sharpe,
|
||||||
"sharpe_se": diag["sharpe_se"],
|
"sharpe_se": diag["sharpe_se"],
|
||||||
"psr": diag["psr"],
|
"psr": diag["psr"],
|
||||||
"n_returns": diag["n_returns"],
|
"n_returns": diag["n_returns"],
|
||||||
"return_skew": diag["return_skew"],
|
"return_skew": diag["return_skew"],
|
||||||
"return_kurtosis": diag["return_kurtosis"],
|
"return_kurtosis": diag["return_kurtosis"],
|
||||||
"trades": len(metric_trades),
|
"trades": len(trades),
|
||||||
"win_rate": (
|
"win_rate": round(wins / len(trades) * 100.0, 1) if trades else None,
|
||||||
round(wins / len(metric_trades) * 100.0, 1)
|
|
||||||
if metric_trades
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"avg_trade_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None,
|
"avg_trade_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None,
|
||||||
"best_trade_r": (
|
"best_trade_r": round(max(t["r"] for t in trades), 2) if trades else None,
|
||||||
round(max(t["r"] for t in metric_trades), 2)
|
"worst_trade_r": round(min(t["r"] for t in trades), 2) if trades else None,
|
||||||
if metric_trades
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"worst_trade_r": (
|
|
||||||
round(min(t["r"] for t in metric_trades), 2)
|
|
||||||
if metric_trades
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"best_trade_pnl": round(max(pnls), 2) if pnls else None,
|
"best_trade_pnl": round(max(pnls), 2) if pnls else None,
|
||||||
"worst_trade_pnl": round(min(pnls), 2) if pnls else None,
|
"worst_trade_pnl": round(min(pnls), 2) if pnls else None,
|
||||||
"avg_hold_days": (
|
"avg_hold_days": (
|
||||||
round(
|
round(sum(t["hold"] for t in trades) / len(trades), 1) if trades else None
|
||||||
sum(t["hold"] for t in metric_trades) / len(metric_trades),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
if metric_trades
|
|
||||||
else None
|
|
||||||
),
|
),
|
||||||
"exit_reasons": reason_counts,
|
"exit_reasons": reason_counts,
|
||||||
"skipped_book_full": skipped_full,
|
"skipped_book_full": skipped_full,
|
||||||
"spy_return_pct": round(spy_pct, 1) if spy_pct is not None else None,
|
"spy_return_pct": round(spy_pct, 1) if spy_pct is not None else None,
|
||||||
"yearly_returns": yearly,
|
"yearly_returns": yearly,
|
||||||
"start_date": date.fromordinal(metric_start_ord).isoformat(),
|
"start_date": date.fromordinal(calendar[0]).isoformat(),
|
||||||
"end_date": date.fromordinal(calendar[-1]).isoformat(),
|
"end_date": date.fromordinal(calendar[-1]).isoformat(),
|
||||||
}
|
}
|
||||||
if measurement_start_date is not None:
|
|
||||||
result["simulation_start_date"] = date.fromordinal(calendar[0]).isoformat()
|
|
||||||
if hard_end_date is not None:
|
|
||||||
result["hard_end_date_exclusive"] = hard_end_date.isoformat()
|
|
||||||
if measurement_start_date is not None:
|
|
||||||
result["measurement_skipped_book_full"] = measurement_skipped_full
|
|
||||||
result["measurement_opened_positions"] = measurement_opened_positions
|
|
||||||
if min_initial_risk_fraction is not None:
|
|
||||||
result["min_initial_risk_fraction"] = float(min_initial_risk_fraction)
|
|
||||||
result["skipped_min_initial_risk"] = skipped_min_initial_risk
|
|
||||||
result["measurement_skipped_min_initial_risk"] = (
|
|
||||||
measurement_skipped_min_initial_risk
|
|
||||||
)
|
|
||||||
if include_capacity_diagnostics:
|
|
||||||
measured_opened = (
|
|
||||||
measurement_opened_positions
|
|
||||||
if measurement_start_date is not None
|
|
||||||
else opened_positions
|
|
||||||
)
|
|
||||||
measured_full = (
|
|
||||||
measurement_skipped_full
|
|
||||||
if measurement_start_date is not None
|
|
||||||
else skipped_full
|
|
||||||
)
|
|
||||||
capacity_opportunities = measured_opened + measured_full
|
|
||||||
result["opened_positions"] = measured_opened
|
|
||||||
result["capacity_opportunities"] = capacity_opportunities
|
|
||||||
result["blocked_fraction"] = (
|
|
||||||
round(measured_full / capacity_opportunities, 6)
|
|
||||||
if capacity_opportunities
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
result["avg_positions"] = (
|
|
||||||
round(
|
|
||||||
sum(float(sample["positions"]) for sample in capacity_samples)
|
|
||||||
/ len(capacity_samples),
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
if capacity_samples
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
result["peak_positions"] = (
|
|
||||||
max(int(sample["positions"]) for sample in capacity_samples)
|
|
||||||
if capacity_samples
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
result["sessions_at_capacity"] = sum(
|
|
||||||
int(sample["at_capacity"]) for sample in capacity_samples
|
|
||||||
)
|
|
||||||
result["sessions_measured"] = len(capacity_samples)
|
|
||||||
result["avg_cash_pct"] = (
|
|
||||||
round(
|
|
||||||
sum(float(sample["cash_pct"]) for sample in capacity_samples)
|
|
||||||
/ len(capacity_samples),
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
if capacity_samples
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
result["avg_gross_exposure_pct"] = (
|
|
||||||
round(
|
|
||||||
sum(
|
|
||||||
float(sample["gross_exposure_pct"])
|
|
||||||
for sample in capacity_samples
|
|
||||||
)
|
|
||||||
/ len(capacity_samples),
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
if capacity_samples
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if weekly_top_n_rebalance:
|
|
||||||
measured_events = [
|
|
||||||
event for event in weekly_rebalance_events if event["measurement"]
|
|
||||||
]
|
|
||||||
measured_reentries = [
|
|
||||||
event for event in rebalance_reentry_events if event["measurement"]
|
|
||||||
]
|
|
||||||
result["weekly_rank_rejected_entries"] = (
|
|
||||||
measurement_weekly_rank_rejected_entries
|
|
||||||
if measurement_start_date is not None
|
|
||||||
else weekly_rank_rejected_entries
|
|
||||||
)
|
|
||||||
result["weekly_rebalance_events"] = [
|
|
||||||
{
|
|
||||||
**{
|
|
||||||
key: value
|
|
||||||
for key, value in event.items()
|
|
||||||
if key not in {"ord", "measurement"}
|
|
||||||
},
|
|
||||||
"date": date.fromordinal(event["ord"]).isoformat(),
|
|
||||||
}
|
|
||||||
for event in measured_events
|
|
||||||
]
|
|
||||||
result["rebalance_reentry_events"] = [
|
|
||||||
{
|
|
||||||
**{
|
|
||||||
key: value
|
|
||||||
for key, value in event.items()
|
|
||||||
if key
|
|
||||||
not in {
|
|
||||||
"exit_ord",
|
|
||||||
"reentry_ord",
|
|
||||||
"measurement",
|
|
||||||
"exit_calendar_index",
|
|
||||||
"reentry_calendar_index",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exit_date": date.fromordinal(event["exit_ord"]).isoformat(),
|
|
||||||
"reentry_date": date.fromordinal(
|
|
||||||
event["reentry_ord"]
|
|
||||||
).isoformat(),
|
|
||||||
}
|
|
||||||
for event in measured_reentries
|
|
||||||
]
|
|
||||||
for session_limit in (5, 10, 20):
|
|
||||||
result[f"rebalance_reentries_within_{session_limit}_sessions"] = sum(
|
|
||||||
1
|
|
||||||
for event in measured_reentries
|
|
||||||
if int(event["wait_sessions"]) <= session_limit
|
|
||||||
)
|
|
||||||
if vol_target is not None:
|
if vol_target is not None:
|
||||||
result["vol_target"] = vol_target
|
result["vol_target"] = vol_target
|
||||||
result["vol_lookback"] = int(vol_lookback)
|
result["vol_lookback"] = int(vol_lookback)
|
||||||
@@ -3182,7 +2855,7 @@ def _simulate_portfolio(
|
|||||||
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
|
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
|
||||||
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
|
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
|
||||||
}
|
}
|
||||||
for trade in metric_trades
|
for trade in trades
|
||||||
]
|
]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -4430,9 +4103,12 @@ async def run_backtest(
|
|||||||
config = await get_recommendation_config(db)
|
config = await get_recommendation_config(db)
|
||||||
activation = await get_activation_config(db)
|
activation = await get_activation_config(db)
|
||||||
|
|
||||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
|
||||||
tickers = list(result.scalars().all())
|
# objects held across them, and touching an expired attribute afterwards
|
||||||
total = len(tickers)
|
# triggers sync lazy-loading, which raises on an AsyncSession.
|
||||||
|
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||||
|
symbols = list(result.scalars().all())
|
||||||
|
total = len(symbols)
|
||||||
rank_only_symbols = await _load_research_rank_only_symbols(db)
|
rank_only_symbols = await _load_research_rank_only_symbols(db)
|
||||||
if rank_only_symbols:
|
if rank_only_symbols:
|
||||||
logger.info(json.dumps({
|
logger.info(json.dumps({
|
||||||
@@ -4456,6 +4132,7 @@ async def run_backtest(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Benchmark load for residual momentum failed")
|
logger.exception("Benchmark load for residual momentum failed")
|
||||||
|
await _rollback_quietly(db, "benchmark load")
|
||||||
|
|
||||||
def _merge(result: tuple[list[dict], dict]) -> None:
|
def _merge(result: tuple[list[dict], dict]) -> None:
|
||||||
cands, series = result
|
cands, series = result
|
||||||
@@ -4487,26 +4164,27 @@ async def run_backtest(
|
|||||||
done = 0
|
done = 0
|
||||||
with pool:
|
with pool:
|
||||||
for start in range(0, total, chunk):
|
for start in range(0, total, chunk):
|
||||||
batch = tickers[start : start + chunk]
|
batch = symbols[start : start + chunk]
|
||||||
futures = []
|
futures = []
|
||||||
for ticker in batch:
|
for symbol in batch:
|
||||||
try:
|
try:
|
||||||
columns = await _fetch_columns(db, ticker.symbol)
|
columns = await _fetch_columns(db, symbol)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Backtest fetch failed for %s", ticker.symbol)
|
logger.exception("Backtest fetch failed for %s", symbol)
|
||||||
|
await _rollback_quietly(db, f"fetch for {symbol}")
|
||||||
continue
|
continue
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
futures.append(loop.run_in_executor(
|
futures.append(loop.run_in_executor(
|
||||||
pool,
|
pool,
|
||||||
_replay_and_signals,
|
_replay_and_signals,
|
||||||
ticker.symbol,
|
symbol,
|
||||||
columns,
|
columns,
|
||||||
config,
|
config,
|
||||||
activation,
|
activation,
|
||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
ticker.symbol in rank_only_symbols,
|
symbol in rank_only_symbols,
|
||||||
))
|
))
|
||||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
@@ -4519,25 +4197,26 @@ async def run_backtest(
|
|||||||
else:
|
else:
|
||||||
# Sequential fallback (Windows / 1 worker): run each replay in a worker
|
# Sequential fallback (Windows / 1 worker): run each replay in a worker
|
||||||
# thread so the event loop — and the API server — stays responsive.
|
# thread so the event loop — and the API server — stays responsive.
|
||||||
for index, ticker in enumerate(tickers):
|
for index, symbol in enumerate(symbols):
|
||||||
if progress_cb is not None:
|
if progress_cb is not None:
|
||||||
progress_cb(index, total, ticker.symbol)
|
progress_cb(index, total, symbol)
|
||||||
try:
|
try:
|
||||||
columns = await _fetch_columns(db, ticker.symbol)
|
columns = await _fetch_columns(db, symbol)
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
_merge(await asyncio.to_thread(
|
_merge(await asyncio.to_thread(
|
||||||
_replay_and_signals,
|
_replay_and_signals,
|
||||||
ticker.symbol,
|
symbol,
|
||||||
columns,
|
columns,
|
||||||
config,
|
config,
|
||||||
activation,
|
activation,
|
||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
ticker.symbol in rank_only_symbols,
|
symbol in rank_only_symbols,
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
logger.exception("Backtest replay failed for %s", symbol)
|
||||||
|
await _rollback_quietly(db, f"replay for {symbol}")
|
||||||
|
|
||||||
if progress_cb is not None and total:
|
if progress_cb is not None and total:
|
||||||
progress_cb(total, total, "")
|
progress_cb(total, total, "")
|
||||||
@@ -4602,6 +4281,7 @@ async def run_backtest(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Benchmark load for the portfolio sim failed")
|
logger.exception("Benchmark load for the portfolio sim failed")
|
||||||
|
await _rollback_quietly(db, "portfolio-sim benchmark load")
|
||||||
|
|
||||||
for policy in ("target", "hold"):
|
for policy in ("target", "hold"):
|
||||||
sim = _simulate_portfolio(
|
sim = _simulate_portfolio(
|
||||||
@@ -4622,6 +4302,7 @@ async def run_backtest(
|
|||||||
live_exit_policy = await get_exit_policy(db)
|
live_exit_policy = await get_exit_policy(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Live exit policy load failed; monitor uses defaults")
|
logger.exception("Live exit policy load failed; monitor uses defaults")
|
||||||
|
await _rollback_quietly(db, "exit policy load")
|
||||||
portfolio_monitor_report = _portfolio_monitor(
|
portfolio_monitor_report = _portfolio_monitor(
|
||||||
candidates, price_columns, spy_closes, hold_horizon,
|
candidates, price_columns, spy_closes, hold_horizon,
|
||||||
live_exit_policy=live_exit_policy,
|
live_exit_policy=live_exit_policy,
|
||||||
@@ -4641,6 +4322,11 @@ async def run_backtest(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Portfolio simulation failed")
|
logger.exception("Portfolio simulation failed")
|
||||||
|
# Catches the price_columns fetch loop, which has no handler of its
|
||||||
|
# own. The inner handlers above may already have rolled back; a
|
||||||
|
# rollback on a clean session is a no-op, so this stays safe as the
|
||||||
|
# backstop for whichever DB call actually failed.
|
||||||
|
await _rollback_quietly(db, "portfolio simulation")
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
|
from app.services import ticker_service
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -112,7 +113,7 @@ def compute_divergence_series(
|
|||||||
async def _load_universe_closes(
|
async def _load_universe_closes(
|
||||||
db: AsyncSession, symbols: list[str] | None = None
|
db: AsyncSession, symbols: list[str] | None = None
|
||||||
) -> dict[str, Series]:
|
) -> dict[str, Series]:
|
||||||
stmt = select(Ticker).order_by(Ticker.symbol)
|
stmt = ticker_service.active_only(select(Ticker).order_by(Ticker.symbol))
|
||||||
if symbols is not None:
|
if symbols is not None:
|
||||||
stmt = stmt.where(Ticker.symbol.in_(symbols))
|
stmt = stmt.where(Ticker.symbol.in_(symbols))
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
@@ -148,11 +149,3 @@ async def compute_breadth_details(
|
|||||||
"""Breadth values plus the qualifying-member count for snapshot metadata."""
|
"""Breadth values plus the qualifying-member count for snapshot metadata."""
|
||||||
closes_by_symbol = await _load_universe_closes(db, symbols)
|
closes_by_symbol = await _load_universe_closes(db, symbols)
|
||||||
return _breadth_with_counts(closes_by_symbol, window, min_tickers)
|
return _breadth_with_counts(closes_by_symbol, window, min_tickers)
|
||||||
|
|
||||||
|
|
||||||
async def compute_breadth_today(db: AsyncSession) -> float | None:
|
|
||||||
"""Latest breadth reading (thin wrapper, for future live use)."""
|
|
||||||
series = await compute_breadth_series(db)
|
|
||||||
if not series:
|
|
||||||
return None
|
|
||||||
return series[max(series)]
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from app.config import settings
|
|||||||
from app.database import insert_for_session
|
from app.database import insert_for_session
|
||||||
from app.models.earnings_event import EarningsEvent
|
from app.models.earnings_event import EarningsEvent
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services import dolt_client, earnings_alignment
|
from app.services import dolt_client, earnings_alignment, ticker_service
|
||||||
from app.services.data_import import ValidationResult
|
from app.services.data_import import ValidationResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -289,7 +289,11 @@ class DoltEarningsImporter:
|
|||||||
# -- helpers -----------------------------------------------------------
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
async def _load_universe(self, db) -> dict[str, int]:
|
async def _load_universe(self, db) -> dict[str, int]:
|
||||||
rows = (await db.execute(select(Ticker.id, Ticker.symbol))).all()
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
ticker_service.active_only(select(Ticker.id, Ticker.symbol))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
return {
|
return {
|
||||||
earnings_alignment.normalise_symbol(symbol): tid
|
earnings_alignment.normalise_symbol(symbol): tid
|
||||||
for tid, symbol in rows
|
for tid, symbol in rows
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Compact chronological validation for the Regime Monitor warning score.
|
"""Compact chronological validation for the AI/Tech Risk Monitor warning score.
|
||||||
|
|
||||||
The study calls its outcome a 10% correction, uses the first 70% of sessions to
|
The study calls its outcome a 10% correction, uses the first 70% of sessions to
|
||||||
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
|
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
"""A5 activation: refresh the legacy fundamentals cache from local bulk data."""
|
"""Refresh the fundamentals compat cache from local SEC/Dolt bulk data.
|
||||||
|
|
||||||
|
``fundamental_data`` is the table scoring reads. This is its only writer.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,38 +15,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import insert_for_session
|
from app.database import insert_for_session
|
||||||
from app.models.fundamental import FundamentalData
|
from app.models.fundamental import FundamentalData
|
||||||
from app.models.score import CompositeScore, DimensionScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
from app.services import fundamentals_candidate_service, settings_store
|
from app.services import fundamentals_candidate_service
|
||||||
|
|
||||||
|
|
||||||
# Absence is deliberately false. Production activation therefore requires one
|
|
||||||
# explicit, durable SystemSetting change after the A5 evidence is approved.
|
|
||||||
ACTIVATION_KEY = "fundamental_data_sec_dolt_cutover_enabled"
|
|
||||||
_SCORE_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise")
|
_SCORE_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise")
|
||||||
|
|
||||||
|
|
||||||
async def is_enabled(db: AsyncSession) -> bool:
|
|
||||||
raw = await settings_store.get_value(db, ACTIVATION_KEY, "false")
|
|
||||||
return str(raw).strip().lower() == "true"
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_if_enabled(
|
|
||||||
db: AsyncSession,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
today: date | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Refresh atomically when activated; otherwise perform no writes."""
|
|
||||||
if not await is_enabled(db):
|
|
||||||
return {
|
|
||||||
"enabled": False,
|
|
||||||
"refreshed": 0,
|
|
||||||
"score_inputs_changed": 0,
|
|
||||||
"dimension_scores_staled": 0,
|
|
||||||
"composite_scores_staled": 0,
|
|
||||||
}
|
|
||||||
return await refresh(db, now=now, today=today)
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh(
|
async def refresh(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -117,7 +93,6 @@ async def refresh(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
|
||||||
"refreshed": len(candidates),
|
"refreshed": len(candidates),
|
||||||
"score_inputs_changed": len(changed_ids),
|
"score_inputs_changed": len(changed_ids),
|
||||||
"dimension_scores_staled": len(dimension_ids),
|
"dimension_scores_staled": len(dimension_ids),
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
"""Fundamental data service.
|
"""Fundamental data read access.
|
||||||
|
|
||||||
Stores fundamental data (P/E, revenue growth, earnings surprise, market cap)
|
``fundamental_data`` is the compat cache scoring reads. It is written solely by
|
||||||
and marks the fundamental dimension score as stale on new data.
|
``fundamental_data_refresh_service`` from SEC snapshots, Dolt earnings events and
|
||||||
|
stored closes; nothing fetches it per ticker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import insert_for_session
|
|
||||||
from app.exceptions import NotFoundError
|
from app.exceptions import NotFoundError
|
||||||
from app.models.fundamental import FundamentalData
|
from app.models.fundamental import FundamentalData
|
||||||
from app.models.score import DimensionScore
|
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,65 +29,6 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
|||||||
return ticker
|
return ticker
|
||||||
|
|
||||||
|
|
||||||
async def store_fundamental(
|
|
||||||
db: AsyncSession,
|
|
||||||
symbol: str,
|
|
||||||
pe_ratio: float | None = None,
|
|
||||||
revenue_growth: float | None = None,
|
|
||||||
earnings_surprise: float | None = None,
|
|
||||||
market_cap: float | None = None,
|
|
||||||
next_earnings_date=None,
|
|
||||||
unavailable_fields: dict[str, str] | None = None,
|
|
||||||
) -> FundamentalData:
|
|
||||||
"""Store or update fundamental data for a ticker.
|
|
||||||
|
|
||||||
Keeps a single latest snapshot per ticker. On new data, marks the
|
|
||||||
fundamental dimension score as stale (if one exists).
|
|
||||||
"""
|
|
||||||
ticker = await _get_ticker(db, symbol)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
unavailable_fields_json = json.dumps(unavailable_fields or {})
|
|
||||||
|
|
||||||
stmt = insert_for_session(db, FundamentalData).values(
|
|
||||||
ticker_id=ticker.id,
|
|
||||||
pe_ratio=pe_ratio,
|
|
||||||
revenue_growth=revenue_growth,
|
|
||||||
earnings_surprise=earnings_surprise,
|
|
||||||
market_cap=market_cap,
|
|
||||||
next_earnings_date=next_earnings_date,
|
|
||||||
fetched_at=now,
|
|
||||||
unavailable_fields_json=unavailable_fields_json,
|
|
||||||
)
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=["ticker_id"],
|
|
||||||
set_={
|
|
||||||
"pe_ratio": stmt.excluded.pe_ratio,
|
|
||||||
"revenue_growth": stmt.excluded.revenue_growth,
|
|
||||||
"earnings_surprise": stmt.excluded.earnings_surprise,
|
|
||||||
"market_cap": stmt.excluded.market_cap,
|
|
||||||
"next_earnings_date": stmt.excluded.next_earnings_date,
|
|
||||||
"fetched_at": stmt.excluded.fetched_at,
|
|
||||||
"unavailable_fields_json": stmt.excluded.unavailable_fields_json,
|
|
||||||
},
|
|
||||||
).returning(FundamentalData)
|
|
||||||
record = (await db.execute(stmt)).scalar_one()
|
|
||||||
|
|
||||||
# Mark fundamental dimension score as stale if it exists
|
|
||||||
# TODO: Use DimensionScore service when built
|
|
||||||
await db.execute(
|
|
||||||
update(DimensionScore)
|
|
||||||
.where(
|
|
||||||
DimensionScore.ticker_id == ticker.id,
|
|
||||||
DimensionScore.dimension == "fundamental",
|
|
||||||
)
|
|
||||||
.values(is_stale=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
async def get_fundamental(
|
async def get_fundamental(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Local SEC/Dolt candidate values for the legacy fundamentals cache.
|
"""Local SEC/Dolt candidate values for the fundamentals compat cache.
|
||||||
|
|
||||||
This is the single read path shared by the A5 parity report and the activated
|
This is the read path behind the ``fundamental_data`` refresh. It never contacts
|
||||||
``fundamental_data`` refresh. It never contacts SEC or Dolt: every input comes
|
SEC or Dolt: every input comes from PostgreSQL, so price- and earnings-driven
|
||||||
from PostgreSQL, so price- and earnings-driven values can still refresh when an
|
values can still refresh when an upstream import is unchanged or unavailable.
|
||||||
upstream import is unchanged or unavailable.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,6 +22,7 @@ from app.models.fundamental_snapshot import FundamentalSnapshot
|
|||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services import fundamentals_derivation as deriv
|
from app.services import fundamentals_derivation as deriv
|
||||||
|
from app.services import ticker_service
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -47,7 +47,11 @@ async def build_candidates(
|
|||||||
"""Derive current cache candidates using only already-stored data."""
|
"""Derive current cache candidates using only already-stored data."""
|
||||||
today = today or datetime.now(ZoneInfo("America/New_York")).date()
|
today = today or datetime.now(ZoneInfo("America/New_York")).date()
|
||||||
tickers = list(
|
tickers = list(
|
||||||
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
|
(
|
||||||
|
await db.execute(
|
||||||
|
ticker_service.active_only(select(Ticker).order_by(Ticker.symbol))
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
)
|
)
|
||||||
if not tickers:
|
if not tickers:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,498 +0,0 @@
|
|||||||
"""Read-only A5 comparison of legacy and SEC/Dolt fundamental inputs.
|
|
||||||
|
|
||||||
The report deliberately does not write ``fundamental_data`` or score tables.
|
|
||||||
It reconstructs the current legacy and candidate fundamental scores, projects
|
|
||||||
their composite-score/rank effect with the active weights, and archives a
|
|
||||||
timestamped JSON + CSV bundle for explicit human approval.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import statistics
|
|
||||||
from datetime import date, datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Iterable
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.data_import_run import DataImportRun
|
|
||||||
from app.models.fundamental import FundamentalData
|
|
||||||
from app.services import fundamentals_candidate_service as candidate_service
|
|
||||||
|
|
||||||
REPORT_VERSION = 1
|
|
||||||
APPROVAL_STATUS = "pending_explicit_approval"
|
|
||||||
FIELD_KEYS = ("pe_ratio", "revenue_growth", "earnings_surprise")
|
|
||||||
MIN_SCORE_METRICS = 2
|
|
||||||
|
|
||||||
# Materiality is a review aid, never an automatic cutover verdict. Definition
|
|
||||||
# changes remain visible even when a delta falls inside these bands.
|
|
||||||
FIELD_TOLERANCES = {
|
|
||||||
"pe_ratio": {"absolute": 1.0, "relative_pct": 10.0},
|
|
||||||
"revenue_growth": {"absolute": 2.0, "relative_pct": None},
|
|
||||||
"earnings_surprise": {"absolute": 2.0, "relative_pct": None},
|
|
||||||
}
|
|
||||||
DEFINITION_NOTES = {
|
|
||||||
"pe_ratio": (
|
|
||||||
"Legacy provider P/E convention versus latest close divided by "
|
|
||||||
"SEC-derived TTM diluted EPS."
|
|
||||||
),
|
|
||||||
"revenue_growth": (
|
|
||||||
"Legacy provider growth convention versus SEC-derived TTM revenue YoY."
|
|
||||||
),
|
|
||||||
"earnings_surprise": (
|
|
||||||
"Legacy provider latest surprise versus latest completed Dolt earnings "
|
|
||||||
"event with actual and estimate."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fundamental_score(
|
|
||||||
pe_ratio: float | None,
|
|
||||||
revenue_growth: float | None,
|
|
||||||
earnings_surprise: float | None,
|
|
||||||
) -> float | None:
|
|
||||||
"""Match the production fundamental-dimension formula without persistence."""
|
|
||||||
scores: list[float] = []
|
|
||||||
if _finite(pe_ratio) and pe_ratio > 0:
|
|
||||||
scores.append(max(0.0, min(100.0, 100.0 - (pe_ratio - 15.0) * (100.0 / 30.0))))
|
|
||||||
if _finite(revenue_growth):
|
|
||||||
scores.append(max(0.0, min(100.0, 50.0 + revenue_growth * 2.5)))
|
|
||||||
if _finite(earnings_surprise):
|
|
||||||
scores.append(max(0.0, min(100.0, 50.0 + earnings_surprise * 5.0)))
|
|
||||||
return sum(scores) / len(scores) if len(scores) >= MIN_SCORE_METRICS else None
|
|
||||||
|
|
||||||
|
|
||||||
async def build_report(
|
|
||||||
db: AsyncSession,
|
|
||||||
*,
|
|
||||||
generated_at: datetime | None = None,
|
|
||||||
today: date | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Build a point-in-time parity report from one database session."""
|
|
||||||
generated_at = generated_at or datetime.now(timezone.utc)
|
|
||||||
today = today or datetime.now(ZoneInfo("America/New_York")).date()
|
|
||||||
|
|
||||||
# A report must not mix rows from before and after a concurrent import
|
|
||||||
# promotion. The scheduled job provides a fresh session, so establish the
|
|
||||||
# production snapshot before its first query and have Postgres enforce the
|
|
||||||
# no-write contract as well. SQLite tests retain their normal transaction.
|
|
||||||
if db.get_bind().dialect.name == "postgresql":
|
|
||||||
connection = await db.connection(
|
|
||||||
execution_options={"isolation_level": "REPEATABLE READ"}
|
|
||||||
)
|
|
||||||
await connection.execute(text("SET TRANSACTION READ ONLY"))
|
|
||||||
|
|
||||||
candidates = await candidate_service.build_candidates(db, today=today)
|
|
||||||
ticker_ids = [candidate.ticker_id for candidate in candidates]
|
|
||||||
legacy_by_ticker = await _legacy_values(db, ticker_ids)
|
|
||||||
source_runs = await _source_runs(db)
|
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
legacy = legacy_by_ticker.get(candidate.ticker_id)
|
|
||||||
candidate_values = {
|
|
||||||
"pe_ratio": candidate.pe_ratio,
|
|
||||||
"revenue_growth": candidate.revenue_growth,
|
|
||||||
"earnings_surprise": candidate.earnings_surprise,
|
|
||||||
}
|
|
||||||
legacy_values = {
|
|
||||||
"pe_ratio": legacy.pe_ratio if legacy else None,
|
|
||||||
"revenue_growth": legacy.revenue_growth if legacy else None,
|
|
||||||
"earnings_surprise": legacy.earnings_surprise if legacy else None,
|
|
||||||
}
|
|
||||||
fields = {
|
|
||||||
key: _field_comparison(key, legacy_values[key], candidate_values[key])
|
|
||||||
for key in FIELD_KEYS
|
|
||||||
}
|
|
||||||
legacy_score = fundamental_score(**legacy_values)
|
|
||||||
candidate_score = fundamental_score(**candidate_values)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"symbol": candidate.symbol,
|
|
||||||
"cik": candidate.cik,
|
|
||||||
"legacy_fetched_at": _iso(legacy.fetched_at) if legacy else None,
|
|
||||||
"price_date": _iso(candidate.price_date),
|
|
||||||
"fields": fields,
|
|
||||||
"scores": {
|
|
||||||
"legacy_fundamental": _round(legacy_score),
|
|
||||||
"candidate_fundamental": _round(candidate_score),
|
|
||||||
"fundamental_delta": _delta(legacy_score, candidate_score),
|
|
||||||
"legacy_fundamental_rank": None,
|
|
||||||
"candidate_fundamental_rank": None,
|
|
||||||
"fundamental_rank_change": None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_attach_ranks(rows, "legacy_fundamental", "legacy_fundamental_rank")
|
|
||||||
_attach_ranks(rows, "candidate_fundamental", "candidate_fundamental_rank")
|
|
||||||
for row in rows:
|
|
||||||
scores = row["scores"]
|
|
||||||
scores["fundamental_rank_change"] = _rank_change(
|
|
||||||
scores["legacy_fundamental_rank"], scores["candidate_fundamental_rank"]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"report_version": REPORT_VERSION,
|
|
||||||
"generated_at": generated_at.isoformat(),
|
|
||||||
"as_of_date": today.isoformat(),
|
|
||||||
"approval_status": APPROVAL_STATUS,
|
|
||||||
"read_only": True,
|
|
||||||
"fundamental_score_formula": (
|
|
||||||
"Equal-weighted mean of 2+ available sub-scores: P/E = "
|
|
||||||
"clamp(100-(pe-15)*(100/30)); revenue growth = "
|
|
||||||
"clamp(50+growth*2.5); earnings surprise = "
|
|
||||||
"clamp(50+surprise*5)."
|
|
||||||
),
|
|
||||||
"source_runs": source_runs,
|
|
||||||
"definition_notes": DEFINITION_NOTES,
|
|
||||||
"materiality_notes": {
|
|
||||||
"fields": FIELD_TOLERANCES,
|
|
||||||
"fundamental_score_absolute": 5.0,
|
|
||||||
"automatic_cutover": False,
|
|
||||||
},
|
|
||||||
"summary": _summary(rows),
|
|
||||||
"rows": rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def store_report(report: dict[str, Any], report_dir: str | Path) -> dict[str, str]:
|
|
||||||
"""Atomically archive JSON/CSV artifacts and update the latest manifest."""
|
|
||||||
directory = Path(report_dir).expanduser().resolve()
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
stamp = _artifact_stamp(report["generated_at"])
|
|
||||||
json_name = f"fundamentals-parity-{stamp}.json"
|
|
||||||
csv_name = f"fundamentals-parity-{stamp}.csv"
|
|
||||||
json_path = directory / json_name
|
|
||||||
csv_path = directory / csv_name
|
|
||||||
|
|
||||||
_atomic_write(json_path, json.dumps(report, indent=2, sort_keys=True) + "\n")
|
|
||||||
_atomic_write(csv_path, report_csv(report))
|
|
||||||
manifest = {
|
|
||||||
"generated_at": report["generated_at"],
|
|
||||||
"json_file": json_name,
|
|
||||||
"csv_file": csv_name,
|
|
||||||
}
|
|
||||||
_atomic_write(
|
|
||||||
directory / "latest.json",
|
|
||||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"json": str(json_path),
|
|
||||||
"csv": str(csv_path),
|
|
||||||
"manifest": str(directory / "latest.json"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_and_store(
|
|
||||||
db: AsyncSession,
|
|
||||||
report_dir: str | Path,
|
|
||||||
*,
|
|
||||||
generated_at: datetime | None = None,
|
|
||||||
today: date | None = None,
|
|
||||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
||||||
report = await build_report(db, generated_at=generated_at, today=today)
|
|
||||||
return report, store_report(report, report_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def load_latest(report_dir: str | Path) -> dict[str, Any] | None:
|
|
||||||
manifest = _load_manifest(report_dir)
|
|
||||||
if manifest is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
path = _manifest_artifact(report_dir, manifest, "json_file")
|
|
||||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return loaded if isinstance(loaded, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def load_latest_csv(report_dir: str | Path) -> tuple[str, str] | None:
|
|
||||||
return _load_latest_text_artifact(report_dir, "csv_file")
|
|
||||||
|
|
||||||
|
|
||||||
def load_latest_json(report_dir: str | Path) -> tuple[str, str] | None:
|
|
||||||
return _load_latest_text_artifact(report_dir, "json_file")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_latest_text_artifact(
|
|
||||||
report_dir: str | Path, manifest_key: str
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
manifest = _load_manifest(report_dir)
|
|
||||||
if manifest is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
path = _manifest_artifact(report_dir, manifest, manifest_key)
|
|
||||||
return path.name, path.read_text(encoding="utf-8")
|
|
||||||
except (OSError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def report_csv(report: dict[str, Any]) -> str:
|
|
||||||
output = io.StringIO(newline="")
|
|
||||||
columns = [
|
|
||||||
"symbol",
|
|
||||||
"cik",
|
|
||||||
"legacy_fetched_at",
|
|
||||||
"price_date",
|
|
||||||
*(
|
|
||||||
f"{field}_{suffix}"
|
|
||||||
for field in FIELD_KEYS
|
|
||||||
for suffix in ("legacy", "candidate", "absolute_delta", "relative_delta_pct", "material")
|
|
||||||
),
|
|
||||||
"legacy_fundamental",
|
|
||||||
"candidate_fundamental",
|
|
||||||
"fundamental_delta",
|
|
||||||
"legacy_fundamental_rank",
|
|
||||||
"candidate_fundamental_rank",
|
|
||||||
"fundamental_rank_change",
|
|
||||||
]
|
|
||||||
writer = csv.DictWriter(output, fieldnames=columns)
|
|
||||||
writer.writeheader()
|
|
||||||
for row in report.get("rows", []):
|
|
||||||
flat = {
|
|
||||||
"symbol": row["symbol"],
|
|
||||||
"cik": row.get("cik"),
|
|
||||||
"legacy_fetched_at": row.get("legacy_fetched_at"),
|
|
||||||
"price_date": row.get("price_date"),
|
|
||||||
**row["scores"],
|
|
||||||
}
|
|
||||||
for field in FIELD_KEYS:
|
|
||||||
comparison = row["fields"][field]
|
|
||||||
for suffix in (
|
|
||||||
"legacy",
|
|
||||||
"candidate",
|
|
||||||
"absolute_delta",
|
|
||||||
"relative_delta_pct",
|
|
||||||
"material",
|
|
||||||
):
|
|
||||||
flat[f"{field}_{suffix}"] = comparison.get(suffix)
|
|
||||||
writer.writerow(flat)
|
|
||||||
return output.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
async def _legacy_values(
|
|
||||||
db: AsyncSession, ticker_ids: list[int]
|
|
||||||
) -> dict[int, FundamentalData]:
|
|
||||||
if not ticker_ids:
|
|
||||||
return {}
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(FundamentalData).where(FundamentalData.ticker_id.in_(ticker_ids))
|
|
||||||
)
|
|
||||||
).scalars()
|
|
||||||
return {row.ticker_id: row for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
async def _source_runs(db: AsyncSession) -> dict[str, dict[str, Any] | None]:
|
|
||||||
sources = ("sec_facts", "dolt_earnings")
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(DataImportRun)
|
|
||||||
.where(
|
|
||||||
DataImportRun.source.in_(sources),
|
|
||||||
DataImportRun.status.in_(("promoted", "no_op")),
|
|
||||||
)
|
|
||||||
.order_by(DataImportRun.id.desc())
|
|
||||||
)
|
|
||||||
).scalars()
|
|
||||||
latest: dict[str, dict[str, Any] | None] = {source: None for source in sources}
|
|
||||||
for row in rows:
|
|
||||||
if latest[row.source] is None:
|
|
||||||
latest[row.source] = {
|
|
||||||
"run_id": row.id,
|
|
||||||
"status": row.status,
|
|
||||||
"revision": row.revision,
|
|
||||||
"source_max_date": _iso(row.source_max_date),
|
|
||||||
"completed_at": _iso(row.completed_at),
|
|
||||||
}
|
|
||||||
return latest
|
|
||||||
|
|
||||||
|
|
||||||
def _field_comparison(
|
|
||||||
key: str, legacy: float | None, candidate: float | None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
legacy = float(legacy) if _finite(legacy) else None
|
|
||||||
candidate = float(candidate) if _finite(candidate) else None
|
|
||||||
absolute = _delta(legacy, candidate)
|
|
||||||
relative = (
|
|
||||||
None
|
|
||||||
if absolute is None or legacy in (None, 0)
|
|
||||||
else round(absolute / abs(legacy) * 100.0, 4)
|
|
||||||
)
|
|
||||||
tolerance = FIELD_TOLERANCES[key]
|
|
||||||
material = False
|
|
||||||
if absolute is not None:
|
|
||||||
material = abs(absolute) > tolerance["absolute"]
|
|
||||||
relative_limit = tolerance["relative_pct"]
|
|
||||||
if relative_limit is not None:
|
|
||||||
material = material and relative is not None and abs(relative) > relative_limit
|
|
||||||
return {
|
|
||||||
"legacy": _round(legacy),
|
|
||||||
"candidate": _round(candidate),
|
|
||||||
"absolute_delta": absolute,
|
|
||||||
"relative_delta_pct": relative,
|
|
||||||
"material": material,
|
|
||||||
"definition_changed": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_ranks(rows: list[dict[str, Any]], value_key: str, rank_key: str) -> None:
|
|
||||||
values = [
|
|
||||||
row["scores"][value_key]
|
|
||||||
for row in rows
|
|
||||||
if _finite(row["scores"][value_key])
|
|
||||||
]
|
|
||||||
for row in rows:
|
|
||||||
value = row["scores"][value_key]
|
|
||||||
row["scores"][rank_key] = (
|
|
||||||
1 + sum(other > value for other in values) if _finite(value) else None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
||||||
field_stats = {}
|
|
||||||
for key in FIELD_KEYS:
|
|
||||||
comparisons = [row["fields"][key] for row in rows]
|
|
||||||
deltas = [
|
|
||||||
abs(item["absolute_delta"])
|
|
||||||
for item in comparisons
|
|
||||||
if item["absolute_delta"] is not None
|
|
||||||
]
|
|
||||||
field_stats[key] = {
|
|
||||||
"legacy_available": sum(item["legacy"] is not None for item in comparisons),
|
|
||||||
"candidate_available": sum(
|
|
||||||
item["candidate"] is not None for item in comparisons
|
|
||||||
),
|
|
||||||
"both_available": len(deltas),
|
|
||||||
"material_differences": sum(item["material"] for item in comparisons),
|
|
||||||
"median_absolute_delta": _round(statistics.median(deltas) if deltas else None),
|
|
||||||
"p95_absolute_delta": _round(_percentile(deltas, 0.95)),
|
|
||||||
"max_absolute_delta": _round(max(deltas) if deltas else None),
|
|
||||||
}
|
|
||||||
|
|
||||||
fundamental_deltas = _score_deltas(rows, "fundamental_delta")
|
|
||||||
changed_rows = sorted(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
"symbol": row["symbol"],
|
|
||||||
"fundamental_delta": row["scores"]["fundamental_delta"],
|
|
||||||
"fundamental_rank_change": row["scores"]["fundamental_rank_change"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
if row["scores"]["fundamental_delta"] is not None
|
|
||||||
),
|
|
||||||
key=lambda item: (
|
|
||||||
abs(item["fundamental_delta"] or 0),
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
)[:20]
|
|
||||||
return {
|
|
||||||
"universe_count": len(rows),
|
|
||||||
"legacy_fundamental_score_available": _count_score(
|
|
||||||
rows, "legacy_fundamental"
|
|
||||||
),
|
|
||||||
"candidate_fundamental_score_available": _count_score(
|
|
||||||
rows, "candidate_fundamental"
|
|
||||||
),
|
|
||||||
"fundamental_scores_compared": len(fundamental_deltas),
|
|
||||||
"fundamental_score_material_changes": sum(
|
|
||||||
abs(delta) > 5.0 for delta in fundamental_deltas
|
|
||||||
),
|
|
||||||
"fundamental_rank_changes": _rank_change_count(
|
|
||||||
rows, "fundamental_rank_change"
|
|
||||||
),
|
|
||||||
"field_stats": field_stats,
|
|
||||||
"largest_changes": changed_rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _score_deltas(rows: Iterable[dict[str, Any]], key: str) -> list[float]:
|
|
||||||
return [
|
|
||||||
row["scores"][key]
|
|
||||||
for row in rows
|
|
||||||
if row["scores"][key] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _count_score(rows: Iterable[dict[str, Any]], key: str) -> int:
|
|
||||||
return sum(row["scores"][key] is not None for row in rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _rank_change_count(rows: Iterable[dict[str, Any]], key: str) -> int:
|
|
||||||
return sum(
|
|
||||||
row["scores"][key] not in (None, 0)
|
|
||||||
for row in rows
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rank_change(legacy: int | None, candidate: int | None) -> int | None:
|
|
||||||
# Positive means the candidate improved its rank.
|
|
||||||
return legacy - candidate if legacy is not None and candidate is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _delta(legacy: float | None, candidate: float | None) -> float | None:
|
|
||||||
if not _finite(legacy) or not _finite(candidate):
|
|
||||||
return None
|
|
||||||
return round(candidate - legacy, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def _round(value: float | None, digits: int = 4) -> float | None:
|
|
||||||
return round(float(value), digits) if _finite(value) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _percentile(values: list[float], quantile: float) -> float | None:
|
|
||||||
if not values:
|
|
||||||
return None
|
|
||||||
ordered = sorted(values)
|
|
||||||
index = max(0, math.ceil(quantile * len(ordered)) - 1)
|
|
||||||
return ordered[index]
|
|
||||||
|
|
||||||
|
|
||||||
def _finite(value: Any) -> bool:
|
|
||||||
return (
|
|
||||||
isinstance(value, (int, float))
|
|
||||||
and not isinstance(value, bool)
|
|
||||||
and math.isfinite(value)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(value: Any) -> str | None:
|
|
||||||
return value.isoformat() if value is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _artifact_stamp(raw: str) -> str:
|
|
||||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
||||||
return parsed.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, content: str) -> None:
|
|
||||||
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
||||||
temp.write_text(content, encoding="utf-8", newline="")
|
|
||||||
os.replace(temp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_manifest(report_dir: str | Path) -> dict[str, Any] | None:
|
|
||||||
path = Path(report_dir).expanduser().resolve() / "latest.json"
|
|
||||||
try:
|
|
||||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return loaded if isinstance(loaded, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _manifest_artifact(
|
|
||||||
report_dir: str | Path, manifest: dict[str, Any], key: str
|
|
||||||
) -> Path:
|
|
||||||
directory = Path(report_dir).expanduser().resolve()
|
|
||||||
name = Path(str(manifest.get(key, ""))).name
|
|
||||||
if not name:
|
|
||||||
raise ValueError(f"Latest parity manifest has no {key}")
|
|
||||||
return directory / name
|
|
||||||
@@ -12,7 +12,6 @@ from app.models.data_import_run import DataImportRun
|
|||||||
from app.models.fundamental_snapshot import FundamentalSnapshot
|
from app.models.fundamental_snapshot import FundamentalSnapshot
|
||||||
from app.models.sec_filing_gap import SecFilingGap
|
from app.models.sec_filing_gap import SecFilingGap
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services import fundamental_data_refresh_service
|
|
||||||
|
|
||||||
_SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
|
_SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
|
||||||
|
|
||||||
@@ -78,8 +77,6 @@ async def blocked_reasons_by_cik(
|
|||||||
ciks: set[str] | None = None,
|
ciks: set[str] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Current SEC blocker code by CIK; no historical audit scan."""
|
"""Current SEC blocker code by CIK; no historical audit scan."""
|
||||||
if not await fundamental_data_refresh_service.is_enabled(db):
|
|
||||||
return {}
|
|
||||||
if ciks is not None and not ciks:
|
if ciks is not None and not ciks:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
|
from app.services import ticker_service
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -169,7 +170,9 @@ async def compute_activation_ranks(db: AsyncSession) -> dict[str, dict[str, floa
|
|||||||
before scanning; the research backtest ranked each weekly setup-candidate
|
before scanning; the research backtest ranked each weekly setup-candidate
|
||||||
cross-section, so this is the deliberate production approximation.
|
cross-section, so this is the deliberate production approximation.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
result = await db.execute(
|
||||||
|
ticker_service.active_only(select(Ticker).order_by(Ticker.symbol))
|
||||||
|
)
|
||||||
tickers = list(result.scalars().all())
|
tickers = list(result.scalars().all())
|
||||||
|
|
||||||
benchmark_closes = await _load_activation_benchmark(db)
|
benchmark_closes = await _load_activation_benchmark(db)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""AI/Tech Regime Monitor v3.
|
"""AI/Tech Risk Monitor v4.
|
||||||
|
|
||||||
The monitor is a risk thermometer, not a probability or trading rule. It keeps
|
The monitor is a risk thermometer, not a probability or trading rule. It keeps
|
||||||
two deliberately separate outputs:
|
two deliberately separate outputs:
|
||||||
@@ -8,13 +8,13 @@ two deliberately separate outputs:
|
|||||||
relative strength, credit impulse).
|
relative strength, credit impulse).
|
||||||
|
|
||||||
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
||||||
earnings-reaction observations are a qualitative *overlay* in v3 rather than
|
earnings-reaction observations are a qualitative *overlay* since v3 rather than
|
||||||
weighted sensors: at a combined 20 points they could not reach the event
|
weighted sensors: at a combined 20 points they could not reach the event
|
||||||
study's alarm threshold even when both pegged, so refreshing them appeared to
|
study's alarm threshold even when both pegged, so refreshing them appeared to
|
||||||
do nothing. They are reported next to the scores instead of inside them.
|
do nothing. They are reported next to the scores instead of inside them.
|
||||||
|
|
||||||
Daily snapshots are the point-in-time record. The first run under a new
|
Daily snapshots are the point-in-time record. The first run under a new
|
||||||
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
|
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
|
||||||
ordinary runs thereafter only upsert the latest trading date. The overlay is
|
ordinary runs thereafter only upsert the latest trading date. The overlay is
|
||||||
still gated by its effective date so a rebuild cannot stamp today's observation
|
still gated by its effective date so a rebuild cannot stamp today's observation
|
||||||
onto historical snapshots.
|
onto historical snapshots.
|
||||||
@@ -48,11 +48,26 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
|||||||
KEY_CONFIG = "regime_monitor_config"
|
KEY_CONFIG = "regime_monitor_config"
|
||||||
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
|
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
|
||||||
|
|
||||||
METHODOLOGY = "v3"
|
METHODOLOGY = "v4"
|
||||||
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
||||||
# collected by hand/LLM and carried across it when the format is compatible.
|
# collected by hand/LLM and carried across it when the format is compatible.
|
||||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
# EVERY methodology sharing the categorical format must be listed: this is checked
|
||||||
REBUILD_SESSIONS = 400
|
# against the *stored* blob, so omitting the current one discards the observation
|
||||||
|
# on its first write, which leaves fetched_at null and locked false -- and then
|
||||||
|
# update_regime_monitor refreshes it via the LLM on every single run, forever.
|
||||||
|
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4"})
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# Deliberately NOT bumped for v4: a METHODOLOGY change already forces a full
|
||||||
|
# reseed (every stored row fails _parse_snapshot, so _latest_snapshot_row returns
|
||||||
|
# None and rebuilding is True). Bumping both would imply the reseed was
|
||||||
|
# revision-driven.
|
||||||
|
SENSOR_REVISION = 2
|
||||||
MIN_COVERAGE = 75.0
|
MIN_COVERAGE = 75.0
|
||||||
SOURCE_MAX_LAG_DAYS = 7
|
SOURCE_MAX_LAG_DAYS = 7
|
||||||
|
|
||||||
@@ -61,9 +76,18 @@ SOURCE_MAX_LAG_DAYS = 7
|
|||||||
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
|
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
|
||||||
# numbers chosen so each band covers a sane share of history, not percentile
|
# numbers chosen so each band covers a sane share of history, not percentile
|
||||||
# fits -- percentile-derived bands would drift on every rebuild and silently
|
# fits -- percentile-derived bands would drift on every rebuild and silently
|
||||||
# rewrite what past snapshots meant. Realized shares over the 408 sessions to
|
# rewrite what past snapshots meant.
|
||||||
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%.
|
#
|
||||||
STATE_BANDS = (20.0, 50.0, 80.0)
|
# v4 moved State's top band 80 -> 65, and only that one. With credit calm it
|
||||||
|
# scores 0.0 (not None) and still holds its full 20 points, so price + breadth +
|
||||||
|
# volatility at *literal maximum* summed to exactly 80.0 -- the old threshold, to
|
||||||
|
# the decimal, with nothing to spare. A 2022-style AI/tech drawdown with calm
|
||||||
|
# credit computes to 70.3-74.0 depending on whether a death cross has formed, so
|
||||||
|
# at 80 the case this monitor exists to measure could not print the top band.
|
||||||
|
# 65 clears it under either assumption. Realized shares over the 408 sessions to
|
||||||
|
# 2026-07-24, reported not fitted: State 78.9/13.0/4.7/3.4%, Warning 69/20/8/3%.
|
||||||
|
# The v4 breaking share (3.4%) matches v3's, which was arrived at independently.
|
||||||
|
STATE_BANDS = (20.0, 50.0, 65.0)
|
||||||
WARNING_BANDS = (20.0, 40.0, 60.0)
|
WARNING_BANDS = (20.0, 40.0, 60.0)
|
||||||
|
|
||||||
QUADRANT_STATE_DIVIDER = 50.0
|
QUADRANT_STATE_DIVIDER = 50.0
|
||||||
@@ -81,7 +105,24 @@ HY_OAS_STRESSED = 7.0
|
|||||||
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
|
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
|
||||||
# encode the long-run distribution, so the credit *level* is now purely anchored
|
# encode the long-run distribution, so the credit *level* is now purely anchored
|
||||||
# and credit *dynamics* live in W3 on the Warning axis where they belong.
|
# and credit *dynamics* live in W3 on the Warning axis where they belong.
|
||||||
HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now
|
# Calendar days, and it must cover the oldest date a rebuild replays -- not just
|
||||||
|
# W3's lookback. REBUILD_SESSIONS is 400 *trading* sessions (~579 calendar
|
||||||
|
# days), so a 400-calendar-day fetch left the oldest ~180 days of a rebuild with
|
||||||
|
# no OAS at all: C1 and W3 both returned None, State landed at 80% coverage and
|
||||||
|
# Warning at exactly MIN_COVERAGE, and *both still published bands* -- a series
|
||||||
|
# that looks homogeneous while its oldest rows were scored without credit.
|
||||||
|
# Widening only prepends older observations; C1 reads [-1] and W3 reads [-21], so
|
||||||
|
# live scores are unchanged and this needs no methodology bump. Stays under
|
||||||
|
# ICE's ~3-year cap so FRED still honours the request.
|
||||||
|
HY_OAS_WINDOW_DAYS = 700
|
||||||
|
|
||||||
|
# A rebuild replays every session inside this window. Bounded by calendar days
|
||||||
|
# rather than a session count because the binding constraint is the OAS fetch:
|
||||||
|
# each replayed row needs W3's 20-business-day lookback (~28 calendar days)
|
||||||
|
# inside HY_OAS_WINDOW_DAYS, so replaying further back would recreate the exact
|
||||||
|
# credit gap a reseed exists to close. 672 days is ~464 trading sessions, which
|
||||||
|
# comfortably covers the 400-session series the v3 cutover wrote.
|
||||||
|
REBUILD_LOOKBACK_DAYS = HY_OAS_WINDOW_DAYS - 28
|
||||||
W3_OAS_LOOKBACK = 20
|
W3_OAS_LOOKBACK = 20
|
||||||
W3_OAS_FULL_SCALE_PCT = 35.0
|
W3_OAS_FULL_SCALE_PCT = 35.0
|
||||||
|
|
||||||
@@ -94,6 +135,27 @@ P3_DRAWDOWN_ANCHORS = (
|
|||||||
(0.0, 0.0), (4.0, 10.0), (8.0, 25.0), (16.0, 50.0), (28.0, 78.0), (40.0, 100.0),
|
(0.0, 0.0), (4.0, 10.0), (8.0, 25.0), (16.0, 50.0), (28.0, 78.0), (40.0, 100.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Trend-break depth (% below the 200-DMA, stress score). v4; see _under_200 for
|
||||||
|
# why the crossing gets a floor of 20 rather than starting at 0. Calibrated to
|
||||||
|
# sit alongside P3 rather than swamp it -- the 200-DMA lags, so a 20% drawdown
|
||||||
|
# typically coincides with ~10% below the average, where this reads ~61 against
|
||||||
|
# P3's ~59. On the population the P1_SCORE_CAP rule actually names -- sessions
|
||||||
|
# with State >= 40 -- P1 is the sole price argmax on 17 of 47 (36.2%), against
|
||||||
|
# P2's 16 and P3's 14, so it informs the pillar without owning it and no cap
|
||||||
|
# was needed.
|
||||||
|
P1_TREND_BREAK_ANCHORS = (
|
||||||
|
(0.0, 20.0), (3.0, 35.0), (8.0, 55.0), (15.0, 75.0), (25.0, 100.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# VIX level anchors (v4). Full scale at 55 rather than at 2020's ~82: anchoring
|
||||||
|
# the top at a once-in-a-generation print would make VIX 50 -- a genuine crisis
|
||||||
|
# -- read only ~70. A typical correction (25-35) now reads 38-67 where v3 read
|
||||||
|
# 66.7-100. The anchors encode the long-run distribution as constants, the same
|
||||||
|
# argument the credit level uses.
|
||||||
|
P5_VIX_ANCHORS = (
|
||||||
|
(15.0, 0.0), (20.0, 20.0), (25.0, 38.0), (30.0, 55.0), (40.0, 80.0), (55.0, 100.0),
|
||||||
|
)
|
||||||
|
|
||||||
STATE_WEIGHTS = {
|
STATE_WEIGHTS = {
|
||||||
"price": 40.0,
|
"price": 40.0,
|
||||||
"breadth": 25.0,
|
"breadth": 25.0,
|
||||||
@@ -195,10 +257,24 @@ def band_for(score: float, bands: tuple[float, float, float] = STATE_BANDS) -> s
|
|||||||
|
|
||||||
|
|
||||||
def _under_200(closes: list[float]) -> float | None:
|
def _under_200(closes: list[float]) -> float | None:
|
||||||
|
"""Trend break graded by depth below the 200-DMA, not a bare yes/no.
|
||||||
|
|
||||||
|
Through v3 this returned 0 or 100, so P1 printed 100 the moment SMH and QQQ
|
||||||
|
were both under their average -- and because the price pillar takes
|
||||||
|
``max(P1, P2, P3)``, that pinned the pillar and stopped P3's anchored ladder
|
||||||
|
resolving anything for the whole of a selloff. It pegged on 46 of the 408
|
||||||
|
sessions to 2026-07-24; under this table, none.
|
||||||
|
|
||||||
|
The step at the crossing (0 -> 20) is deliberate: the break itself is a
|
||||||
|
genuine binary event and deserves a floor. Only the depth past it is graded.
|
||||||
|
"""
|
||||||
sma200 = _sma(closes, 200)
|
sma200 = _sma(closes, 200)
|
||||||
if sma200 is None:
|
if sma200 is None or sma200 <= 0:
|
||||||
return None
|
return None
|
||||||
return 100.0 if closes[-1] < sma200 else 0.0
|
pct_below = (sma200 - closes[-1]) / sma200 * 100.0
|
||||||
|
if pct_below <= 0:
|
||||||
|
return 0.0
|
||||||
|
return _clamp(_interpolate(pct_below, P1_TREND_BREAK_ANCHORS))
|
||||||
|
|
||||||
|
|
||||||
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
|
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
|
||||||
@@ -264,9 +340,17 @@ def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60)
|
|||||||
|
|
||||||
|
|
||||||
def p5_volatility(vix: float | None) -> float | None:
|
def p5_volatility(vix: float | None) -> float | None:
|
||||||
|
"""VIX level against named anchors, so it keeps resolving past a 30 print.
|
||||||
|
|
||||||
|
v3 used ``(vix - 15) / 15``, which reached 100 at VIX 30 -- the same
|
||||||
|
saturation v3 itself had just removed from P3. VIX 30 is a bad week, 50 is a
|
||||||
|
crisis and 82 was March 2020, and all three scored identically. In the 408
|
||||||
|
sessions to 2026-07-24 that flattened five distinct April-2025 prints
|
||||||
|
(52.33, 46.98, 45.31, 40.72, 38.57) into a single 100.
|
||||||
|
"""
|
||||||
if vix is None:
|
if vix is None:
|
||||||
return None
|
return None
|
||||||
return _clamp((vix - 15.0) / 15.0 * 100.0)
|
return _clamp(_interpolate(vix, P5_VIX_ANCHORS))
|
||||||
|
|
||||||
|
|
||||||
def breadth_level_score(pct_above_200: float | None) -> float | None:
|
def breadth_level_score(pct_above_200: float | None) -> float | None:
|
||||||
@@ -477,17 +561,29 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
|
|||||||
return _next_weekday(fetched) if fetched else None
|
return _next_weekday(fetched) if fetched else None
|
||||||
|
|
||||||
|
|
||||||
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
def _overlay_timing(
|
||||||
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
|
overrides: dict, config: dict, as_of: date
|
||||||
|
) -> tuple[date | None, bool, int | None, bool]:
|
||||||
The effective-date gate stays even though nothing is scored from this: the
|
"""Shared effective-date arithmetic: (effective, pending, age_days, stale)."""
|
||||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
|
||||||
onto 2024 snapshots would be plain lookahead in the stored record.
|
|
||||||
"""
|
|
||||||
effective = _fundamental_effective_date(overrides)
|
effective = _fundamental_effective_date(overrides)
|
||||||
pending = effective is None or as_of < effective
|
pending = effective is None or as_of < effective
|
||||||
age = None if pending else (as_of - effective).days
|
age = None if pending else (as_of - effective).days
|
||||||
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
|
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 since 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, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||||
return {
|
return {
|
||||||
"available": not pending and not stale,
|
"available": not pending and not stale,
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
@@ -504,6 +600,43 @@ def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
|
"""The observation as it stands now, for the live reading only.
|
||||||
|
|
||||||
|
Same shape as ``fundamental_overlay``, but the effective date is *reported*
|
||||||
|
rather than used to blank the content. A refresh stamps
|
||||||
|
``_next_weekday(today)``, so gating the live card hid a just-collected read
|
||||||
|
for one day -- three over a weekend -- and refreshing appeared to do
|
||||||
|
nothing. Nothing here is scored, so showing it early cannot leak into a
|
||||||
|
published number; the stored snapshot keeps the gate.
|
||||||
|
"""
|
||||||
|
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||||
|
# The default override carries "unknown"/"mixed" placeholders for every
|
||||||
|
# hyperscaler. Those are the absence of an observation, not an observation
|
||||||
|
# of absence, and must never be presented as collected. ``fetched_at`` is
|
||||||
|
# the collection timestamp and is the only field written on every path that
|
||||||
|
# produces real content (LLM refresh and manual save both stamp it).
|
||||||
|
observed = bool(overrides.get("fetched_at"))
|
||||||
|
return {
|
||||||
|
"observed": observed,
|
||||||
|
# Live availability is about usefulness, not effectiveness: a pending
|
||||||
|
# observation is the freshest thing we have -- but nothing collected is
|
||||||
|
# never available.
|
||||||
|
"available": observed and not stale,
|
||||||
|
"pending": pending,
|
||||||
|
"stale": stale,
|
||||||
|
"effective_date": effective.isoformat() if effective else None,
|
||||||
|
"age_days": age,
|
||||||
|
"capex": overrides.get("capex") if observed else None,
|
||||||
|
"good_news_stock_down": overrides.get("good_news_stock_down") if observed else None,
|
||||||
|
"capex_stress": overrides.get("f1_score") if observed else None,
|
||||||
|
"earnings_stress": overrides.get("f3_score") if observed else None,
|
||||||
|
"reasoning": overrides.get("reasoning") if observed else None,
|
||||||
|
"source": overrides.get("source"),
|
||||||
|
"fetched_at": overrides.get("fetched_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _basket_hash(symbols: list[str]) -> str:
|
def _basket_hash(symbols: list[str]) -> str:
|
||||||
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
|
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
|
||||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||||
@@ -524,7 +657,7 @@ def _compute_index(
|
|||||||
divergence_series: Series | None = None,
|
divergence_series: Series | None = None,
|
||||||
breadth_counts: dict[date, int] | None = None,
|
breadth_counts: dict[date, int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Compute the complete v2 State/Warning snapshot as of one trading date."""
|
"""Compute the complete State/Warning snapshot as of one trading date."""
|
||||||
tickers = config["tickers"]
|
tickers = config["tickers"]
|
||||||
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
||||||
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
||||||
@@ -630,6 +763,8 @@ def _compute_index(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"methodology": METHODOLOGY,
|
"methodology": METHODOLOGY,
|
||||||
|
# Not part of the history filter -- only the reseed trigger.
|
||||||
|
"sensor_revision": SENSOR_REVISION,
|
||||||
"date": as_of.isoformat(),
|
"date": as_of.isoformat(),
|
||||||
"state": state,
|
"state": state,
|
||||||
"warning": warning,
|
"warning": warning,
|
||||||
@@ -697,7 +832,7 @@ async def get_regime_config(db: AsyncSession) -> dict:
|
|||||||
if stored.get("fundamental_staleness_days") is not None:
|
if stored.get("fundamental_staleness_days") is not None:
|
||||||
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
|
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
|
||||||
except (TypeError, ValueError, ValidationError):
|
except (TypeError, ValueError, ValidationError):
|
||||||
logger.warning("Corrupt %s; using v2 defaults", KEY_CONFIG)
|
logger.warning("Corrupt %s; using defaults", KEY_CONFIG)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -843,7 +978,7 @@ async def _fetch_prices(config: dict, start: date, end: date) -> dict[str, Serie
|
|||||||
bars = await provider.fetch_ohlcv(symbol, start, end)
|
bars = await provider.fetch_ohlcv(symbol, start, end)
|
||||||
out[symbol] = sorted(((b.date, float(b.close)) for b in bars), key=lambda item: item[0])
|
out[symbol] = sorted(((b.date, float(b.close)) for b in bars), key=lambda item: item[0])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Regime monitor: price fetch failed for %s: %s", symbol, exc)
|
logger.warning("Risk monitor: price fetch failed for %s: %s", symbol, exc)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -866,7 +1001,7 @@ async def _fetch_fred_series(series_id: str, start: date, end: date) -> Series |
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Regime monitor: FRED fetch failed for %s: %s", series_id, exc)
|
logger.warning("Risk monitor: FRED fetch failed for %s: %s", series_id, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
out: Series = []
|
out: Series = []
|
||||||
@@ -889,7 +1024,7 @@ async def _upsert_snapshot(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
result: dict,
|
result: dict,
|
||||||
*,
|
*,
|
||||||
rewrite_existing_v2: bool,
|
rewrite_existing: bool,
|
||||||
) -> tuple[bool, dict]:
|
) -> tuple[bool, dict]:
|
||||||
snapshot_date = date.fromisoformat(result["date"])
|
snapshot_date = date.fromisoformat(result["date"])
|
||||||
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date))
|
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date))
|
||||||
@@ -906,15 +1041,23 @@ async def _upsert_snapshot(
|
|||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
existing_v2 = _parse_snapshot(row.breakdown_json)
|
existing_parsed = _parse_snapshot(row.breakdown_json)
|
||||||
if existing_v2 is not None and not rewrite_existing_v2:
|
if existing_parsed is not None and not rewrite_existing:
|
||||||
return False, existing_v2
|
return False, existing_parsed
|
||||||
row.total_score = float(state_score or 0.0)
|
row.total_score = float(state_score or 0.0)
|
||||||
row.band = state_band or "unavailable"
|
row.band = state_band or "unavailable"
|
||||||
row.breakdown_json = payload
|
row.breakdown_json = payload
|
||||||
return True, result
|
return True, result
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_revision(snapshot: dict) -> int:
|
||||||
|
"""Sensor revision of a stored snapshot; pre-marker rows read as 1."""
|
||||||
|
try:
|
||||||
|
return int(snapshot.get("sensor_revision") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _parse_snapshot(raw: str) -> dict | None:
|
def _parse_snapshot(raw: str) -> dict | None:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
@@ -934,14 +1077,16 @@ async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict]
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUILD_SESSIONS) -> dict:
|
async def update_regime_monitor(
|
||||||
|
db: AsyncSession, rebuild_lookback_days: int = REBUILD_LOOKBACK_DAYS
|
||||||
|
) -> dict:
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(db)
|
overrides = await get_fundamental_overrides(db)
|
||||||
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
||||||
try:
|
try:
|
||||||
overrides = await refresh_fundamental_overrides(db, config=config)
|
overrides = await refresh_fundamental_overrides(db, config=config)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Regime monitor: fundamentals refresh skipped: %s", exc)
|
logger.warning("Risk monitor: fundamentals refresh skipped: %s", exc)
|
||||||
|
|
||||||
end = date.today()
|
end = date.today()
|
||||||
prices = await _fetch_prices(config, end - timedelta(days=1200), end)
|
prices = await _fetch_prices(config, end - timedelta(days=1200), end)
|
||||||
@@ -965,13 +1110,21 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
|||||||
)
|
)
|
||||||
divergence = breadth_service.compute_divergence_series(breadth, leader_series)
|
divergence = breadth_service.compute_divergence_series(breadth, leader_series)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc)
|
logger.warning("Risk monitor: fixed-basket breadth skipped: %s", exc)
|
||||||
breadth, breadth_counts, divergence = {}, {}, {}
|
breadth, breadth_counts, divergence = {}, {}, {}
|
||||||
|
|
||||||
latest_v2 = await _latest_snapshot_row(db)
|
latest_snapshot = await _latest_snapshot_row(db)
|
||||||
rebuilding = latest_v2 is None and bool(leader_series)
|
# A stored series written under an older sensor revision is reseeded once.
|
||||||
|
# Without this, raising HY_OAS_WINDOW_DAYS would only ever reach newly
|
||||||
|
# computed rows: routine runs touch the latest date alone, so every older row
|
||||||
|
# would keep the credit gap indefinitely.
|
||||||
|
rebuilding = bool(leader_series) and (
|
||||||
|
latest_snapshot is None
|
||||||
|
or _snapshot_revision(latest_snapshot[1]) < SENSOR_REVISION
|
||||||
|
)
|
||||||
if rebuilding:
|
if rebuilding:
|
||||||
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]]
|
floor = end - timedelta(days=rebuild_lookback_days)
|
||||||
|
dates = [d for d, _ in leader_series if d >= floor] or [latest_date]
|
||||||
else:
|
else:
|
||||||
# Routine PIT rule: only the latest trading date may be inserted/updated.
|
# Routine PIT rule: only the latest trading date may be inserted/updated.
|
||||||
dates = [latest_date]
|
dates = [latest_date]
|
||||||
@@ -995,7 +1148,9 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
|||||||
written, latest_result = await _upsert_snapshot(
|
written, latest_result = await _upsert_snapshot(
|
||||||
db,
|
db,
|
||||||
computed,
|
computed,
|
||||||
rewrite_existing_v2=rebuilding or snapshot_date == latest_date,
|
# True for *every* replayed date on a reseed, or it would write one
|
||||||
|
# row and leave the rest at the old revision.
|
||||||
|
rewrite_existing=rebuilding or snapshot_date == latest_date,
|
||||||
)
|
)
|
||||||
snapshots_written += int(written)
|
snapshots_written += int(written)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -1042,7 +1197,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
|
|||||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||||
latest = await _latest_snapshot_row(db)
|
latest = await _latest_snapshot_row(db)
|
||||||
if latest is None:
|
if latest is None:
|
||||||
return {"available": False, "reason": "v2 not computed yet"}
|
return {"available": False, "reason": "not computed yet"}
|
||||||
row, result = latest
|
row, result = latest
|
||||||
basket_hash = (result.get("basket") or {}).get("hash")
|
basket_hash = (result.get("basket") or {}).get("hash")
|
||||||
previous_7 = await _result_at_or_before(
|
previous_7 = await _result_at_or_before(
|
||||||
@@ -1071,7 +1226,9 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
|||||||
# session, because otherwise refreshing it looks like it did nothing.
|
# session, because otherwise refreshing it looks like it did nothing.
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(db)
|
overrides = await get_fundamental_overrides(db)
|
||||||
live = fundamental_overlay(overrides, config, date.today())
|
live = current_observation(overrides, config, date.today())
|
||||||
|
# Deliberately reads the *snapshot's* overlay, not the live one: this is how
|
||||||
|
# the reader tells "shown here" from "in the stored record".
|
||||||
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
|
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
|
||||||
result["fundamental_context"] = live
|
result["fundamental_context"] = live
|
||||||
result["available"] = True
|
result["available"] = True
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from app.services import fundamentals_quality_service, system_event_service
|
|||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
from app.services.qualification import setup_qualifies
|
from app.services.qualification import setup_qualifies
|
||||||
from app.services.sr_service import detect_gate_target_ladder
|
from app.services.sr_service import detect_gate_target_ladder
|
||||||
from app.services import settings_store
|
from app.services import settings_store, ticker_service
|
||||||
from app.services.trade_policy import (
|
from app.services.trade_policy import (
|
||||||
MANUAL_BOOK,
|
MANUAL_BOOK,
|
||||||
SHADOW_BOOK,
|
SHADOW_BOOK,
|
||||||
@@ -735,7 +735,11 @@ async def scan_all_tickers(
|
|||||||
# Plain ids/strings, not Ticker instances: the rollbacks below expire any
|
# Plain ids/strings, not Ticker instances: the rollbacks below expire any
|
||||||
# ORM objects held across them, and touching an expired attribute afterwards
|
# ORM objects held across them, and touching an expired attribute afterwards
|
||||||
# triggers sync lazy-loading, which raises on an AsyncSession.
|
# triggers sync lazy-loading, which raises on an AsyncSession.
|
||||||
result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol))
|
result = await db.execute(
|
||||||
|
ticker_service.active_only(
|
||||||
|
select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol)
|
||||||
|
)
|
||||||
|
)
|
||||||
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
|
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
|
||||||
total = len(ticker_rows)
|
total = len(ticker_rows)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.database import insert_for_session
|
|||||||
from app.exceptions import NotFoundError, ValidationError
|
from app.exceptions import NotFoundError, ValidationError
|
||||||
from app.models.score import CompositeScore, DimensionScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services import settings_store
|
from app.services import settings_store, ticker_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -497,8 +497,8 @@ async def _compute_fundamental_score(
|
|||||||
"reason": "Earnings surprise data not available",
|
"reason": "Earnings surprise data not available",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Require at least two real metrics — a single available metric (e.g. only
|
# Require at least two real metrics — a single available metric (e.g. an
|
||||||
# market cap is free on FMP) does not make a meaningful fundamental score.
|
# issuer with only a market cap) does not make a meaningful fundamental score.
|
||||||
MIN_METRICS = 2
|
MIN_METRICS = 2
|
||||||
if len(scores) < MIN_METRICS:
|
if len(scores) < MIN_METRICS:
|
||||||
unavailable.append({
|
unavailable.append({
|
||||||
@@ -883,7 +883,11 @@ async def get_rankings(db: AsyncSession) -> dict:
|
|||||||
Returns dict suitable for RankingResponse.
|
Returns dict suitable for RankingResponse.
|
||||||
"""
|
"""
|
||||||
weights = await _get_weights(db)
|
weights = await _get_weights(db)
|
||||||
tickers = (await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars().all()
|
tickers = (
|
||||||
|
await db.execute(
|
||||||
|
ticker_service.active_only(select(Ticker).order_by(Ticker.symbol))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
async def _load_scores() -> tuple[dict[int, CompositeScore], dict[int, dict[str, DimensionScore]]]:
|
async def _load_scores() -> tuple[dict[int, CompositeScore], dict[int, dict[str, DimensionScore]]]:
|
||||||
comps = {
|
comps = {
|
||||||
@@ -947,7 +951,7 @@ async def update_weights(
|
|||||||
await _save_weights(db, full_weights)
|
await _save_weights(db, full_weights)
|
||||||
|
|
||||||
# Recompute all composite scores
|
# Recompute all composite scores
|
||||||
result = await db.execute(select(Ticker))
|
result = await db.execute(ticker_service.active_only(select(Ticker)))
|
||||||
tickers = list(result.scalars().all())
|
tickers = list(result.scalars().all())
|
||||||
|
|
||||||
for ticker in tickers:
|
for ticker in tickers:
|
||||||
|
|||||||
+113
-1
@@ -39,12 +39,39 @@ logger = logging.getLogger(__name__)
|
|||||||
_WWW = "https://www.sec.gov"
|
_WWW = "https://www.sec.gov"
|
||||||
_DATA = "https://data.sec.gov"
|
_DATA = "https://data.sec.gov"
|
||||||
|
|
||||||
# Resolve CA bundle for explicit httpx verify (matches app/providers/fmp.py).
|
# Resolve CA bundle for explicit httpx verify (matches app/providers/alpaca.py).
|
||||||
_CA = os.environ.get("SSL_CERT_FILE", "")
|
_CA = os.environ.get("SSL_CERT_FILE", "")
|
||||||
_CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
|
_CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
|
||||||
|
|
||||||
_FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
|
_FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
|
||||||
|
|
||||||
|
# Notification of removal from listing. "25" is issuer-filed, "25-NSE" exchange-
|
||||||
|
# filed. The Form 15 family is deliberately absent: it ends a *reporting*
|
||||||
|
# obligation and does not mean the security stopped trading.
|
||||||
|
_DELISTING_FORMS = frozenset({"25", "25-NSE"})
|
||||||
|
|
||||||
|
# ``descriptionClassSecurity`` is free text ("Common Stock", "Class A Common
|
||||||
|
# Stock, $0.01 par value", "6.25% Notes due 2030", "Warrants", "Depositary
|
||||||
|
# Shares"). Only a common-equity class means the ticker itself stopped trading.
|
||||||
|
_NON_COMMON_CLASS = re.compile(
|
||||||
|
r"\b(note|bond|debenture|preferred|warrant|right|unit|depositary|"
|
||||||
|
r"subordinated|debt|trust)s?\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_common_stock(description: str) -> bool:
|
||||||
|
"""Does this Form 25 security class describe common equity?
|
||||||
|
|
||||||
|
Requires an explicit common-stock match AND no debt/preferred/warrant marker,
|
||||||
|
so "Depositary Shares each representing 1/1000th of Preferred" cannot pass on
|
||||||
|
the word "shares" alone. Unrecognised text is rejected — a symbol is retired
|
||||||
|
on this answer, so ambiguity must not read as yes.
|
||||||
|
"""
|
||||||
|
if _NON_COMMON_CLASS.search(description):
|
||||||
|
return False
|
||||||
|
return re.search(r"\bcommon\s+(stock|share)", description, re.IGNORECASE) is not None
|
||||||
|
|
||||||
|
|
||||||
class SecError(ProviderError):
|
class SecError(ProviderError):
|
||||||
"""SEC request failed (403, exhausted 429/5xx, timeout, transport, parse)."""
|
"""SEC request failed (403, exhausted 429/5xx, timeout, transport, parse)."""
|
||||||
@@ -253,6 +280,91 @@ class SecClient:
|
|||||||
"filings": filings,
|
"filings": filings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def delisting_filing(
|
||||||
|
self, cik: int | str, *, not_before: date | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Newest Form 25 removing this issuer's COMMON stock from listing.
|
||||||
|
|
||||||
|
Deliberately narrow, because the caller retires a symbol on the answer:
|
||||||
|
|
||||||
|
- **Form 25 only.** The Form 15 family terminates a reporting obligation
|
||||||
|
(often just a class falling under the holder threshold) and is no
|
||||||
|
evidence that trading stopped.
|
||||||
|
- **Class-checked.** Form 25 is filed per security class — an issuer
|
||||||
|
delisting its notes, preferred, warrants or an ADR class while the
|
||||||
|
common keeps trading files one too. The filing's own
|
||||||
|
``descriptionClassSecurity`` is what separates those, so the primary
|
||||||
|
document is fetched and read rather than trusting the form type.
|
||||||
|
- **``not_before``** rejects a historical filing for some long-gone
|
||||||
|
class. Without it a 2019 Form 25 would retire a symbol whose bars
|
||||||
|
stopped in 2026, and stamp 2019 as the date.
|
||||||
|
|
||||||
|
Anything unreadable — no primary document (pre-2009 filings have none),
|
||||||
|
malformed XML, unrecognised class — returns ``None``. Fail closed: the
|
||||||
|
caller keeps warning instead of retiring on a guess.
|
||||||
|
|
||||||
|
Reads ``filings.recent`` directly; ``submissions()`` keeps only the
|
||||||
|
10-K/10-Q family, so Form 25 never survives its parser.
|
||||||
|
"""
|
||||||
|
base = await self.get_json(f"{_DATA}/submissions/CIK{cik10(cik)}.json")
|
||||||
|
arrays = (base.get("filings") or {}).get("recent") or {}
|
||||||
|
forms = arrays.get("form") or []
|
||||||
|
dates = arrays.get("filingDate") or []
|
||||||
|
accessions = arrays.get("accessionNumber") or []
|
||||||
|
docs = arrays.get("primaryDocument") or []
|
||||||
|
|
||||||
|
candidates: list[tuple[date, str, str, str]] = []
|
||||||
|
for i, form in enumerate(forms):
|
||||||
|
if form not in _DELISTING_FORMS or i >= len(dates) or not dates[i]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
filed = date.fromisoformat(dates[i])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not_before is not None and filed < not_before:
|
||||||
|
continue
|
||||||
|
if i >= len(accessions) or not accessions[i]:
|
||||||
|
continue
|
||||||
|
candidates.append((filed, form, accessions[i], docs[i] if i < len(docs) else ""))
|
||||||
|
|
||||||
|
for filed, form, accession, _doc in sorted(candidates, reverse=True):
|
||||||
|
security = await self._form25_security_class(cik, accession)
|
||||||
|
if security is None:
|
||||||
|
continue
|
||||||
|
if not _is_common_stock(security):
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"form": form,
|
||||||
|
"filing_date": filed,
|
||||||
|
"security_class": security,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _form25_security_class(
|
||||||
|
self, cik: int | str, accession: str
|
||||||
|
) -> str | None:
|
||||||
|
"""``descriptionClassSecurity`` from a Form 25's primary XML, or None.
|
||||||
|
|
||||||
|
The rendered ``primaryDocument`` is an XSL view of this file; the raw
|
||||||
|
``primary_doc.xml`` beside it is the structured original.
|
||||||
|
"""
|
||||||
|
folder = accession.replace("-", "")
|
||||||
|
url = (
|
||||||
|
f"{_WWW}/Archives/edgar/data/{int(cik)}/{folder}/primary_doc.xml"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = await self.get_text(url)
|
||||||
|
except SecNotFoundError:
|
||||||
|
return None
|
||||||
|
match = re.search(
|
||||||
|
r"<descriptionClassSecurity>(.*?)</descriptionClassSecurity>",
|
||||||
|
body,
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
return " ".join(match.group(1).split()) or None
|
||||||
|
|
||||||
async def companyfacts(self, cik: int | str) -> dict[str, Any]:
|
async def companyfacts(self, cik: int | str) -> dict[str, Any]:
|
||||||
"""Raw companyfacts JSON ({cik, entityName, facts})."""
|
"""Raw companyfacts JSON ({cik, entityName, facts})."""
|
||||||
return await self.get_json(f"{_DATA}/api/xbrl/companyfacts/CIK{cik10(cik)}.json")
|
return await self.get_json(f"{_DATA}/api/xbrl/companyfacts/CIK{cik10(cik)}.json")
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, exists, select, update
|
||||||
|
|
||||||
from app.database import insert_for_session
|
from app.database import insert_for_session
|
||||||
from app.models.data_import_run import DataImportRun
|
from app.models.data_import_run import DataImportRun
|
||||||
@@ -81,6 +81,26 @@ MIN_BACKFILL_COVERAGE = 0.5
|
|||||||
# three); past that it is misfiled, not late, and blocking forever costs more
|
# three); past that it is misfiled, not late, and blocking forever costs more
|
||||||
# than the missing filing does — see the unresolved-filing guardrail below.
|
# than the missing filing does — see the unresolved-filing guardrail below.
|
||||||
MISSING_XBRL_RETRY_DAYS = 3
|
MISSING_XBRL_RETRY_DAYS = 3
|
||||||
|
|
||||||
|
# Aggregate ceiling on deferral. MISSING_XBRL_RETRY_DAYS bounds how long ONE
|
||||||
|
# filing blocks; it does not bound how long the import as a whole can stay
|
||||||
|
# deferred. Those differ because a blocking filing is only queued by promote(),
|
||||||
|
# which a deferred run never reaches — so during a rolling supply of
|
||||||
|
# unresolvable filings (earnings season, when SEC's Company-Facts aggregation is
|
||||||
|
# furthest behind) each new arrival restarts the clock before the previous one
|
||||||
|
# clears, and nothing is written at all: not the good rows, not the gap rows
|
||||||
|
# that would stop those filings blocking again.
|
||||||
|
#
|
||||||
|
# Once promotions have been stale this long, every unresolved filing is treated
|
||||||
|
# as past the window. promote() then queues them all (see the _past_retry_window
|
||||||
|
# call there), source_max_date advances, and _missing() forces queued rows
|
||||||
|
# aged-out on later runs so they never block again — the import self-heals
|
||||||
|
# through the paths that already exist.
|
||||||
|
#
|
||||||
|
# Well above MISSING_XBRL_RETRY_DAYS so ordinary overlapping blocks never trip
|
||||||
|
# it. Affected symbols stay barred from setups either way: setup_blocked_ciks is
|
||||||
|
# built from every missing filing regardless of window.
|
||||||
|
PROMOTION_CEILING_DAYS = 7
|
||||||
FILING_GAP_ESCALATE_DAYS = 14
|
FILING_GAP_ESCALATE_DAYS = 14
|
||||||
# Share-count band a co-registrant-recovered row must land in, relative to the
|
# Share-count band a co-registrant-recovered row must land in, relative to the
|
||||||
# issuer's own last snapshot. Wide enough for buybacks/issuance, nowhere near
|
# issuer's own last snapshot. Wide enough for buybacks/issuance, nowhere near
|
||||||
@@ -157,6 +177,9 @@ class SecFundamentalsImporter:
|
|||||||
self._retry_rows: list[dict[str, Any]] = []
|
self._retry_rows: list[dict[str, Any]] = []
|
||||||
self._latest_index_date: date | None = None
|
self._latest_index_date: date | None = None
|
||||||
self._backfill = False
|
self._backfill = False
|
||||||
|
# Set by validate() when the aggregate ceiling forced the block open;
|
||||||
|
# read by promote() to alert that it did.
|
||||||
|
self._ceiling_tripped: dict[str, Any] | None = None
|
||||||
|
|
||||||
# -- SourceImporter protocol -------------------------------------------
|
# -- SourceImporter protocol -------------------------------------------
|
||||||
|
|
||||||
@@ -421,6 +444,24 @@ class SecFundamentalsImporter:
|
|||||||
# reconstructible by re-walking the index.
|
# reconstructible by re-walking the index.
|
||||||
blocking = _within_retry_window(staged.missing_xbrl)
|
blocking = _within_retry_window(staged.missing_xbrl)
|
||||||
aged_out = _past_retry_window(staged.missing_xbrl)
|
aged_out = _past_retry_window(staged.missing_xbrl)
|
||||||
|
|
||||||
|
# ...unless promotions have been stale past the aggregate ceiling, in
|
||||||
|
# which case the deferral has cost more than the filings it withholds.
|
||||||
|
# Ageing them here (not just locally) is deliberate: promote() re-derives
|
||||||
|
# the queue from the same list, so this is what gets them queued.
|
||||||
|
self._ceiling_tripped = None
|
||||||
|
if blocking and db is not None and await self._promotions_stale(db):
|
||||||
|
for item in staged.missing_xbrl:
|
||||||
|
item["age_days"] = max(
|
||||||
|
item.get("age_days", 0), MISSING_XBRL_RETRY_DAYS + 1
|
||||||
|
)
|
||||||
|
self._ceiling_tripped = {
|
||||||
|
"forced": len(blocking),
|
||||||
|
"unresolved": len(staged.missing_xbrl),
|
||||||
|
}
|
||||||
|
blocking = _within_retry_window(staged.missing_xbrl)
|
||||||
|
aged_out = _past_retry_window(staged.missing_xbrl)
|
||||||
|
|
||||||
if blocking:
|
if blocking:
|
||||||
messages.append(
|
messages.append(
|
||||||
f"{len(blocking)} tracked XBRL filing(s) unresolved within the "
|
f"{len(blocking)} tracked XBRL filing(s) unresolved within the "
|
||||||
@@ -464,6 +505,9 @@ class SecFundamentalsImporter:
|
|||||||
"missing_xbrl": staged.missing_xbrl[:50],
|
"missing_xbrl": staged.missing_xbrl[:50],
|
||||||
"missing_xbrl_count": len(staged.missing_xbrl),
|
"missing_xbrl_count": len(staged.missing_xbrl),
|
||||||
"missing_xbrl_blocking": len(blocking),
|
"missing_xbrl_blocking": len(blocking),
|
||||||
|
# Present only when the aggregate ceiling forced this run through, so
|
||||||
|
# a promoted run that carries known-unresolved filings says so.
|
||||||
|
"promotion_ceiling_tripped": self._ceiling_tripped,
|
||||||
"recovered_from_coregistrant": staged.recovered[:50],
|
"recovered_from_coregistrant": staged.recovered[:50],
|
||||||
"recovered_count": len(staged.recovered),
|
"recovered_count": len(staged.recovered),
|
||||||
# Complete compact gate input; detailed audit lists above stay capped.
|
# Complete compact gate input; detailed audit lists above stay capped.
|
||||||
@@ -616,6 +660,25 @@ class SecFundamentalsImporter:
|
|||||||
created_at=_now(),
|
created_at=_now(),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# A ceiling-forced promotion is the safety valve firing — it must be
|
||||||
|
# visible, or the import silently starts carrying known-unresolved
|
||||||
|
# filings. The affected symbols stay barred from setups regardless.
|
||||||
|
if self._ceiling_tripped:
|
||||||
|
db.add(SystemEvent(
|
||||||
|
severity="warning",
|
||||||
|
source="sec_facts",
|
||||||
|
code="promotion_ceiling_forced",
|
||||||
|
message=(
|
||||||
|
f"Promoted with {self._ceiling_tripped['unresolved']} unresolved "
|
||||||
|
f"filing(s) — {self._ceiling_tripped['forced']} still inside the "
|
||||||
|
f"{MISSING_XBRL_RETRY_DAYS}-day retry window — because nothing had "
|
||||||
|
f"promoted in {PROMOTION_CEILING_DAYS} days. They are queued for "
|
||||||
|
"retry and their symbols remain blocked from setups."
|
||||||
|
)[:4000],
|
||||||
|
dedup_key=f"sec_facts:promotion_ceiling_forced:{run_id}",
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
# Persistent current gaps get one actionable escalation rather than a
|
# Persistent current gaps get one actionable escalation rather than a
|
||||||
# daily warning. The nullable marker makes this durable and noise-free.
|
# daily warning. The nullable marker makes this durable and noise-free.
|
||||||
escalation_cutoff = now - timedelta(days=FILING_GAP_ESCALATE_DAYS)
|
escalation_cutoff = now - timedelta(days=FILING_GAP_ESCALATE_DAYS)
|
||||||
@@ -757,6 +820,38 @@ class SecFundamentalsImporter:
|
|||||||
if accession not in resolved
|
if accession not in resolved
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def _promotions_stale(self, db) -> bool:
|
||||||
|
"""Has nothing promoted within ``PROMOTION_CEILING_DAYS``?
|
||||||
|
|
||||||
|
Only true for a source that HAS promoted before. A never-promoted import
|
||||||
|
is initial setup, not a wedge: forcing its first promotion through would
|
||||||
|
mask a misconfiguration rather than recover from a transient SEC gap.
|
||||||
|
|
||||||
|
Measured from ``self.today`` rather than the wall clock, so the ceiling
|
||||||
|
honors the same injected date that ages the filings it releases.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.combine(
|
||||||
|
self.today - timedelta(days=PROMOTION_CEILING_DAYS),
|
||||||
|
time.min,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
ever, recent = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
exists().where(
|
||||||
|
DataImportRun.source == SOURCE,
|
||||||
|
DataImportRun.status == STATUS_PROMOTED,
|
||||||
|
),
|
||||||
|
exists().where(
|
||||||
|
DataImportRun.source == SOURCE,
|
||||||
|
DataImportRun.status == STATUS_PROMOTED,
|
||||||
|
DataImportRun.started_at >= cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
return bool(ever) and not bool(recent)
|
||||||
|
|
||||||
async def _last_processed_index_date(self, db) -> date | None:
|
async def _last_processed_index_date(self, db) -> date | None:
|
||||||
return (
|
return (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from typing import Iterable
|
|||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services import settings_store
|
from app.services import settings_store, ticker_service
|
||||||
from app.services.earnings_alignment import normalise_symbol
|
from app.services.earnings_alignment import normalise_symbol
|
||||||
from app.services.sec_client import SecClient
|
from app.services.sec_client import SecClient
|
||||||
|
|
||||||
@@ -55,7 +55,11 @@ async def resolve_ciks(db, client: SecClient) -> ResolvedUniverse:
|
|||||||
returns the mapping + proposed `tickers.cik` writes; mutates nothing."""
|
returns the mapping + proposed `tickers.cik` writes; mutates nothing."""
|
||||||
ticker_to_cik = await client.company_tickers()
|
ticker_to_cik = await client.company_tickers()
|
||||||
overrides = await cik_overrides(db)
|
overrides = await cik_overrides(db)
|
||||||
rows = (await db.execute(select(Ticker.id, Ticker.symbol, Ticker.cik))).all()
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
ticker_service.active_only(select(Ticker.id, Ticker.symbol, Ticker.cik))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
result = ResolvedUniverse()
|
result = ResolvedUniverse()
|
||||||
for tid, symbol, current_cik in rows:
|
for tid, symbol, current_cik in rows:
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ KEY_CAPACITY = "shadow_book_capacity"
|
|||||||
KEY_RISK_PCT = "shadow_book_risk_pct"
|
KEY_RISK_PCT = "shadow_book_risk_pct"
|
||||||
KEY_START_EQUITY = "shadow_book_start_equity"
|
KEY_START_EQUITY = "shadow_book_start_equity"
|
||||||
|
|
||||||
# Matches the validated configuration: 10-position book, 1% fixed-fractional
|
# Matches the validated configuration: 1% fixed-fractional risk, and a count cap
|
||||||
# risk. Start equity is only a sizing base — comparisons are drawn in percent
|
# set as headroom rather than a target — see backtest_service.SIM_MAX_POSITIONS,
|
||||||
# and R-multiples, never in raw currency.
|
# which this must track. NOTIONAL_CAP below saturates the book near 12 positions,
|
||||||
DEFAULT_CAPACITY = 10
|
# so the count cap should simply never bind. Start equity is only a sizing base —
|
||||||
|
# comparisons are drawn in percent and R-multiples, never in raw currency.
|
||||||
|
DEFAULT_CAPACITY = 15
|
||||||
DEFAULT_RISK_PCT = 1.0
|
DEFAULT_RISK_PCT = 1.0
|
||||||
DEFAULT_START_EQUITY = 100_000.0
|
DEFAULT_START_EQUITY = 100_000.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,65 @@
|
|||||||
"""Ticker Registry service: add, delete, and list tracked tickers."""
|
"""Ticker Registry service: add, delete, list, and retire tracked tickers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Reasons a symbol may be marked delisted, narrowest first.
|
||||||
|
REASON_FORM_25 = "form_25" # SEC Form 25/25-NSE/15 confirmed the exchange exit
|
||||||
|
REASON_MANUAL = "manual" # an operator decided
|
||||||
|
|
||||||
|
# How long a symbol must be without bars before we spend an SEC request asking
|
||||||
|
# whether it delisted. Guards against a market-data outage probing the whole
|
||||||
|
# universe at once; a real delisting is still stale days later.
|
||||||
|
MIN_STALE_DAYS_BEFORE_PROBE = 3
|
||||||
|
|
||||||
|
# Rule 12d2-2: a Form 25 removal takes effect ten days after filing, so the
|
||||||
|
# filing date is not the date the security stopped trading.
|
||||||
|
FORM_25_EFFECTIVE_DAYS = 10
|
||||||
|
|
||||||
|
# How far before the last bar a Form 25 may be filed and still explain this gap.
|
||||||
|
# An exchange can file shortly before trading actually stops; anything older
|
||||||
|
# concerns a class that was already gone while the symbol kept printing bars.
|
||||||
|
FILING_LOOKBACK_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _sec_client_factory():
|
||||||
|
"""Build the SEC client for a delisting probe (patched in tests).
|
||||||
|
|
||||||
|
Imported lazily so the SEC/httpx stack stays off the import path of every
|
||||||
|
module that only wants ``active_only``.
|
||||||
|
"""
|
||||||
|
from app.services.sec_client import SecClient
|
||||||
|
|
||||||
|
return SecClient()
|
||||||
|
|
||||||
|
|
||||||
|
def active_only(stmt, *, as_of: date | None = None):
|
||||||
|
"""Restrict a Ticker query to symbols that still trade.
|
||||||
|
|
||||||
|
Opt-in on purpose rather than folded into a shared getter: list and admin
|
||||||
|
views deliberately keep delisted rows so the delisting is *visible*, which a
|
||||||
|
silent default would undo. Apply this on the live signal path — scanning,
|
||||||
|
ranking, scoring, breadth, ingestion — and nowhere else.
|
||||||
|
|
||||||
|
``delisted_on`` is an *effective* date, and a Form 25 is known ten days
|
||||||
|
before it takes effect, so a future date must not drop the symbol yet — it
|
||||||
|
is still trading and still worth scanning and ingesting. Compared in SQL
|
||||||
|
against the database's own date; ``as_of`` overrides it for tests.
|
||||||
|
"""
|
||||||
|
cutoff = func.current_date() if as_of is None else as_of
|
||||||
|
return stmt.where(
|
||||||
|
or_(Ticker.delisted_on.is_(None), Ticker.delisted_on > cutoff)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def add_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
async def add_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||||
"""Add a new ticker after validation.
|
"""Add a new ticker after validation.
|
||||||
@@ -52,6 +104,150 @@ async def delete_ticker(db: AsyncSession, symbol: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def list_tickers(db: AsyncSession) -> list[Ticker]:
|
async def list_tickers(db: AsyncSession) -> list[Ticker]:
|
||||||
"""Return all tracked tickers sorted alphabetically by symbol."""
|
"""Return all tracked tickers sorted alphabetically by symbol.
|
||||||
|
|
||||||
|
Delisted symbols are included and carry ``delisted_on`` — the registry is
|
||||||
|
where an operator needs to *see* that a symbol retired, not where it should
|
||||||
|
quietly disappear.
|
||||||
|
"""
|
||||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol.asc()))
|
result = await db.execute(select(Ticker).order_by(Ticker.symbol.asc()))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_delisted(
|
||||||
|
db: AsyncSession,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
delisted_on: date,
|
||||||
|
reason: str = REASON_MANUAL,
|
||||||
|
) -> bool:
|
||||||
|
"""Record that a symbol stopped trading. True if this changed anything.
|
||||||
|
|
||||||
|
Idempotent, so the staleness path can call it every run without churning the
|
||||||
|
row: re-marking is a no-op. The one exception is an SEC confirmation landing
|
||||||
|
on a row an operator marked by hand — Form 25 carries the real effective
|
||||||
|
date, so it replaces the operator's estimate. Nothing downgrades a confirmed
|
||||||
|
row back to a manual one.
|
||||||
|
"""
|
||||||
|
normalised = symbol.strip().upper()
|
||||||
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||||
|
ticker = result.scalar_one_or_none()
|
||||||
|
if ticker is None:
|
||||||
|
raise NotFoundError(f"Ticker not found: {normalised}")
|
||||||
|
if ticker.delisted_on is not None:
|
||||||
|
upgrading = (
|
||||||
|
reason == REASON_FORM_25 and ticker.delisted_reason != REASON_FORM_25
|
||||||
|
)
|
||||||
|
if not upgrading:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Ticker)
|
||||||
|
.where(Ticker.id == ticker.id)
|
||||||
|
.values(delisted_on=delisted_on, delisted_reason=reason)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"ticker %s marked delisted on %s (%s)", normalised, delisted_on, reason
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def confirm_delisting(
|
||||||
|
db: AsyncSession,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
last_bar: date | None,
|
||||||
|
today: date | None = None,
|
||||||
|
) -> date | None:
|
||||||
|
"""Ask SEC whether ``symbol`` actually delisted; mark it if so.
|
||||||
|
|
||||||
|
Called when OHLCV goes stale, because "no new bars" alone cannot tell a
|
||||||
|
delisting from a halt or a rename. Returns the effective date whenever the
|
||||||
|
symbol is known to have delisted — whether this call established that or an
|
||||||
|
earlier one did — and ``None`` while it remains unproven, so the caller warns
|
||||||
|
only about gaps that still have no explanation.
|
||||||
|
|
||||||
|
Returning the already-known date matters between filing and effect: trading
|
||||||
|
usually stops before the ten-day Rule 12d2-2 delay expires, so the symbol is
|
||||||
|
correctly still active (see ``active_only``) while producing no bars. Without
|
||||||
|
this the staleness warning would fire daily across that window — the exact
|
||||||
|
noise the delisting flow exists to remove.
|
||||||
|
|
||||||
|
Deliberately driven by staleness rather than by the SEC fundamentals import:
|
||||||
|
that importer stalls for days at a time on unrelated Company-Facts gaps, and
|
||||||
|
detection wired into it would stall with it.
|
||||||
|
|
||||||
|
The probe waits for ``MIN_STALE_DAYS_BEFORE_PROBE``. A delisted symbol stays
|
||||||
|
stale forever, so the delay costs nothing, and it keeps a broad market-data
|
||||||
|
outage — where every tracked symbol reports stale at once — from turning into
|
||||||
|
one SEC request per symbol per run.
|
||||||
|
"""
|
||||||
|
from app.services.sec_client import SecError
|
||||||
|
|
||||||
|
normalised = symbol.strip().upper()
|
||||||
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||||
|
ticker = result.scalar_one_or_none()
|
||||||
|
if ticker is None:
|
||||||
|
return None
|
||||||
|
known = ticker.delisted_on
|
||||||
|
# Already confirmed by SEC — nothing left to learn, but the caller still
|
||||||
|
# needs the date to know this gap is explained. A row an operator marked by
|
||||||
|
# hand is worth probing: Form 25 upgrades the estimated date.
|
||||||
|
if ticker.delisted_reason == REASON_FORM_25:
|
||||||
|
return known
|
||||||
|
if not ticker.cik:
|
||||||
|
return known
|
||||||
|
# No bars at all is an ingestion problem, not evidence of a delisting.
|
||||||
|
if last_bar is None:
|
||||||
|
return known
|
||||||
|
if ((today or date.today()) - last_bar).days < MIN_STALE_DAYS_BEFORE_PROBE:
|
||||||
|
return known
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with _sec_client_factory() as client:
|
||||||
|
# Only a Form 25 filed around or after the last bar can explain THIS
|
||||||
|
# gap. An older one belongs to a class that stopped trading before
|
||||||
|
# the symbol was still printing bars, and must not retire it.
|
||||||
|
filing = await client.delisting_filing(
|
||||||
|
ticker.cik, not_before=last_bar - timedelta(days=FILING_LOOKBACK_DAYS)
|
||||||
|
)
|
||||||
|
except SecError:
|
||||||
|
# Never let a probe failure escalate a routine staleness warning.
|
||||||
|
logger.warning("delisting probe failed for %s", normalised, exc_info=True)
|
||||||
|
return known
|
||||||
|
|
||||||
|
if filing is None:
|
||||||
|
return known
|
||||||
|
# Removal takes effect ten days after filing, so the filing date is not the
|
||||||
|
# date the symbol stopped trading.
|
||||||
|
effective = filing["filing_date"] + timedelta(days=FORM_25_EFFECTIVE_DAYS)
|
||||||
|
if await mark_delisted(
|
||||||
|
db, normalised, delisted_on=effective, reason=REASON_FORM_25
|
||||||
|
):
|
||||||
|
return effective
|
||||||
|
return known
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_delisted(db: AsyncSession, symbol: str) -> bool:
|
||||||
|
"""Un-retire a symbol. True if it had been marked.
|
||||||
|
|
||||||
|
The counterpart that makes automatic marking acceptable: a false positive
|
||||||
|
costs one row update, where a delete would have cost the price history.
|
||||||
|
"""
|
||||||
|
normalised = symbol.strip().upper()
|
||||||
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||||
|
ticker = result.scalar_one_or_none()
|
||||||
|
if ticker is None:
|
||||||
|
raise NotFoundError(f"Ticker not found: {normalised}")
|
||||||
|
if ticker.delisted_on is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Ticker)
|
||||||
|
.where(Ticker.id == ticker.id)
|
||||||
|
.values(delisted_on=None, delisted_reason=None)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("ticker %s un-marked as delisted", normalised)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -113,116 +113,6 @@ def _normalise_symbols(symbols: Iterable[str]) -> list[str]:
|
|||||||
return sorted(deduped)
|
return sorted(deduped)
|
||||||
|
|
||||||
|
|
||||||
def _extract_symbols_from_fmp_payload(payload: object) -> list[str]:
|
|
||||||
if not isinstance(payload, list):
|
|
||||||
return []
|
|
||||||
|
|
||||||
symbols: list[str] = []
|
|
||||||
for item in payload:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
candidate = item.get("symbol") or item.get("ticker")
|
|
||||||
if isinstance(candidate, str):
|
|
||||||
symbols.append(candidate)
|
|
||||||
return symbols
|
|
||||||
|
|
||||||
|
|
||||||
async def _try_fmp_urls(
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
urls: list[str],
|
|
||||||
) -> tuple[list[str], list[str]]:
|
|
||||||
failures: list[str] = []
|
|
||||||
for url in urls:
|
|
||||||
endpoint = url.split("?")[0]
|
|
||||||
try:
|
|
||||||
response = await client.get(url)
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
failures.append(f"{endpoint}: network error ({type(exc).__name__}: {exc})")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
failures.append(f"{endpoint}: HTTP {response.status_code}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
except ValueError:
|
|
||||||
failures.append(f"{endpoint}: invalid JSON payload")
|
|
||||||
continue
|
|
||||||
|
|
||||||
symbols = _extract_symbols_from_fmp_payload(payload)
|
|
||||||
if symbols:
|
|
||||||
return symbols, failures
|
|
||||||
|
|
||||||
failures.append(f"{endpoint}: empty/unsupported payload")
|
|
||||||
|
|
||||||
return [], failures
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_universe_symbols_from_fmp(universe: str) -> list[str]:
|
|
||||||
if not settings.fmp_api_key:
|
|
||||||
raise ValidationError(
|
|
||||||
"FMP API key is required for universe bootstrap (set FMP_API_KEY)"
|
|
||||||
)
|
|
||||||
|
|
||||||
api_key = settings.fmp_api_key
|
|
||||||
stable_base = "https://financialmodelingprep.com/stable"
|
|
||||||
legacy_base = "https://financialmodelingprep.com/api/v3"
|
|
||||||
|
|
||||||
stable_candidates: dict[str, list[str]] = {
|
|
||||||
"sp500": [
|
|
||||||
f"{stable_base}/sp500-constituent?apikey={api_key}",
|
|
||||||
f"{stable_base}/sp500-constituents?apikey={api_key}",
|
|
||||||
],
|
|
||||||
"nasdaq100": [
|
|
||||||
f"{stable_base}/nasdaq-100-constituent?apikey={api_key}",
|
|
||||||
f"{stable_base}/nasdaq100-constituent?apikey={api_key}",
|
|
||||||
f"{stable_base}/nasdaq-100-constituents?apikey={api_key}",
|
|
||||||
],
|
|
||||||
"nasdaq_all": [
|
|
||||||
f"{stable_base}/stock-screener?exchange=NASDAQ&isEtf=false&limit=10000&apikey={api_key}",
|
|
||||||
f"{stable_base}/available-traded/list?apikey={api_key}",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
legacy_candidates: dict[str, list[str]] = {
|
|
||||||
"sp500": [
|
|
||||||
f"{legacy_base}/sp500_constituent?apikey={api_key}",
|
|
||||||
f"{legacy_base}/sp500_constituent",
|
|
||||||
],
|
|
||||||
"nasdaq100": [
|
|
||||||
f"{legacy_base}/nasdaq_constituent?apikey={api_key}",
|
|
||||||
f"{legacy_base}/nasdaq_constituent",
|
|
||||||
],
|
|
||||||
"nasdaq_all": [
|
|
||||||
f"{legacy_base}/stock-screener?exchange=NASDAQ&isEtf=false&limit=10000&apikey={api_key}",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
failures: list[str] = []
|
|
||||||
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
|
|
||||||
stable_symbols, stable_failures = await _try_fmp_urls(client, stable_candidates[universe])
|
|
||||||
failures.extend(stable_failures)
|
|
||||||
|
|
||||||
if stable_symbols:
|
|
||||||
return stable_symbols
|
|
||||||
|
|
||||||
legacy_symbols, legacy_failures = await _try_fmp_urls(client, legacy_candidates[universe])
|
|
||||||
failures.extend(legacy_failures)
|
|
||||||
|
|
||||||
if legacy_symbols:
|
|
||||||
return legacy_symbols
|
|
||||||
|
|
||||||
if failures:
|
|
||||||
reason = "; ".join(failures[:6])
|
|
||||||
logger.warning("FMP universe fetch failed for %s: %s", universe, reason)
|
|
||||||
raise ProviderError(
|
|
||||||
f"Failed to fetch universe symbols from FMP for '{universe}'. Attempts: {reason}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise ProviderError(f"Failed to fetch universe symbols from FMP for '{universe}'")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_wiki_constituent_symbols(
|
async def _fetch_wiki_constituent_symbols(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -351,13 +241,16 @@ async def fetch_universe_symbols(
|
|||||||
|
|
||||||
Fallback order:
|
Fallback order:
|
||||||
1) Free public sources (Wikipedia/NASDAQ trader)
|
1) Free public sources (Wikipedia/NASDAQ trader)
|
||||||
2) FMP endpoints (if available)
|
2) Cached snapshot in SystemSetting
|
||||||
3) Cached snapshot in SystemSetting
|
3) Built-in seed symbols
|
||||||
4) Built-in seed symbols
|
|
||||||
|
|
||||||
Returns ``(symbols, source_label)`` so bootstrap UI can show where the
|
Returns ``(symbols, source_label)`` so bootstrap UI can show where the
|
||||||
list came from (important when Wikipedia/FMP fail and a stale cache still
|
list came from (important when the public source fails and a stale cache
|
||||||
lists BK instead of BNY).
|
still lists BK instead of BNY).
|
||||||
|
|
||||||
|
The seeds are representative, not complete, so a *fresh* install whose
|
||||||
|
public source is down bootstraps a partial universe. A warm instance is
|
||||||
|
unaffected — it falls through to its cached snapshot.
|
||||||
"""
|
"""
|
||||||
normalised_universe = _validate_universe(universe)
|
normalised_universe = _validate_universe(universe)
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
@@ -369,15 +262,6 @@ async def fetch_universe_symbols(
|
|||||||
await _write_cached_symbols(db, normalised_universe, cleaned_public, public_source or "public")
|
await _write_cached_symbols(db, normalised_universe, cleaned_public, public_source or "public")
|
||||||
return cleaned_public, public_source or "public"
|
return cleaned_public, public_source or "public"
|
||||||
|
|
||||||
try:
|
|
||||||
fmp_symbols = await _fetch_universe_symbols_from_fmp(normalised_universe)
|
|
||||||
cleaned_fmp = _normalise_symbols(fmp_symbols)
|
|
||||||
if cleaned_fmp:
|
|
||||||
await _write_cached_symbols(db, normalised_universe, cleaned_fmp, "fmp")
|
|
||||||
return cleaned_fmp, "fmp"
|
|
||||||
except (ProviderError, ValidationError) as exc:
|
|
||||||
failures.append(str(exc))
|
|
||||||
|
|
||||||
cached_symbols = await _read_cached_symbols(db, normalised_universe)
|
cached_symbols = await _read_cached_symbols(db, normalised_universe)
|
||||||
if cached_symbols:
|
if cached_symbols:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -473,8 +357,25 @@ async def bootstrap_universe(
|
|||||||
db.add(Ticker(symbol=symbol))
|
db.add(Ticker(symbol=symbol))
|
||||||
|
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
|
skipped_delisted: list[str] = []
|
||||||
if symbols_to_delete:
|
if symbols_to_delete:
|
||||||
result = await db.execute(delete(Ticker).where(Ticker.symbol.in_(symbols_to_delete)))
|
# A delisted row was retained on purpose — its price history is exactly
|
||||||
|
# what a survivorship-honest backtest needs, and the delete cascades it
|
||||||
|
# away. Pruning must not undo that. (Pruning a symbol that is merely no
|
||||||
|
# longer an index constituent still destroys history; that needs a
|
||||||
|
# tracked/membership state separate from delisting.)
|
||||||
|
protected = (
|
||||||
|
await db.execute(
|
||||||
|
select(Ticker.symbol).where(
|
||||||
|
Ticker.symbol.in_(symbols_to_delete),
|
||||||
|
Ticker.delisted_on.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
skipped_delisted = sorted(protected)
|
||||||
|
deletable = [s for s in symbols_to_delete if s not in set(protected)]
|
||||||
|
if deletable:
|
||||||
|
result = await db.execute(delete(Ticker).where(Ticker.symbol.in_(deletable)))
|
||||||
deleted_count = int(result.rowcount or 0)
|
deleted_count = int(result.rowcount or 0)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -494,4 +395,8 @@ async def bootstrap_universe(
|
|||||||
"already_tracked": len(target_symbols & existing_symbols),
|
"already_tracked": len(target_symbols & existing_symbols),
|
||||||
"deleted": deleted_count,
|
"deleted": deleted_count,
|
||||||
"added_symbols": symbols_to_add[:50],
|
"added_symbols": symbols_to_add[:50],
|
||||||
|
# Delisted rows a prune declined to destroy, so the caller can see the
|
||||||
|
# count did not match what they asked to remove.
|
||||||
|
"kept_delisted": skipped_delisted[:50],
|
||||||
|
"kept_delisted_count": len(skipped_delisted),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ MIN_FREE_GB="${DOLT_MIN_FREE_DISK_GB:-5}"
|
|||||||
EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}"
|
EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}"
|
||||||
DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}"
|
DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}"
|
||||||
DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}"
|
DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}"
|
||||||
FUNDAMENTALS_PARITY_REPORT_DIR="${FUNDAMENTALS_PARITY_REPORT_DIR:-/var/lib/signal-platform/reports/fundamentals-parity}"
|
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "ERROR: $*" >&2
|
echo "ERROR: $*" >&2
|
||||||
@@ -80,8 +79,6 @@ check_env() {
|
|||||||
|| fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE"
|
|| fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE"
|
||||||
grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \
|
grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \
|
||||||
|| fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email"
|
|| fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email"
|
||||||
grep -Fqx "FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR" "$ENV_FILE" \
|
|
||||||
|| fail "set FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR in $ENV_FILE"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
check_all() {
|
check_all() {
|
||||||
@@ -104,15 +101,6 @@ check_all() {
|
|||||||
identity_email="$(repo_config_value user.email 2>/dev/null || true)"
|
identity_email="$(repo_config_value user.email 2>/dev/null || true)"
|
||||||
[[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR"
|
[[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR"
|
||||||
[[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR"
|
[[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR"
|
||||||
[[ -d "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|
|
||||||
|| fail "missing parity report directory: $FUNDAMENTALS_PARITY_REPORT_DIR"
|
|
||||||
if [[ "$(id -un)" == "$APP_USER" ]]; then
|
|
||||||
[[ -w "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|
|
||||||
|| fail "parity report directory is not writable by $APP_USER"
|
|
||||||
else
|
|
||||||
runuser -u "$APP_USER" -- test -w "$FUNDAMENTALS_PARITY_REPORT_DIR" \
|
|
||||||
|| fail "parity report directory is not writable by $APP_USER"
|
|
||||||
fi
|
|
||||||
check_free_space
|
check_free_space
|
||||||
check_env
|
check_env
|
||||||
echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned"
|
echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned"
|
||||||
@@ -139,7 +127,6 @@ fi
|
|||||||
version_ok || fail "Dolt $DOLT_VERSION installation failed"
|
version_ok || fail "Dolt $DOLT_VERSION installation failed"
|
||||||
|
|
||||||
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR"
|
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR"
|
||||||
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$FUNDAMENTALS_PARITY_REPORT_DIR"
|
|
||||||
check_free_space
|
check_free_space
|
||||||
|
|
||||||
if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then
|
if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
# Dolt bulk-data integration — implementation plan
|
# Dolt bulk-data integration — implementation plan
|
||||||
|
|
||||||
Status: approved 2026-07-21, revised through four review rounds; direction: KISS
|
Status: **workstream A complete and deployed** (A0–A6, last step 2026-08-07);
|
||||||
backend, UI value first. Hand-off document for the implementing agent;
|
**workstream B dropped 2026-08-07** — see § Why B was dropped. Approved 2026-07-21,
|
||||||
self-contained.
|
revised through five review rounds; direction: KISS backend, UI value first.
|
||||||
|
Originally a hand-off document for the implementing agent; now the design record.
|
||||||
|
Current operations live in `docs/fundamentals-deployment.md`.
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
Replace the free-tier fundamentals APIs (FMP, Finnhub, Alpha Vantage) with bulk
|
Replace the free-tier fundamentals APIs (FMP, Finnhub, Alpha Vantage) with bulk
|
||||||
data: SEC Company Facts for fundamentals, the DoltHub earnings repo for the
|
data: SEC Company Facts for fundamentals and the DoltHub earnings repo for the
|
||||||
earnings calendar/history, and — later, independently — the DoltHub stocks repo for
|
earnings calendar/history. PostgreSQL stays the production system of record.
|
||||||
historical OHLCV. PostgreSQL stays the production system of record.
|
(A third source — the DoltHub stocks repo for historical OHLCV — was planned as
|
||||||
|
workstream B and dropped; Alpaca remains the price source.)
|
||||||
|
|
||||||
**Delivery order: two independent workstreams.**
|
**Delivery order: two independent workstreams.**
|
||||||
|
|
||||||
@@ -17,9 +20,9 @@ historical OHLCV. PostgreSQL stays the production system of record.
|
|||||||
FundamentalsPanel + decommission FMP/Finnhub/Alpha Vantage. Valuation uses the
|
FundamentalsPanel + decommission FMP/Finnhub/Alpha Vantage. Valuation uses the
|
||||||
existing Alpaca closes already in `ohlcv_records`. This alone achieves the goal
|
existing Alpaca closes already in `ohlcv_records`. This alone achieves the goal
|
||||||
(killing the quota-limited APIs) and delivers all the UI value.
|
(killing the quota-limited APIs) and delivers all the UI value.
|
||||||
- **Workstream B (later, optional until needed):** replace historical OHLCV with
|
- **Workstream B — DROPPED 2026-08-07, see below.** Would have replaced historical
|
||||||
the Dolt stocks repo. The most complex machinery (4.7 GB clone, split
|
OHLCV with the Dolt stocks repo. Its design is retained further down as a record,
|
||||||
adjustment, source-bar table, reconciliation) lives here and blocks nothing in A.
|
not as a backlog item.
|
||||||
|
|
||||||
**Guiding principle: KISS.** Plain daily importers with staging and atomic
|
**Guiding principle: KISS.** Plain daily importers with staging and atomic
|
||||||
promotion — no forensic replay, no permanent archive store, no conflict tables, no
|
promotion — no forensic replay, no permanent archive store, no conflict tables, no
|
||||||
@@ -70,8 +73,9 @@ notes (retain a CC BY-SA 4.0 reference + attribution to `post-no-preference/earn
|
|||||||
and a note of the transformations applied — e.g. in a repo `NOTICE`/attribution file
|
and a note of the transformations applied — e.g. in a repo `NOTICE`/attribution file
|
||||||
and the importer module); **no public API, bulk export, or redistribution** of the
|
and the importer module); **no public API, bulk export, or redistribution** of the
|
||||||
data; re-review licensing before any public or commercial access. The
|
data; re-review licensing before any public or commercial access. The
|
||||||
`post-no-preference/stocks` repo (workstream B) is **not** covered here and will be
|
`post-no-preference/stocks` repo (workstream B) is **not** covered here. B was
|
||||||
reviewed separately if B begins.
|
dropped before any licensing review, so that repo has never been assessed — any
|
||||||
|
future use of it starts that review from scratch.
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
@@ -119,7 +123,9 @@ reviewed separately if B begins.
|
|||||||
cache, repopulated by the daily SEC job — but only after the phase-A5 parity
|
cache, repopulated by the daily SEC job — but only after the phase-A5 parity
|
||||||
gate.
|
gate.
|
||||||
|
|
||||||
**Migration 027 (workstream B, written when B starts):**
|
**Migration 027 (workstream B — NEVER WRITTEN; B was dropped, and `027` was
|
||||||
|
subsequently used for `fundamental_snapshots.weighted_avg_diluted_shares`). The
|
||||||
|
design below is a record only:**
|
||||||
|
|
||||||
- `ohlcv_source_bars` — source-truth bar table, required because `ohlcv_records`
|
- `ohlcv_source_bars` — source-truth bar table, required because `ohlcv_records`
|
||||||
allows one row per (ticker_id, date) (`app/models/ohlcv.py:12`) and Alpaca
|
allows one row per (ticker_id, date) (`app/models/ohlcv.py:12`) and Alpaca
|
||||||
@@ -219,7 +225,7 @@ Workstream A:
|
|||||||
**The new API valuation object is not stored anywhere** — it is computed at
|
**The new API valuation object is not stored anywhere** — it is computed at
|
||||||
request time (below). No valuation cache or table exists.
|
request time (below). No valuation cache or table exists.
|
||||||
|
|
||||||
Workstream B:
|
Workstream B (dropped — never built):
|
||||||
|
|
||||||
- Dolt OHLCV+splits pull/import: `0 2 * * tue-sat` ET. If source_max_date is not
|
- Dolt OHLCV+splits pull/import: `0 2 * * tue-sat` ET. If source_max_date is not
|
||||||
fresh, retry hourly until ~06:00, then give up quietly. After a successful
|
fresh, retry hourly until ~06:00, then give up quietly. After a successful
|
||||||
@@ -430,16 +436,55 @@ workstream B — Alpaca remains the price source throughout.
|
|||||||
approval** — see the handoff section below. Step (c) is implemented behind the
|
approval** — see the handoff section below. Step (c) is implemented behind the
|
||||||
default-off `fundamental_data_sec_dolt_cutover_enabled` SystemSetting; the
|
default-off `fundamental_data_sec_dolt_cutover_enabled` SystemSetting; the
|
||||||
remaining production action is flipping that switch on and observing it.
|
remaining production action is flipping that switch on and observing it.
|
||||||
- A6. Remove FMP/Finnhub/Alpha Vantage; keep monitoring + manual fallback.
|
- A6. **DONE 2026-08-07.** FMP/Finnhub/Alpha Vantage removed, along with the
|
||||||
|
weekly `fundamental_collector` job, the A5 cutover toggle (SEC+Dolt is now the
|
||||||
|
unconditional path) and the parity report. Migration `029` tombstoned the two
|
||||||
|
behavior-bearing settings rows for the rollback window and `030` dropped them
|
||||||
|
once the deploy was confirmed healthy; the archived parity bundles stay as the
|
||||||
|
A5 evidence trail.
|
||||||
|
|
||||||
**Workstream B (independent, start when wanted):**
|
**Workstream B — DROPPED 2026-08-07.** The phases below are recorded for anyone
|
||||||
|
who revisits the decision; none of them are scheduled work.
|
||||||
|
|
||||||
- B0. Stocks clone (~4.7 GB) provisioned; migration 027.
|
- ~~B0. Stocks clone (~4.7 GB) provisioned; migration 027.~~
|
||||||
- B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca
|
- ~~B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca
|
||||||
keeps owning `ohlcv_records`); historical backfill.
|
keeps owning `ohlcv_records`); historical backfill.~~
|
||||||
- B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries.
|
- ~~B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries.~~
|
||||||
- B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source
|
- ~~B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source
|
||||||
bars + splits); morning pipeline → 03:00.
|
bars + splits); morning pipeline → 03:00.~~
|
||||||
|
|
||||||
|
### Why B was dropped
|
||||||
|
|
||||||
|
Reviewed after A6 shipped. Four reasons, in order of weight:
|
||||||
|
|
||||||
|
1. **Its motivation no longer exists.** B was scoped inside a plan whose goal was
|
||||||
|
killing the quota-limited free-tier APIs. Alpaca was never one of them, and the
|
||||||
|
plan always said so (§ Decommissioning: "Alpaca remains the price source
|
||||||
|
throughout"). A6 achieved the goal. What remained was swapping one working
|
||||||
|
price source for another.
|
||||||
|
2. **Its only concrete benefit is reachable far more cheaply.** The prize was
|
||||||
|
`corporate_actions`, the documented fix for the KLAC-class post-filing split
|
||||||
|
(TTM EPS pre-split vs a post-split price → P/E 6.19 instead of ~13, invisible to
|
||||||
|
snapshots). That needs *split events*, not 4.7 GB of bars — and the Alpaca SDK
|
||||||
|
already in the venv exposes them via
|
||||||
|
`alpaca.data.historical.corporate_actions.CorporateActionsClient.get_corporate_actions`
|
||||||
|
with `CorporateActionsRequest` / `CorporateActionsType`. See the follow-up below.
|
||||||
|
3. **The benefit is small.** Fundamentals carry 20% of the composite, P/E is one of
|
||||||
|
three fundamental inputs, and only names that split between their last 10-Q and
|
||||||
|
today are affected — a handful at a time, self-correcting at the next filing.
|
||||||
|
4. **B would add a risk the current setup does not carry.** By design a newly
|
||||||
|
published split rewrites a symbol's entire adjusted history. A backtest↔prod
|
||||||
|
parity guard exists precisely because changed history invalidates comparisons;
|
||||||
|
B makes history mutable as a routine event. It also needs its own license
|
||||||
|
review — the A0 CC BY-SA decision covers only `post-no-preference/earnings`.
|
||||||
|
|
||||||
|
**Optional follow-up, not scheduled:** a small `corporate_actions` table populated
|
||||||
|
from Alpaca, used to null or correct P/E when a split post-dates the newest
|
||||||
|
snapshot. Roughly a day's work; captures essentially all of B's value with no
|
||||||
|
clone, no `ohlcv_source_bars`, no split-adjustment pipeline and no reconciliation
|
||||||
|
window. Worth doing only if the wart starts costing something — it has been visible
|
||||||
|
and harmless since July 2026. Note that migration numbering has moved on: head is
|
||||||
|
`030`, so any such table would be `031+`, not the `027` named below.
|
||||||
|
|
||||||
## Test plan
|
## Test plan
|
||||||
|
|
||||||
@@ -464,8 +509,8 @@ workstream B — Alpaca remains the price source throughout.
|
|||||||
falls back from P/E to FCF yield for the valuation segment when P/E is null.
|
falls back from P/E to FCF yield for the valuation segment when P/E is null.
|
||||||
- Peer comparison disappears below 5 peer issuers; favorable-percentile direction
|
- Peer comparison disappears below 5 peer issuers; favorable-percentile direction
|
||||||
correct for both polarities.
|
correct for both polarities.
|
||||||
- Workstream B: split-adjusted OHLCV matches Alpaca on representative normal /
|
- ~~Workstream B: split-adjusted OHLCV matches Alpaca on representative normal /
|
||||||
split / reverse-split symbols.
|
split / reverse-split symbols.~~ (dropped)
|
||||||
- UI states: positive, adverse, neutral, insufficient history, insufficient
|
- UI states: positive, adverse, neutral, insufficient history, insufficient
|
||||||
peers; mobile layout; non-color accessibility.
|
peers; mobile layout; non-color accessibility.
|
||||||
- Unit, integration, scheduler and frontend suites pass.
|
- Unit, integration, scheduler and frontend suites pass.
|
||||||
@@ -492,30 +537,32 @@ Post-fix: candidate scores 504 of 511 vs legacy's 507 (gap = PSKY/Q new registra
|
|||||||
FITB, all explained); revenue-growth agreement 0.0038 median abs delta where both exist.
|
FITB, all explained); revenue-growth agreement 0.0038 median abs delta where both exist.
|
||||||
Dennis reviewed the evidence 2026-07-24 and directed proceeding to cutover.
|
Dennis reviewed the evidence 2026-07-24 and directed proceeding to cutover.
|
||||||
|
|
||||||
**Task 1 — A5 activation (IMPLEMENTED 2026-07-24; production switch remains).** The
|
**Task 1 — A5 activation: DONE.** Implemented 2026-07-24, switched on and observed
|
||||||
post-activation local refresh of `fundamental_data` derives `pe_ratio` and
|
in production, and made unconditional by A6 (2026-08-07) — there is no longer a
|
||||||
`market_cap` from newest valid snapshots × latest PostgreSQL close, `revenue_growth`
|
switch, an Admin card, or a weekly legacy collector to skip. The local refresh of
|
||||||
from snapshots, `earnings_surprise`/`next_earnings_date` from `earnings_events`; mark
|
`fundamental_data` derives `pe_ratio` and `market_cap` from newest valid snapshots ×
|
||||||
affected cached fundamental scores stale; must run identically when SEC is unreachable.
|
latest PostgreSQL close, `revenue_growth` from snapshots, and
|
||||||
|
`earnings_surprise`/`next_earnings_date` from `earnings_events`; it marks affected
|
||||||
|
cached fundamental scores stale and runs identically when SEC is unreachable.
|
||||||
It consumes `fundamentals_derivation.derive()` outputs, NOT raw snapshot fields —
|
It consumes `fundamentals_derivation.derive()` outputs, NOT raw snapshot fields —
|
||||||
that path carries the split guard (`ttm_diluted_eps`
|
that path carries the split guard (`ttm_diluted_eps` nulls when contaminated, with
|
||||||
nulls when contaminated, with `ttm_diluted_eps_caveat`) and the multi-class share
|
`ttm_diluted_eps_caveat`) and the multi-class share fallback (`shares_outstanding` +
|
||||||
fallback (`shares_outstanding` + `shares_outstanding_estimated`). Parity and activation
|
`shares_outstanding_estimated`). See `docs/fundamentals-deployment.md` for current
|
||||||
share the same candidate builder. Activation is the explicit
|
operations and rollback.
|
||||||
`fundamental_data_sec_dolt_cutover_enabled` SystemSetting and defaults off. It is
|
|
||||||
managed by the **Fundamentals data source** card in Admin → Settings; while active,
|
|
||||||
the weekly legacy collector skips itself so it cannot overwrite the SEC/Dolt cache.
|
|
||||||
See `docs/fundamentals-deployment.md` for the production flip and rollback procedure.
|
|
||||||
|
|
||||||
**Task 2 — A6 decommissioning.** After a short observation window: remove
|
**Task 2 — A6 decommissioning: DONE 2026-08-07.** The cutover ran on and was
|
||||||
FMP/Finnhub/Alpha Vantage providers, config and env keys; keep monitoring + manual
|
observed in production, so the legacy providers, their config/env keys, the weekly
|
||||||
fallback. Gated by the acceptance criteria above — especially forward-calendar
|
collector job and the parity report were all removed. Two consequences to carry:
|
||||||
timeliness from `dolt_earnings` (its `source_max_date` ran ~5 weeks ahead as of
|
(1) `fundamental_data` now has no provider fallback — recovery is restore-from-backup;
|
||||||
2026-07-23, which passes).
|
(2) disabling **SEC Fundamentals Import** stops the SEC fetch only, because the local
|
||||||
|
cache refresh was deliberately moved outside the job-enable check. No follow-ups
|
||||||
|
remain: migration `030` dropped the tombstone rows after the deploy was verified.
|
||||||
|
|
||||||
**Known caveats to carry (documented in the findings report, not bugs to fix):**
|
**Known caveats to carry (documented in the findings report, not bugs to fix):**
|
||||||
- KLAC-class post-filing splits: P/E wrong until the next 10-Q; undetectable from
|
- KLAC-class post-filing splits: P/E wrong until the next 10-Q; undetectable from
|
||||||
snapshots. Workstream B's `corporate_actions` table is the natural future fix.
|
snapshots. Still open and still harmless. The fix, if ever wanted, is a small
|
||||||
|
`corporate_actions` table fed from Alpaca — **not** workstream B, which was
|
||||||
|
dropped; see § Why B was dropped.
|
||||||
- BRK-B: no share count exists anywhere in companyfacts → no market cap, correctly.
|
- BRK-B: no share count exists anywhere in companyfacts → no market cap, correctly.
|
||||||
- FITB: unscored (split guard + no taggable revenue) — the one name that lost its
|
- FITB: unscored (split guard + no taggable revenue) — the one name that lost its
|
||||||
score relative to legacy; composite renormalises.
|
score relative to legacy; composite renormalises.
|
||||||
@@ -528,7 +575,7 @@ timeliness from `dolt_earnings` (its `source_max_date` ran ~5 weeks ahead as of
|
|||||||
|
|
||||||
## Deferred (explicitly, until a concrete need appears)
|
## Deferred (explicitly, until a concrete need appears)
|
||||||
|
|
||||||
- Workstream B itself is deferred relative to A and blocks nothing in A.
|
- Workstream B: **dropped** 2026-08-07, not deferred — see § Why B was dropped.
|
||||||
- Exact byte-level source replay of historical imports; permanent archive store.
|
- Exact byte-level source replay of historical imports; permanent archive store.
|
||||||
- Point-in-time backtest enforcement (`accepted_at` is stored now; derivation and
|
- Point-in-time backtest enforcement (`accepted_at` is stored now; derivation and
|
||||||
backtest visibility rules are built only when fundamentals enter
|
backtest visibility rules are built only when fundamentals enter
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
# Fundamentals production deployment
|
# Fundamentals production deployment
|
||||||
|
|
||||||
This is the one-time production setup for the Dolt earnings and SEC fundamentals
|
This is the one-time production setup for the Dolt earnings and SEC fundamentals
|
||||||
imports. The A5 scoring cutover was approved on 2026-07-24; the compat-cache write
|
imports. Since A6 (2026-08) these are the *only* fundamentals sources — the
|
||||||
path is still default-off until the explicit production switch below is set. Do
|
FMP/Finnhub/Alpha Vantage providers, the weekly legacy collector and the A5 parity
|
||||||
not add OS cron entries: the application scheduler owns both jobs.
|
report are gone, and the cache write path is unconditional. Do not add OS cron
|
||||||
|
entries: the application scheduler owns both jobs.
|
||||||
|
|
||||||
## What the deployment adds
|
## What the deployment adds
|
||||||
|
|
||||||
- `Dolt Earnings Import (shadow)` runs daily at 02:30 America/New_York.
|
- `Dolt Earnings Import` runs daily at 02:30 America/New_York.
|
||||||
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York. Its local
|
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York, then refreshes
|
||||||
`fundamental_data` refresh runs only when the A5 switch is enabled.
|
`fundamental_data` — the compat cache scoring reads — from stored snapshots,
|
||||||
- `Fundamentals Parity Report (read-only)` runs daily at 05:30 America/New_York.
|
earnings events and closes.
|
||||||
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
|
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
|
||||||
|
**Disabling the SEC job stops its SEC network fetch only**; the local cache
|
||||||
|
refresh still runs, because prices and earnings move daily even when no filing
|
||||||
|
does.
|
||||||
- Cron expressions are editable in Admin → Schedule.
|
- Cron expressions are editable in Admin → Schedule.
|
||||||
- Every attempt is recorded in `data_import_runs`; failures also create a system
|
- Every attempt is recorded in `data_import_runs`; failures also create a system
|
||||||
event. A failed validation does not promote partial data.
|
event. A failed validation does not promote partial data.
|
||||||
@@ -36,14 +40,16 @@ DOLT_EARNINGS_SUBDIR=earnings
|
|||||||
DOLT_MIN_FREE_DISK_GB=5.0
|
DOLT_MIN_FREE_DISK_GB=5.0
|
||||||
SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com)
|
SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com)
|
||||||
SEC_REQUEST_SPACING_SECONDS=0.2
|
SEC_REQUEST_SPACING_SECONDS=0.2
|
||||||
FUNDAMENTALS_PARITY_REPORT_DIR=/var/lib/signal-platform/reports/fundamentals-parity
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
|
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
|
||||||
path; 8–10 GB gives comfortable growth headroom. The data directory must stay
|
path; 8–10 GB gives comfortable growth headroom. The data directory must stay
|
||||||
outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
|
outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
|
||||||
The parity-report directory is also persistent and owned by the service user;
|
|
||||||
its small timestamped JSON/CSV bundles form the temporary A5 review trail.
|
`FMP_API_KEY`, `FINNHUB_API_KEY` and `ALPHA_VANTAGE_API_KEY` must be **removed**
|
||||||
|
from this file. Nothing reads them any more, and leaving them installed is the
|
||||||
|
one thing that would let a rolled-back pre-A6 process resume the legacy
|
||||||
|
collector and overwrite the SEC/Dolt cache.
|
||||||
|
|
||||||
## One-time provisioning
|
## One-time provisioning
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ a reviewed change to `DOLT_VERSION`, followed by the same provision/check flow.
|
|||||||
|
|
||||||
In Admin → Jobs, wait until no other job is running, then:
|
In Admin → Jobs, wait until no other job is running, then:
|
||||||
|
|
||||||
1. Trigger **Dolt Earnings Import (shadow)**. Expect `completed` with import
|
1. Trigger **Dolt Earnings Import**. Expect `completed` with import
|
||||||
status `promoted`; a repeat without an upstream change should report `no_op`.
|
status `promoted`; a repeat without an upstream change should report `no_op`.
|
||||||
2. Trigger **SEC Fundamentals Import**. The first run performs the
|
2. Trigger **SEC Fundamentals Import**. The first run performs the
|
||||||
tracked-universe history backfill and can take materially longer than a daily
|
tracked-universe history backfill and can take materially longer than a daily
|
||||||
@@ -91,27 +97,7 @@ In Admin → Jobs, wait until no other job is running, then:
|
|||||||
data and still handles partial/missing issuers cleanly. A ticker held by the
|
data and still handles partial/missing issuers cleanly. A ticker held by the
|
||||||
quality gate should show **New setups paused** with the specific SEC reason.
|
quality gate should show **New setups paused** with the specific SEC reason.
|
||||||
|
|
||||||
## A5 parity observation window
|
## Verification
|
||||||
|
|
||||||
After both shadow imports are healthy, trigger **Fundamentals Parity Report
|
|
||||||
(read-only)** once in Admin → Jobs. The **A5 Fundamentals Parity** card above
|
|
||||||
the jobs shows the latest coverage/delta summary and provides authenticated JSON
|
|
||||||
and CSV downloads. The canonical server-side bundles are archived at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/var/lib/signal-platform/reports/fundamentals-parity/
|
|
||||||
```
|
|
||||||
|
|
||||||
The scheduler then generates one report daily at 05:30 New York time, after the
|
|
||||||
02:30 Dolt and 04:00 SEC jobs. Review 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.
|
|
||||||
|
|
||||||
Optional database verification:
|
Optional database verification:
|
||||||
|
|
||||||
@@ -162,45 +148,24 @@ Expect `OK: source lock is busy`. This is the remaining live-PostgreSQL
|
|||||||
mutual-exclusion check; SQLite unit tests cannot exercise PostgreSQL advisory
|
mutual-exclusion check; SQLite unit tests cannot exercise PostgreSQL advisory
|
||||||
locks. A second Admin trigger should independently report the job as busy.
|
locks. A second Admin trigger should independently report the job as busy.
|
||||||
|
|
||||||
## A5 production activation (approved 2026-07-24)
|
## The fundamentals cache
|
||||||
|
|
||||||
The write path is controlled by the SystemSetting
|
`fundamental_data` is the compat cache scoring reads. The SEC Fundamentals
|
||||||
`fundamental_data_sec_dolt_cutover_enabled`. An absent value, `false`, or any
|
Import rebuilds it every run from data already in PostgreSQL: newest valid
|
||||||
value other than `true` leaves `fundamental_data` untouched. Before enabling it,
|
snapshots x latest close for `pe_ratio` and `market_cap`, snapshots alone for
|
||||||
confirm the normal PostgreSQL backup containing `fundamental_data` is current.
|
`revenue_growth`, and `earnings_events` for `earnings_surprise` and
|
||||||
|
`next_earnings_date`. It therefore also runs after an SEC network/validation
|
||||||
|
failure, a `no_op`, a source-lock skip, or with the job disabled — no network
|
||||||
|
access is involved. The job message appends the cache row count and the changed
|
||||||
|
score-input count.
|
||||||
|
|
||||||
In **Admin → Settings → Fundamentals data source**:
|
A refresh marks affected fundamental and composite score caches stale. The
|
||||||
|
normal 15:30 near-close scanner recomputes them before using the rankings; until
|
||||||
|
then, reads truthfully expose the stale state.
|
||||||
|
|
||||||
1. Turn on **Use SEC + Dolt for scoring inputs** and accept the confirmation.
|
Verify the refreshed rows:
|
||||||
2. Click **Run refresh now**. The SEC import may be `promoted` or `no_op`; either
|
|
||||||
result runs the local cache refresh.
|
|
||||||
|
|
||||||
The weekly legacy collector is automatically skipped while the switch is on, so
|
|
||||||
it cannot overwrite the activated cache. The switch remains visible even before
|
|
||||||
its SystemSetting row exists because the safe default is off.
|
|
||||||
|
|
||||||
If the Admin UI is unavailable, enable the cutover directly in PostgreSQL:
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
INSERT INTO system_settings (key, value, updated_at)
|
|
||||||
VALUES ('fundamental_data_sec_dolt_cutover_enabled', 'true', now())
|
|
||||||
ON CONFLICT (key) DO UPDATE
|
|
||||||
SET value = EXCLUDED.value, updated_at = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
Then trigger **SEC Fundamentals Import** once in Admin → Jobs. Once enabled, the
|
|
||||||
same refresh also runs after an SEC network/validation failure or a source-lock
|
|
||||||
skip, because it reads only PostgreSQL snapshots, earnings events, and closes.
|
|
||||||
The job message appends the cache row count and changed score-input count when
|
|
||||||
the import itself completed successfully.
|
|
||||||
|
|
||||||
Verify the switch and refreshed rows:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT key, value, updated_at
|
|
||||||
FROM system_settings
|
|
||||||
WHERE key = 'fundamental_data_sec_dolt_cutover_enabled';
|
|
||||||
|
|
||||||
SELECT count(*) AS rows,
|
SELECT count(*) AS rows,
|
||||||
max(fetched_at) AS refreshed_at,
|
max(fetched_at) AS refreshed_at,
|
||||||
count(pe_ratio) AS pe_available,
|
count(pe_ratio) AS pe_available,
|
||||||
@@ -213,28 +178,26 @@ SELECT dimension, is_stale, count(*)
|
|||||||
FROM dimension_scores
|
FROM dimension_scores
|
||||||
WHERE dimension = 'fundamental'
|
WHERE dimension = 'fundamental'
|
||||||
GROUP BY dimension, is_stale;
|
GROUP BY dimension, is_stale;
|
||||||
|
|
||||||
SELECT is_stale, count(*)
|
|
||||||
FROM composite_scores
|
|
||||||
GROUP BY is_stale;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The first refresh intentionally marks affected fundamental and composite score
|
|
||||||
caches stale. The normal 15:30 near-close scanner recomputes them before using
|
|
||||||
the rankings; until then, reads truthfully expose the stale state. Observe at
|
|
||||||
least several scheduled cycles before A6 removes the legacy providers.
|
|
||||||
|
|
||||||
## Failure and rollback
|
## Failure and rollback
|
||||||
|
|
||||||
- To stop the A5 cache writes without stopping SEC snapshot ingestion, turn off
|
- **There is no provider fallback any more, and no Admin switch that freezes the
|
||||||
**Use SEC + Dolt for scoring inputs** in Admin → Settings. If the UI is
|
cache.** Disabling **SEC Fundamentals Import** stops SEC network access only;
|
||||||
unavailable, set `fundamental_data_sec_dolt_cutover_enabled` back to `false`
|
the 04:00 job still rebuilds `fundamental_data` from the stored snapshots,
|
||||||
with the SQL above (changing only the value). This prevents the next local
|
earnings events and closes.
|
||||||
refresh but does not restore rows already replaced. Restore `fundamental_data`
|
- Restoring `fundamental_data` from the PostgreSQL backup is therefore a
|
||||||
from the pre-cutover database backup, or—before A6—manually run the legacy
|
*temporary* fix on its own: if the bad values come from the snapshots or from
|
||||||
Fundamental Collector if its provider keys and quota are still available.
|
the derivation code, the next scheduled run reproduces them. Fix the cause —
|
||||||
- Disable a failing source-import job in Admin → Jobs only when ingestion itself
|
restore or repair `fundamental_snapshots` / `earnings_events`, or revert the
|
||||||
must stop. Existing promoted snapshots/events remain available.
|
parser change and re-run `scripts/reparse_fundamentals.py --apply`.
|
||||||
|
- To genuinely freeze the cache while you work, stop the service
|
||||||
|
(`sudo systemctl stop signalplatform.service`) — that stops the scheduler with
|
||||||
|
it. There is no finer-grained control, by design: a silently frozen scoring
|
||||||
|
input is worse than an obvious outage.
|
||||||
|
- Disable a failing source-import job in Admin → Jobs when SEC network access
|
||||||
|
itself must stop. Existing promoted snapshots and events remain available, and
|
||||||
|
the job's runtime message still reports the cache result.
|
||||||
- Inspect the job runtime, latest `data_import_runs.validation_json`, service
|
- Inspect the job runtime, latest `data_import_runs.validation_json`, service
|
||||||
logs, and Admin → System Events before retrying.
|
logs, and Admin → System Events before retrying.
|
||||||
- `unresolved_filing` is emitted once when a filing enters automatic retry. It
|
- `unresolved_filing` is emitted once when a filing enters automatic retry. It
|
||||||
@@ -250,8 +213,3 @@ least several scheduled cycles before A6 removes the legacy providers.
|
|||||||
- The Dolt clone is a reproducible cache and does not need a bespoke backup.
|
- The Dolt clone is a reproducible cache and does not need a bespoke backup.
|
||||||
PostgreSQL (including `earnings_events`, `fundamental_snapshots`, and import
|
PostgreSQL (including `earnings_events`, `fundamental_snapshots`, and import
|
||||||
audit rows) must remain covered by the normal production database backup.
|
audit rows) must remain covered by the normal production database backup.
|
||||||
- Do not proceed to A6 until the activated cache has completed the observation
|
|
||||||
window and the forward earnings calendar remains timely.
|
|
||||||
- If report generation fails, inspect Admin → System Events and verify
|
|
||||||
`FUNDAMENTALS_PARITY_REPORT_DIR` exists and is writable by `deploy`. Existing
|
|
||||||
reports and all live data remain untouched.
|
|
||||||
|
|||||||
+29
-13
@@ -25,7 +25,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
|||||||
| 1.5× ATR initial stop | Real exit | Cuts losers fast |
|
| 1.5× ATR initial stop | Real exit | Cuts losers fast |
|
||||||
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
|
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
|
||||||
| Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) |
|
| Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) |
|
||||||
| Max 10 concurrent positions, 1% risk per trade | Sizing | The cap binds by signal count, but the focused bracket found negligible opportunity cost: cap 15 admitted every blocked setup and added only 0.0018 R/trade in affected paths. [Findings](portfolio-capacity-bracket-findings.md) |
|
| Max **15** concurrent positions, 1% risk per trade | Sizing | Raised from 10 (2026-08-05) so the count cap never binds: +1.075pp CAGR paired, 51 paths better / 2 worse, drawdown unchanged. Cash plus the 20% notional cap saturates the book near 12. [Findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
|
||||||
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
|
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
|
||||||
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
|
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ invites overfitting.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| ATR trail multiple {1.5–4.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau |
|
| 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 |
|
| 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** — the focused daily bracket found no meaningful gain from cap 15, while weekly rank replacement hurt. [Findings](portfolio-capacity-bracket-findings.md) |
|
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep cutoff 80; book size now 15** — the focused daily bracket found cap 15 worth +1.075pp CAGR (the weekly replay's contrary reading was EV-per-trade). Weekly rank replacement hurt. [Findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
|
||||||
| Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** |
|
| Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** |
|
||||||
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
|
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
|
||||||
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
|
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
|
||||||
@@ -146,7 +146,7 @@ knobs.
|
|||||||
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. −0.048 / t −1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
|
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. −0.048 / t −1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
|
||||||
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
|
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
|
||||||
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
|
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
|
||||||
| **Minimum effective-risk floor** | In cap-never-bound paths, the confounded 0.5% floor arm removed about 8% of fills while EV rose from 0.328 to 0.399 R and PF from 1.60 to 1.75, with exposure nearly unchanged | Run the frozen single-variable cap-10 A/B. [Specification](effective-risk-floor-ab.md) / [capacity findings](portfolio-capacity-bracket-findings.md) |
|
| **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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -198,15 +198,31 @@ qualification. The [daily re-entry matrix](post-stop-reentry.md) supports this
|
|||||||
for the current 10-position book, but not as a universal rule for other
|
for the current 10-position book, but not as a universal rule for other
|
||||||
portfolio capacities.
|
portfolio capacities.
|
||||||
|
|
||||||
Capacity is now closed as a negative result. The current daily Phase A control
|
Capacity is closed **positive**: the count cap was raised 10 → 15 so it no longer
|
||||||
does reject 519 qualified entries because the ten-slot book is full versus 472
|
binds, worth **+1.075pp CAGR** paired across 175 paths (51 better, 2 worse) at
|
||||||
admitted trades, so the older weekly “cap never binds” claim was stale. But the
|
unchanged drawdown. Fifteen is headroom, not a target — cap15 peaked at 12 with
|
||||||
clean cap-15 arm admitted every opportunity the strategy requested and added
|
zero full-book skips, so cash plus the 20% notional cap is the real ceiling.
|
||||||
only 0.0018 R/trade in paths where cap 10 bound. Weekly current-rank replacement
|
|
||||||
reduced mean EV and created substantial churn. Keep cap 10 and do not build the
|
An earlier reading of this run concluded "keep cap 10, added only 0.0018 R/trade."
|
||||||
replacement policy. See the [frozen specification](portfolio-capacity-bracket.md)
|
That was **EV per trade**, which is the wrong metric for a treatment that changes
|
||||||
and the separate [capacity findings](portfolio-capacity-bracket-findings.md).
|
trade *count*: flat EV/trade means the blocked entries were as good as the taken
|
||||||
The only open follow-up from that run is the
|
ones, so refusing them cost their whole contribution to return. Weekly
|
||||||
[frozen confound-free 0.5% minimum effective-risk-floor A/B](effective-risk-floor-ab.md).
|
current-rank replacement remains rejected (−0.043 EV R, 24% churn). The 0.5%
|
||||||
|
effective-risk-floor A/B is **closed negative** without being run — it costs
|
||||||
|
0.75pp of CAGR while raising EV/trade, and its frozen pass rule would have shipped
|
||||||
|
it. See the [frozen specification](portfolio-capacity-bracket.md) and the
|
||||||
|
[capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
|
||||||
|
|
||||||
The next real evidence is **forward**, not backward: the live paper-trade record.
|
The next real evidence is **forward**, not backward: the live paper-trade record.
|
||||||
|
|
||||||
|
## AI/Tech Risk Monitor
|
||||||
|
|
||||||
|
An observational risk thermometer (State + Warning) shown on the Risk page. It
|
||||||
|
gates nothing — no entries, exits, sizing or ranking — so it is not a strategy
|
||||||
|
document, but its calibration follows the same rules as one.
|
||||||
|
|
||||||
|
- [Methodology, v4](regime-monitor-v4.md) — sensors, weights, bands, and the
|
||||||
|
reasoning behind each cut from v2 onward.
|
||||||
|
- Reproduce any number in it with `scripts/run_regime_monitor_calibration.py`,
|
||||||
|
which replays the series offline and refuses to report unless it first
|
||||||
|
reproduces the published v2 and v3 figures.
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
# Effective initial-risk floor A/B - frozen specification
|
# 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
|
Date frozen: 2026-08-05
|
||||||
Branch: research/portfolio-capacity-rebalancing
|
Branch: research/portfolio-capacity-rebalancing (deleted; tag `research/portfolio-capacity-final`)
|
||||||
Runner: scripts/run_portfolio_construction_matrix.py
|
Runner: scripts/run_portfolio_construction_matrix.py (not on main; see tag)
|
||||||
Study ID: risk-floor-ab
|
Study ID: risk-floor-ab
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ Mechanics guards confirmed before reading results: calendar truncation asserted
|
|||||||
skipped_book_full = 519 versus 472 admitted trades, so the ten-slot book
|
skipped_book_full = 519 versus 472 admitted trades, so the ten-slot book
|
||||||
refuses 52.4% of admitted+blocked qualified opportunities. The older weekly
|
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
|
claim that the cap never bound is stale and does not apply to this daily
|
||||||
gate-reset configuration. Capacity is now isolated in the
|
gate-reset configuration. Capacity was isolated in the
|
||||||
[focused bracket study](portfolio-capacity-bracket.md).
|
[focused bracket study](portfolio-capacity-bracket.md) and **resolved: the count
|
||||||
|
cap was raised 10 → 15 so it no longer binds (+1.075pp CAGR paired, 51 paths
|
||||||
|
better / 2 worse, drawdown unchanged).** Note that the blocked *count* was a poor
|
||||||
|
guide in both directions — one path had 244 blocked entries and relieving all of
|
||||||
|
them moved CAGR by −0.1pp. See the
|
||||||
|
[findings correction](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
|
||||||
|
|
||||||
Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta.
|
Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
|
|
||||||
Date interpreted: 2026-08-05
|
Date interpreted: 2026-08-05
|
||||||
|
|
||||||
Status: **capacity and weekly replacement closed as negative results; the
|
Status: **SUPERSEDED IN PART — see [Correction](#correction-2026-08-05-ev-per-trade-was-the-wrong-lens)
|
||||||
minimum effective-risk floor remains an open single-variable follow-up.**
|
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
|
This document interprets the frozen v2 run without modifying its generated
|
||||||
outputs:
|
outputs:
|
||||||
@@ -116,9 +124,96 @@ start-date evidence, but they necessarily mix initialization with market regime.
|
|||||||
|
|
||||||
## Final decisions
|
## Final decisions
|
||||||
|
|
||||||
1. Keep cap 10; its measured opportunity cost is negligible.
|
1. ~~Keep cap 10; its measured opportunity cost is negligible.~~ **REVERSED —
|
||||||
2. Reject weekly rank replacement.
|
see the correction below.**
|
||||||
|
2. Reject weekly rank replacement. *(Stands.)*
|
||||||
3. Do not interpret the `cash_unbounded` improvement as a capacity effect.
|
3. Do not interpret the `cash_unbounded` improvement as a capacity effect.
|
||||||
4. Run only the focused cap-10 effective-risk-floor A/B next.
|
*(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
|
5. Report means, inert fractions, and absolute dispersion beside medians and
|
||||||
ratios in future sparse-treatment studies.
|
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.
|
||||||
|
|||||||
@@ -1,202 +1,6 @@
|
|||||||
# Regime Monitor v3 methodology
|
# Moved
|
||||||
|
|
||||||
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
|
The methodology doc now lives at [regime-monitor-v4.md](regime-monitor-v4.md).
|
||||||
gate entries, exits, position size, ranking, or alerts about individual setups.
|
|
||||||
|
|
||||||
v3 supersedes v2. Every parameter below was calibrated against the 408 v2
|
v3's text is in git history (`git log --follow docs/research/regime-monitor-v4.md`).
|
||||||
sessions ending 2026-07-24, reproduced offline from the same Alpaca and FRED
|
This stub exists because commit messages up to 2026-08-08 cite the old path.
|
||||||
inputs the live job uses; the reproduction matched the stored prod distribution
|
|
||||||
exactly (State avg 22.6/22.7, p80 35.1, max 91.2, P3 pegged 39, W1 live 108).
|
|
||||||
|
|
||||||
## What changed and why
|
|
||||||
|
|
||||||
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
|
|
||||||
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
|
|
||||||
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
|
|
||||||
still inside the "stable" band. The sourced observation could not change any
|
|
||||||
published conclusion, so refreshing it looked like it did nothing. They are now
|
|
||||||
a qualitative overlay reported beside the scores. Capex also stopped scoring
|
|
||||||
`raising` and `holding` identically at 0: `holding` is the deceleration case and
|
|
||||||
now scores 50, so a boom no longer reads the same as a stall.
|
|
||||||
|
|
||||||
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
|
|
||||||
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
|
|
||||||
sessions sat at exactly 100 with no resolution left, and the price pillar showed
|
|
||||||
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
|
|
||||||
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
|
|
||||||
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
|
|
||||||
matching its nominal weight.
|
|
||||||
|
|
||||||
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
|
|
||||||
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
|
|
||||||
in a calm tape. Its 20-session rate of change still does, and spread widening is
|
|
||||||
a classic lead.
|
|
||||||
|
|
||||||
**The credit percentile leg was removed.** Its reference window silently shrank
|
|
||||||
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
|
|
||||||
which it scored 20 points of stress at a spread the same sensor's anchors call
|
|
||||||
"mild". See Calibration below.
|
|
||||||
|
|
||||||
**Breadth loss counts during declines.** v2's divergence gate was
|
|
||||||
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
|
|
||||||
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
|
|
||||||
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
|
|
||||||
fully when price masks it (true divergence, the dangerous pre-top case) and at
|
|
||||||
35% when price confirms it. Breadth *level* lives in State, but breadth
|
|
||||||
*velocity* appears nowhere else, so this is not double counting.
|
|
||||||
|
|
||||||
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
|
|
||||||
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
|
|
||||||
upper half of the Warning axis was unreachable.
|
|
||||||
|
|
||||||
## Outputs
|
|
||||||
|
|
||||||
**State** — current structural stress:
|
|
||||||
|
|
||||||
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
|
|
||||||
- Fixed-basket breadth level, 25%.
|
|
||||||
- HY option-adjusted credit spread level, 20%.
|
|
||||||
- VIX level, 15%.
|
|
||||||
|
|
||||||
**Warning** — deterioration and divergence:
|
|
||||||
|
|
||||||
- Fixed-basket breadth divergence, 45%.
|
|
||||||
- 60-session SMH/SPY relative-strength deterioration, 30%.
|
|
||||||
- HY OAS 20-session widening, 25%.
|
|
||||||
|
|
||||||
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3.
|
|
||||||
|
|
||||||
## Calibration
|
|
||||||
|
|
||||||
P3 drawdown anchors, as (drawdown %, score): 0→0, 4→10, 8→25, 16→50, 28→78,
|
|
||||||
40→100, flat outside. Credit impulse is relative (+35% over 20 sessions = 100)
|
|
||||||
rather than absolute, because +0.5pp means something very different at an OAS of
|
|
||||||
2.7 than at 8.0.
|
|
||||||
|
|
||||||
Bands are round, meaning-anchored numbers, not percentile fits — percentile
|
|
||||||
thresholds would drift on every rebuild and silently rewrite what past snapshots
|
|
||||||
meant. Realized shares over the calibration window:
|
|
||||||
|
|
||||||
| Axis | stable | watch | elevated | breaking | thresholds |
|
|
||||||
|------|--------|-------|----------|----------|------------|
|
|
||||||
| State | 73.3% | 15.0% | 8.3% | 3.4% | 20 / 50 / 80 |
|
|
||||||
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
|
|
||||||
|
|
||||||
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
|
|
||||||
Warning 40.
|
|
||||||
|
|
||||||
Scores renormalize over available fixed weights, but a band is published only at
|
|
||||||
75% or greater coverage. Trend deltas are suppressed when the participating
|
|
||||||
pillar set changes. Zero means ordinary/healthy; only stress contributes.
|
|
||||||
|
|
||||||
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
|
|
||||||
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
|
|
||||||
a 30% upper-tail percentile over a nominally 10-year window.
|
|
||||||
|
|
||||||
That leg was removed rather than repaired. ICE restricted FRED to a rolling
|
|
||||||
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
|
|
||||||
outright ("Starting in April 2026, this series will only include 3 years of
|
|
||||||
observations"), and an unbounded request returns the same 795 observations as a
|
|
||||||
30-year one. The v2 percentile therefore ranked the current spread against three
|
|
||||||
uniformly tight years (range 2.59–4.61 over the calibration window), which made
|
|
||||||
it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors
|
|
||||||
call *mild*, scoring zero stress — the blended sensor read 20.1, and the
|
|
||||||
percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it
|
|
||||||
roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled
|
|
||||||
its nonzero days (60 vs 27).
|
|
||||||
|
|
||||||
The anchors already encode the long-run distribution as constants, so the
|
|
||||||
percentile was a second, noisier estimate of the same thing. What it was
|
|
||||||
genuinely reaching for — "unusual versus recent history" — is now W3 on the
|
|
||||||
Warning axis, computed as a rate of change, which is where deterioration
|
|
||||||
belongs. Removing it moved State's average by −0.4 and its maximum by −3.8, left
|
|
||||||
Warning bit-identical, and did not shift any band threshold.
|
|
||||||
|
|
||||||
A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to
|
|
||||||
1997) was considered and rejected: ranking an HY spread against investment-grade
|
|
||||||
history is not a coherent statistic, and it would rescue a leg that is redundant
|
|
||||||
anyway.
|
|
||||||
|
|
||||||
Every snapshot now records `data_quality.credit_history_days` and
|
|
||||||
`vix_history_days`. This defect was invisible for roughly three months because
|
|
||||||
nothing asserted the window the code claimed; the spans make a future upstream
|
|
||||||
truncation show up in the record instead of quietly reshaping a sensor.
|
|
||||||
|
|
||||||
**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration
|
|
||||||
window reaches back to 2024, so names were partly selected for having done well.
|
|
||||||
Every distribution above inherits that bias. It is the same bias v2 carried, so
|
|
||||||
the v2/v3 comparison is like-for-like, but the absolute band shares are
|
|
||||||
optimistic.
|
|
||||||
|
|
||||||
## Point-in-time record
|
|
||||||
|
|
||||||
The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions
|
|
||||||
with sufficient sensor warm-up; routine runs thereafter insert/update only the
|
|
||||||
latest trading date. The history API and main chart show only snapshots matching
|
|
||||||
the current methodology, so a bump reseeds the series rather than splicing two
|
|
||||||
formulas into one line.
|
|
||||||
|
|
||||||
The fundamental overlay keeps its effective date (normally the next session after
|
|
||||||
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
|
||||||
observation onto historical snapshots. Because the observation is stored in a
|
|
||||||
single slot, a refresh replaces the previously effective record: the snapshot
|
|
||||||
therefore reports the overlay as `pending` until the new effective date, and the
|
|
||||||
live reading additionally carries `fundamental_context` so a just-collected
|
|
||||||
observation is visible immediately rather than appearing to have done nothing.
|
|
||||||
|
|
||||||
Each snapshot stores the fixed basket symbols, hash, and freeze date.
|
|
||||||
Reconstructed history before that freeze date is retrospective/exploratory.
|
|
||||||
|
|
||||||
## Warning study
|
|
||||||
|
|
||||||
The study calls the outcome a **10% correction**, not a regime break. The first
|
|
||||||
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
|
|
||||||
measured on the final 30%. Because v3 dropped fundamentals from the score, the
|
|
||||||
study now measures exactly the live Warning score rather than a technical-only
|
|
||||||
approximation of it, and both are computed from one shared sensor definition
|
|
||||||
(`warning_sensor_scores`) so they cannot drift apart.
|
|
||||||
|
|
||||||
A cached report is discarded when its methodology no longer matches, so the panel
|
|
||||||
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
|
|
||||||
the Event Study job after cutting over to v3.**
|
|
||||||
|
|
||||||
### Reading the result
|
|
||||||
|
|
||||||
The report carries a `reliability` block and the UI renders its warnings, because
|
|
||||||
the headline numbers invite over-reading in two specific ways.
|
|
||||||
|
|
||||||
**The holdout is thin.** The study detects 11 corrections across 5 years but the
|
|
||||||
70/30 split leaves only 4 in the test period. Recall is therefore one event away
|
|
||||||
from a materially different headline, and in practice the event that flips is
|
|
||||||
decided by where the frozen threshold happens to land rather than by whether the
|
|
||||||
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
|
|
||||||
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
|
|
||||||
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
|
|
||||||
rising edge, and a lower threshold can mean the alarm already fired outside the
|
|
||||||
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
|
|
||||||
holdout events the report says so explicitly.
|
|
||||||
|
|
||||||
Some events carry no information at all for comparison: in that run every
|
|
||||||
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
|
|
||||||
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
|
|
||||||
warning.
|
|
||||||
|
|
||||||
**Sensor coverage can straddle the split.** The score renormalises over available
|
|
||||||
sensors, so a training window predating a sensor's history freezes the threshold
|
|
||||||
on a different construct than the holdout is measured against. At the v3 cutover
|
|
||||||
only 39% of training sessions had all three Warning sensors versus 100% of the
|
|
||||||
test period, because credit history begins 2023-07-25.
|
|
||||||
|
|
||||||
Restricting the threshold to sensor-matched training sessions was tried and is
|
|
||||||
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
|
|
||||||
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
|
|
||||||
coverage bias for a regime-selection bias. The honest position is that the
|
|
||||||
threshold is hypersensitive to window choice at this sample size; the report
|
|
||||||
states its limits rather than pretending to a precision it does not have.
|
|
||||||
|
|
||||||
## Operator rule
|
|
||||||
|
|
||||||
Quadrant alerts default off for new/reset configurations. When enabled they
|
|
||||||
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
|
|
||||||
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
|
|
||||||
not a trade signal.**
|
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# AI/Tech Risk Monitor v4 methodology
|
||||||
|
|
||||||
|
Named "Regime Monitor" until 2026-08-07; the filename's `regime` stem, the
|
||||||
|
`regime_monitor` job id, the `/regime` route and the `METHODOLOGY`/snapshot
|
||||||
|
fields keep the old word, because those are persisted or externally linked.
|
||||||
|
|
||||||
|
The AI/Tech Risk Monitor is an observational risk thermometer. It does not
|
||||||
|
gate entries, exits, position size, ranking, or alerts about individual setups.
|
||||||
|
|
||||||
|
**v4 supersedes v3** (2026-08-08). Unlike v3, whose calibration was ad-hoc and
|
||||||
|
never landed, every number below is reproducible:
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/Scripts/python.exe scripts/run_regime_monitor_calibration.py --methodology v2_reconstruction,v2_reconstruction_oas400,v3,v4,v4-vix-only,v4-p1-only --cache-dir .calib-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
`v3` and `v4` are mandatory — the row-wise `state_v4 <= state_v3` invariant is
|
||||||
|
a hard gate and needs both — and the replayed **start** date is asserted
|
||||||
|
against the published window. The session *count* alone proves nothing, since
|
||||||
|
the harness slices the tail of the price series to whatever was asked for.
|
||||||
|
|
||||||
|
The harness replays the 408 sessions ending 2026-07-24 from the live inputs
|
||||||
|
(Alpaca for all 33 symbols, FRED for VIX and HY OAS) with no database, and
|
||||||
|
reproduces the published v2 and v3 figures before it will emit anything:
|
||||||
|
|
||||||
|
| figure | published | replayed |
|
||||||
|
|---|---|---|
|
||||||
|
| v2 State avg | 22.6 | 22.68 |
|
||||||
|
| v2 State p80 | 35.1 | **35.1** |
|
||||||
|
| v2 State max | 91.2 | **91.2** |
|
||||||
|
| v2 P3 pegged | 39 | **39** |
|
||||||
|
| v2 W1 live | 108 | **108** |
|
||||||
|
| v3 State max | 87.4 | **87.4** |
|
||||||
|
| v3 band shares | 73.3 / 15.0 / 8.3 / 3.4 | 73.0 / 15.4 / 8.1 / 3.4 |
|
||||||
|
|
||||||
|
It refuses to emit a band recommendation, and exits non-zero, unless every hard
|
||||||
|
gate passes — 33 symbols fetched with full warm-up, the whole basket on every
|
||||||
|
session, the calendar anchors, 100% coverage on every row, and a row-wise
|
||||||
|
`state_v4 <= state_v3` invariant. Reading a calibration result out of a run whose
|
||||||
|
pipeline did not validate is meant to be structurally impossible.
|
||||||
|
|
||||||
|
## What changed in v4
|
||||||
|
|
||||||
|
**V1 stopped saturating at VIX 30.** `(vix - 15) / 15` reached 100 at VIX 30 —
|
||||||
|
the same defect v3 had *just* removed from P3, left in place one sensor over. VIX
|
||||||
|
30 is a bad week, 50 is a crisis and 82 was March 2020, and all three scored
|
||||||
|
identically. In the calibration window this flattened five distinct April-2025
|
||||||
|
prints (52.33, 46.98, 45.31, 40.72, 38.57) into a single 100. It pegged on 14 of
|
||||||
|
408 sessions; under the anchors below, none.
|
||||||
|
|
||||||
|
**The trend break is graded by depth, not a yes/no.** `_under_200` returned a
|
||||||
|
bare 0/100, so P1 printed 100 the moment SMH and QQQ were both under their
|
||||||
|
average — and because the price pillar takes `max(P1, P2, P3)`, that pinned the
|
||||||
|
pillar and stopped P3's anchored ladder resolving anything for the whole of a
|
||||||
|
selloff. It pegged on 46 of 408 sessions; now none. A 2% break reads ~30 where it
|
||||||
|
used to read 100.
|
||||||
|
|
||||||
|
`max()` was **kept**. The defect was the step function feeding it, not the vote
|
||||||
|
itself, and v3's "one capped vote for correlated reads" rationale still holds.
|
||||||
|
The `P1_SCORE_CAP` fallback drafted during design was to fire if P1 became the
|
||||||
|
sole price argmax on **more than 80% of sessions with State ≥ 40** — i.e. if it
|
||||||
|
had quietly become a second drawdown sensor. Measured on that population: 47
|
||||||
|
qualifying sessions, P1 sole argmax on **17 of them (36.2%)**, against P2's 16
|
||||||
|
and P3's 14. Well under the threshold, so the cap is not shipped.
|
||||||
|
|
||||||
|
**The top State band moved 80 → 65.** See Calibration; this is the one change
|
||||||
|
that is about the band rather than a sensor.
|
||||||
|
|
||||||
|
**Scope.** All three are State-side. `WARNING_BANDS`, `WARNING_WEIGHTS`,
|
||||||
|
`QUADRANT_WARNING_DIVIDER` and the event study's frozen threshold are untouched.
|
||||||
|
`QUADRANT_STATE_DIVIDER` stays 50 because only `breaking` moved.
|
||||||
|
|
||||||
|
## What changed in v3
|
||||||
|
|
||||||
|
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
|
||||||
|
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
|
||||||
|
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
|
||||||
|
still inside the "stable" band. The sourced observation could not change any
|
||||||
|
published conclusion, so refreshing it looked like it did nothing. They are now
|
||||||
|
a qualitative overlay reported beside the scores. Capex also stopped scoring
|
||||||
|
`raising` and `holding` identically at 0: `holding` is the deceleration case and
|
||||||
|
now scores 50, so a boom no longer reads the same as a stall.
|
||||||
|
|
||||||
|
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
|
||||||
|
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
|
||||||
|
sessions sat at exactly 100 with no resolution left, and the price pillar showed
|
||||||
|
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
|
||||||
|
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
|
||||||
|
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
|
||||||
|
matching its nominal weight.
|
||||||
|
|
||||||
|
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
|
||||||
|
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
|
||||||
|
in a calm tape. Its 20-session rate of change still does, and spread widening is
|
||||||
|
a classic lead.
|
||||||
|
|
||||||
|
**The credit percentile leg was removed.** Its reference window silently shrank
|
||||||
|
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
|
||||||
|
which it scored 20 points of stress at a spread the same sensor's anchors call
|
||||||
|
"mild". See Calibration below.
|
||||||
|
|
||||||
|
**Breadth loss counts during declines.** v2's divergence gate was
|
||||||
|
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
|
||||||
|
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
|
||||||
|
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
|
||||||
|
fully when price masks it (true divergence, the dangerous pre-top case) and at
|
||||||
|
35% when price confirms it. Breadth *level* lives in State, but breadth
|
||||||
|
*velocity* appears nowhere else, so this is not double counting.
|
||||||
|
|
||||||
|
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
|
||||||
|
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
|
||||||
|
upper half of the Warning axis was unreachable.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
**State** — current structural stress:
|
||||||
|
|
||||||
|
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
|
||||||
|
- Fixed-basket breadth level, 25%.
|
||||||
|
- HY option-adjusted credit spread level, 20%.
|
||||||
|
- VIX level, 15%.
|
||||||
|
|
||||||
|
**Warning** — deterioration and divergence:
|
||||||
|
|
||||||
|
- Fixed-basket breadth divergence, 45%.
|
||||||
|
- 60-session SMH/SPY relative-strength deterioration, 30%.
|
||||||
|
- HY OAS 20-session widening, 25%.
|
||||||
|
|
||||||
|
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3 or v4.
|
||||||
|
|
||||||
|
## Calibration
|
||||||
|
|
||||||
|
### Interpolated sensor tables
|
||||||
|
|
||||||
|
All three are `(x, stress score)` pairs read by `_interpolate`, flat outside the
|
||||||
|
first and last anchor.
|
||||||
|
|
||||||
|
| sensor | anchors |
|
||||||
|
|---|---|
|
||||||
|
| P3 drawdown (% below the 52w high) | 0→0, 4→10, 8→25, 16→50, 28→78, 40→100 |
|
||||||
|
| **P1 trend break** (% below the 200-DMA) | 0→**20**, 3→35, 8→55, 15→75, 25→100 |
|
||||||
|
| **V1 volatility** (VIX level) | 15→0, 20→20, 25→38, 30→55, 40→80, 55→100 |
|
||||||
|
|
||||||
|
P1's floor of 20 at the crossing is deliberate: the break itself is a genuine
|
||||||
|
binary event and deserves a floor; only the depth past it is graded. P1 is
|
||||||
|
calibrated to sit alongside P3 rather than swamp it — the 200-DMA lags, so a 20%
|
||||||
|
drawdown typically coincides with ~10% below the average, where P1 reads ~61
|
||||||
|
against P3's ~59.
|
||||||
|
|
||||||
|
V1 reaches full scale at 55 rather than at 2020's ~82: anchoring the top at a
|
||||||
|
once-in-a-generation print would make VIX 50 — a genuine crisis — read only ~70.
|
||||||
|
The anchors encode the long-run distribution as constants, the same argument the
|
||||||
|
credit level uses. Unlike P1 and V1, whose slopes ease off monotonically, P3's do
|
||||||
|
not (2.5, 3.75, 3.125, 2.33, 1.83) — its gentle onset is intentional and the
|
||||||
|
monotone-slope test excludes it.
|
||||||
|
|
||||||
|
Credit impulse is relative (+35% over 20 sessions = 100) rather than absolute,
|
||||||
|
because +0.5pp means something very different at an OAS of 2.7 than at 8.0.
|
||||||
|
|
||||||
|
### Bands
|
||||||
|
|
||||||
|
Round, meaning-anchored numbers, **not** percentile fits — those would drift on
|
||||||
|
every rebuild and silently rewrite what past snapshots meant.
|
||||||
|
|
||||||
|
**Why `breaking` moved 80 → 65.** With credit calm, `f2_credit_spreads` returns
|
||||||
|
`0.0` (not `None`), so it keeps its full 20 points pinned at zero. Price, breadth
|
||||||
|
and volatility at *literal maximum* therefore sum to:
|
||||||
|
|
||||||
|
(100×40 + 100×25 + 0×20 + 100×15) / 100 = 80.0 exactly
|
||||||
|
|
||||||
|
`band_for` uses `>=`, so v3's top band was reachable only by touching its floor
|
||||||
|
to the decimal, with nothing above it. The band was fit on v2, when credit's
|
||||||
|
since-removed percentile leg still contributed regularly; the sensor is not
|
||||||
|
wrong — a calm-credit selloff genuinely *is* less stressed than one with credit
|
||||||
|
contagion — the threshold was stale.
|
||||||
|
|
||||||
|
Chosen by scenario arithmetic on unchanged weights (`_scenarios` in the harness
|
||||||
|
computes these, so they are machine-checked, not prose):
|
||||||
|
|
||||||
|
| scenario | price | breadth | C1 | V1 | State |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Ordinary tape (3% dd, breadth 65%, VIX 16, OAS 2.8) | 7.5 | 0 | 0 | 4.0 | **3.6** |
|
||||||
|
| 10% correction, calm credit (2% below, breadth 35%, VIX 24) | 31.2 | 62.5 | 0 | 34.4 | **33.3** |
|
||||||
|
| **2022-style drawdown, calm credit, no death cross** | 90.8 | 100 | 0 | 60.0 | **70.3** |
|
||||||
|
| **same, with death cross** (P2 pegged) | 100 | 100 | 0 | 60.0 | **74.0** |
|
||||||
|
| Credit event on top (OAS 6.0, VIX 45) | 100 | 100 | 75.0 | 86.7 | **93.0** |
|
||||||
|
| March 2020 (everything pegged) | 100 | 100 | 100 | 100 | **100** |
|
||||||
|
|
||||||
|
Rows 3 and 4 are the case this monitor exists to measure, and they must print
|
||||||
|
`breaking`. At 80 they do not. **65** clears them under either P2 assumption,
|
||||||
|
which matters because P2 is set by the 50/200-DMA gap and no drawdown figure
|
||||||
|
implies it; 70 would have left 0.33 points of headroom in row 3, reproducing the
|
||||||
|
defect being fixed.
|
||||||
|
|
||||||
|
Realized shares, **reported not fitted**, over the 408 sessions to 2026-07-24:
|
||||||
|
|
||||||
|
| Axis | stable | watch | elevated | breaking | thresholds |
|
||||||
|
|------|--------|-------|----------|----------|------------|
|
||||||
|
| State (v4) | 78.9% | 13.0% | 4.7% | **3.4%** | 20 / 50 / **65** |
|
||||||
|
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
|
||||||
|
|
||||||
|
The v4 `breaking` share lands on 3.4% — the same as v3's — having been chosen by
|
||||||
|
scenario reasoning rather than aimed at that number. Sensitivity: 60 gives 5.1%,
|
||||||
|
70 gives 1.2%.
|
||||||
|
|
||||||
|
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
|
||||||
|
Warning 40. Only `breaking` moved in v4, so the dividers and every alert
|
||||||
|
threshold are unchanged. `test_quadrant_dividers_match_the_band_boundaries` now
|
||||||
|
enforces that relationship, which nothing did before.
|
||||||
|
|
||||||
|
Scores renormalize over available fixed weights, but a band is published only at
|
||||||
|
75% or greater coverage. Trend deltas are suppressed when the participating
|
||||||
|
pillar set changes. Zero means ordinary/healthy; only stress contributes.
|
||||||
|
|
||||||
|
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
|
||||||
|
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
|
||||||
|
a 30% upper-tail percentile over a nominally 10-year window.
|
||||||
|
|
||||||
|
That leg was removed rather than repaired. ICE restricted FRED to a rolling
|
||||||
|
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
|
||||||
|
outright ("Starting in April 2026, this series will only include 3 years of
|
||||||
|
observations"), and an unbounded request returns the same 795 observations as a
|
||||||
|
30-year one. The v2 percentile therefore ranked the current spread against three
|
||||||
|
uniformly tight years (range 2.59–4.61 over the calibration window), which made
|
||||||
|
it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors
|
||||||
|
call *mild*, scoring zero stress — the blended sensor read 20.1, and the
|
||||||
|
percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it
|
||||||
|
roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled
|
||||||
|
its nonzero days (60 vs 27).
|
||||||
|
|
||||||
|
The anchors already encode the long-run distribution as constants, so the
|
||||||
|
percentile was a second, noisier estimate of the same thing. What it was
|
||||||
|
genuinely reaching for — "unusual versus recent history" — is now W3 on the
|
||||||
|
Warning axis, computed as a rate of change, which is where deterioration
|
||||||
|
belongs. Removing it moved State's average by −0.4 and its maximum by −3.8, left
|
||||||
|
Warning bit-identical, and did not shift any band threshold.
|
||||||
|
|
||||||
|
A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to
|
||||||
|
1997) was considered and rejected: ranking an HY spread against investment-grade
|
||||||
|
history is not a coherent statistic, and it would rescue a leg that is redundant
|
||||||
|
anyway.
|
||||||
|
|
||||||
|
Every snapshot now records `data_quality.credit_history_days` and
|
||||||
|
`vix_history_days`. This defect was invisible for roughly three months because
|
||||||
|
nothing asserted the window the code claimed; the spans make a future upstream
|
||||||
|
truncation show up in the record instead of quietly reshaping a sensor.
|
||||||
|
|
||||||
|
**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration
|
||||||
|
window reaches back to 2024, so names were partly selected for having done well.
|
||||||
|
Every distribution above inherits that bias. It is the same bias v2 carried, so
|
||||||
|
the v2/v3 comparison is like-for-like, but the absolute band shares are
|
||||||
|
optimistic.
|
||||||
|
|
||||||
|
**Which OAS window the published v2 figures used.** v2 requested 13 years of HY
|
||||||
|
OAS and sliced `HY_OAS_REFERENCE_YEARS = 10.0` per session; ICE serves only ~3
|
||||||
|
years (778 observations from 2023-08-08), so the effective window was that. But
|
||||||
|
production v2 also fetched only 400 *calendar* days at one point — the bug fixed
|
||||||
|
2026-08-07 — and whether the published numbers predate that was not recoverable
|
||||||
|
from the text. Settled by replay rather than assumed: the
|
||||||
|
`v2_reconstruction_oas400` variant truncates the OAS **source series** to 400
|
||||||
|
days (patching the per-session window cannot simulate data that was simply
|
||||||
|
absent) and yields avg 26.54, p80 42.52, max **100.00**, against published
|
||||||
|
22.6 / 35.1 / 91.2. Full coverage reproduces all three. So the published figures
|
||||||
|
correspond to the untruncated fetch.
|
||||||
|
|
||||||
|
**The top VIX anchors are exercised, not just asserted.** The window contains a
|
||||||
|
52.33 close (2025-04-08), so the 40 → 80 → 55 → 100 segment is fed by real data
|
||||||
|
rather than justified from long-run history alone.
|
||||||
|
|
||||||
|
## Point-in-time record
|
||||||
|
|
||||||
|
The first run under a new `METHODOLOGY` rebuilds every session inside
|
||||||
|
`REBUILD_LOOKBACK_DAYS` — 672 calendar days, roughly 464 trading sessions;
|
||||||
|
routine runs thereafter insert/update only the latest trading date. The bound is
|
||||||
|
in calendar days rather than a session count because the binding constraint is
|
||||||
|
the OAS fetch: each replayed row needs W3's lookback inside
|
||||||
|
`HY_OAS_WINDOW_DAYS`, so replaying further back would recreate the credit gap a
|
||||||
|
reseed exists to close. The history API and main chart show only snapshots matching
|
||||||
|
the current methodology, so a bump reseeds the series rather than splicing two
|
||||||
|
formulas into one line.
|
||||||
|
|
||||||
|
The fundamental overlay keeps its effective date (normally the next session after
|
||||||
|
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
||||||
|
observation onto historical snapshots. Because the observation is stored in a
|
||||||
|
single slot, a refresh replaces the previously effective record: the snapshot
|
||||||
|
therefore reports the overlay as `pending` until the new effective date.
|
||||||
|
|
||||||
|
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
|
||||||
|
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
|
||||||
|
measured on the final 30%. Because v3 dropped fundamentals from the score, the
|
||||||
|
study now measures exactly the live Warning score rather than a technical-only
|
||||||
|
approximation of it, and both are computed from one shared sensor definition
|
||||||
|
(`warning_sensor_scores`) so they cannot drift apart.
|
||||||
|
|
||||||
|
A cached report is discarded when its methodology no longer matches, so the panel
|
||||||
|
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
|
||||||
|
the Event Study job after cutting over to v4.**
|
||||||
|
|
||||||
|
### Reading the result
|
||||||
|
|
||||||
|
The report carries a `reliability` block and the UI renders its warnings, because
|
||||||
|
the headline numbers invite over-reading in two specific ways.
|
||||||
|
|
||||||
|
**The holdout is thin.** The study detects 11 corrections across 5 years but the
|
||||||
|
70/30 split leaves only 4 in the test period. Recall is therefore one event away
|
||||||
|
from a materially different headline, and in practice the event that flips is
|
||||||
|
decided by where the frozen threshold happens to land rather than by whether the
|
||||||
|
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
|
||||||
|
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
|
||||||
|
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
|
||||||
|
rising edge, and a lower threshold can mean the alarm already fired outside the
|
||||||
|
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
|
||||||
|
holdout events the report says so explicitly.
|
||||||
|
|
||||||
|
Some events carry no information at all for comparison: in that run every
|
||||||
|
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
|
||||||
|
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
|
||||||
|
warning.
|
||||||
|
|
||||||
|
**Sensor coverage can straddle the split.** The score renormalises over available
|
||||||
|
sensors, so a training window predating a sensor's history freezes the threshold
|
||||||
|
on a different construct than the holdout is measured against. At the v3 cutover
|
||||||
|
only 39% of training sessions had all three Warning sensors versus 100% of the
|
||||||
|
test period, because credit history begins 2023-07-25.
|
||||||
|
|
||||||
|
Restricting the threshold to sensor-matched training sessions was tried and is
|
||||||
|
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
|
||||||
|
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
|
||||||
|
coverage bias for a regime-selection bias. The honest position is that the
|
||||||
|
threshold is hypersensitive to window choice at this sample size; the report
|
||||||
|
states its limits rather than pretending to a precision it does not have.
|
||||||
|
|
||||||
|
## Resolved in v4 (raised 2026-08-07, shipped 2026-08-08)
|
||||||
|
|
||||||
|
The three questions this section used to hold are now answered. Kept here
|
||||||
|
because the reasoning that resolved them is not obvious from the code.
|
||||||
|
|
||||||
|
**1. `breaking` had zero headroom — resolved by moving the band, not the sensor.**
|
||||||
|
`f2_credit_spreads` returns `0.0`, not `None`, below the 3.5 mild anchor, so
|
||||||
|
credit stays *available* at weight 20 and is pinned at zero on roughly 93% of
|
||||||
|
sessions rather than being renormalized out. Price + breadth + volatility at
|
||||||
|
literal maximum therefore summed to exactly 80.0 — v3's threshold, to the
|
||||||
|
decimal.
|
||||||
|
|
||||||
|
The sensor is **deliberately unchanged**. A calm-credit selloff genuinely is less
|
||||||
|
stressed than one with credit contagion, so scoring it lower is correct; what was
|
||||||
|
stale was `STATE_BANDS`, fit on v2 while credit's since-removed percentile leg
|
||||||
|
still contributed. Making credit `None` when calm was considered and rejected: it
|
||||||
|
would leave State on 80% coverage, which still publishes, but consumes the whole
|
||||||
|
buffer — any *second* missing pillar would then suppress the band, and the 7d/30d
|
||||||
|
trend deltas would null out every time OAS crossed 3.5, because `_delta`
|
||||||
|
suppresses on a change of participating pillars. See Calibration for the
|
||||||
|
scenario arithmetic behind 65.
|
||||||
|
|
||||||
|
**2. V1 saturated at VIX 30 — resolved with an anchor table.** See "What changed
|
||||||
|
in v4".
|
||||||
|
|
||||||
|
**3. `max(P1, P2, P3)` defeated P3's anchoring — resolved by grading `_under_200`,
|
||||||
|
keeping `max()`.** The `max` was deliberate ("one capped vote for correlated
|
||||||
|
reads") and survives; the binary step feeding it was the defect.
|
||||||
|
|
||||||
|
**Its limit, stated precisely.** `_death_cross` is `clamp(-gap_pct * 20)`, so P2
|
||||||
|
pegs at a −5% 50/200-DMA gap — routine in a real downtrend. In a *deep* selloff
|
||||||
|
the price pillar therefore still reaches 100 via P2 even with P1 graded. What v4
|
||||||
|
repairs is the shallow-to-moderate break, which is where resolution was most
|
||||||
|
obviously missing: a 10% correction 2% below the average now scores 31 where v3
|
||||||
|
scored 100. It would be wrong to claim "the price pillar no longer pegs".
|
||||||
|
|
||||||
|
P2 did not peg once in the 408-session calibration window, so this is a property
|
||||||
|
of the sensor rather than an observed problem. Grading P2 the same way is the
|
||||||
|
natural next item if it starts binding; the replay reports a P2-pegged census
|
||||||
|
alongside P3 and V1 so the evidence accumulates.
|
||||||
|
|
||||||
|
## 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 bumped
|
||||||
|
`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
|
||||||
|
rows into the fresh series. Fixing the window first meant the v4 reseed replayed
|
||||||
|
a clean window; doing it the other way round would have meant reseeding twice.
|
||||||
|
|
||||||
|
## Operator rule
|
||||||
|
|
||||||
|
Quadrant alerts default off for new/reset configurations. When enabled they
|
||||||
|
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
|
||||||
|
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
|
||||||
|
not a trade signal.**
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>FundamentalsPanel harness</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0b11] text-gray-100 font-sans">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/dev/harness.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+23
-67
@@ -4,7 +4,6 @@ import type {
|
|||||||
AdminUser,
|
AdminUser,
|
||||||
AlertConfig,
|
AlertConfig,
|
||||||
AlertTestResult,
|
AlertTestResult,
|
||||||
FundamentalsCutoverConfig,
|
|
||||||
PipelineReadiness,
|
PipelineReadiness,
|
||||||
RecommendationConfig,
|
RecommendationConfig,
|
||||||
ScheduleConfig,
|
ScheduleConfig,
|
||||||
@@ -57,18 +56,6 @@ export function updateSetting(key: string, value: string) {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFundamentalsCutoverSettings() {
|
|
||||||
return apiClient
|
|
||||||
.get<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover')
|
|
||||||
.then((r) => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateFundamentalsCutoverSettings(enabled: boolean) {
|
|
||||||
return apiClient
|
|
||||||
.put<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover', { enabled })
|
|
||||||
.then((r) => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRecommendationSettings() {
|
export function getRecommendationSettings() {
|
||||||
return apiClient
|
return apiClient
|
||||||
.get<RecommendationConfig>('admin/settings/recommendations')
|
.get<RecommendationConfig>('admin/settings/recommendations')
|
||||||
@@ -220,14 +207,28 @@ export function backfillTickerNames() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
export type JobCategory = 'pipeline' | 'pipeline_step' | 'scheduled' | 'manual';
|
||||||
|
export type NextRunSource = 'own_schedule' | 'via_pipeline' | 'manual_only';
|
||||||
|
|
||||||
export interface JobStatus {
|
export interface JobStatus {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
next_run_at: string | null;
|
|
||||||
via_pipeline?: boolean;
|
|
||||||
registered: 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;
|
running?: boolean;
|
||||||
|
/** runtime_* is live, in-memory state only — it resets when the app restarts. */
|
||||||
runtime_status?: string | null;
|
runtime_status?: string | null;
|
||||||
runtime_processed?: number | null;
|
runtime_processed?: number | null;
|
||||||
runtime_total?: number | null;
|
runtime_total?: number | null;
|
||||||
@@ -236,6 +237,13 @@ export interface JobStatus {
|
|||||||
runtime_started_at?: string | null;
|
runtime_started_at?: string | null;
|
||||||
runtime_finished_at?: string | null;
|
runtime_finished_at?: string | null;
|
||||||
runtime_message?: 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 {
|
export interface TriggerJobResponse {
|
||||||
@@ -246,40 +254,6 @@ export interface TriggerJobResponse {
|
|||||||
cadence?: BacktestCadence;
|
cadence?: BacktestCadence;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParityFieldStats {
|
|
||||||
legacy_available: number;
|
|
||||||
candidate_available: number;
|
|
||||||
both_available: number;
|
|
||||||
material_differences: number;
|
|
||||||
median_absolute_delta: number | null;
|
|
||||||
p95_absolute_delta: number | null;
|
|
||||||
max_absolute_delta: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FundamentalsParityReport {
|
|
||||||
report_version: number;
|
|
||||||
generated_at: string;
|
|
||||||
as_of_date: string;
|
|
||||||
approval_status: string;
|
|
||||||
read_only: boolean;
|
|
||||||
summary: {
|
|
||||||
universe_count: number;
|
|
||||||
legacy_fundamental_score_available: number;
|
|
||||||
candidate_fundamental_score_available: number;
|
|
||||||
fundamental_scores_compared: number;
|
|
||||||
fundamental_score_material_changes: number;
|
|
||||||
fundamental_rank_changes: number;
|
|
||||||
field_stats: Record<string, ParityFieldStats>;
|
|
||||||
};
|
|
||||||
source_runs: Record<string, {
|
|
||||||
run_id: number;
|
|
||||||
status: string;
|
|
||||||
revision: string | null;
|
|
||||||
source_max_date: string | null;
|
|
||||||
completed_at: string | null;
|
|
||||||
} | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
||||||
export type BacktestCadence = 'weekly' | 'daily';
|
export type BacktestCadence = 'weekly' | 'daily';
|
||||||
|
|
||||||
@@ -306,24 +280,6 @@ export function triggerJob(
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFundamentalsParityReport() {
|
|
||||||
return apiClient
|
|
||||||
.get<FundamentalsParityReport | null>('admin/fundamentals-parity')
|
|
||||||
.then((r) => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFundamentalsParityCsv() {
|
|
||||||
return apiClient
|
|
||||||
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/csv')
|
|
||||||
.then((r) => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFundamentalsParityJson() {
|
|
||||||
return apiClient
|
|
||||||
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/json')
|
|
||||||
.then((r) => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// System events (operational warnings / errors)
|
// System events (operational warnings / errors)
|
||||||
export interface SystemEvent {
|
export interface SystemEvent {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../stores/authStore';
|
|||||||
* Typed error class for API errors, providing structured error handling
|
* Typed error class for API errors, providing structured error handling
|
||||||
* across the application.
|
* across the application.
|
||||||
*/
|
*/
|
||||||
export class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface FetchDataResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Provider sources that cost an API call/quota. */
|
/** Provider sources that cost an API call/quota. */
|
||||||
export type FetchSource = 'ohlcv' | 'sentiment' | 'fundamentals';
|
export type FetchSource = 'ohlcv' | 'sentiment';
|
||||||
/** Source selector: omit → fetch all; array → those providers; 'recompute' → derived only (free). */
|
/** Source selector: omit → fetch all; array → those providers; 'recompute' → derived only (free). */
|
||||||
export type FetchSelector = FetchSource[] | 'recompute';
|
export type FetchSelector = FetchSource[] | 'recompute';
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ export interface EquityPoint {
|
|||||||
benchmark_pnl: number;
|
benchmark_pnl: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEquityCurve() {
|
|
||||||
return apiClient.get<EquityPoint[]>('paper-trades/equity-curve').then((r) => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PerfPoint {
|
export interface PerfPoint {
|
||||||
date: string;
|
date: string;
|
||||||
manual_pnl: number;
|
manual_pnl: number;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
|
|||||||
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
|
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
|
||||||
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched ticker’s composite drops sharply' },
|
{ 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: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
|
||||||
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' },
|
{ key: 'regime_quadrant_enabled', label: 'Risk quadrant change', hint: 'the AI/Tech risk monitor shifts quadrant (hysteresis + cooldown)' },
|
||||||
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
|
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import {
|
|
||||||
useFundamentalsCutoverSettings,
|
|
||||||
useJobs,
|
|
||||||
useTriggerJob,
|
|
||||||
useUpdateFundamentalsCutoverSettings,
|
|
||||||
} from '../../hooks/useAdmin';
|
|
||||||
import { SkeletonCard } from '../ui/Skeleton';
|
|
||||||
|
|
||||||
const SEC_JOB = 'sec_fundamentals_import';
|
|
||||||
|
|
||||||
function formatRun(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return 'not run in this process';
|
|
||||||
const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
|
||||||
if (minutes < 1) return 'just now';
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FundamentalsCutoverSettings() {
|
|
||||||
const cutover = useFundamentalsCutoverSettings();
|
|
||||||
const update = useUpdateFundamentalsCutoverSettings();
|
|
||||||
const trigger = useTriggerJob();
|
|
||||||
const { data: jobs } = useJobs();
|
|
||||||
|
|
||||||
if (cutover.isLoading) return <SkeletonCard />;
|
|
||||||
if (cutover.isError || !cutover.data) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-red-400">
|
|
||||||
{(cutover.error as Error)?.message || 'Failed to load fundamentals data source'}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabled = cutover.data.enabled;
|
|
||||||
const secJob = jobs?.find((job) => job.name === SEC_JOB);
|
|
||||||
const runningJob = jobs?.find((job) => job.running);
|
|
||||||
const refreshBlocked = Boolean(runningJob && runningJob.name !== SEC_JOB);
|
|
||||||
|
|
||||||
const changeSource = () => {
|
|
||||||
const next = !enabled;
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
next
|
|
||||||
? 'Activate SEC + Dolt fundamentals? The next SEC import will replace the legacy cache and mark affected scores stale.'
|
|
||||||
: 'Pause SEC + Dolt cache refreshes? Existing cache values will stay in place; legacy values are not restored automatically.',
|
|
||||||
);
|
|
||||||
if (confirmed) update.mutate(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="glass overflow-hidden" aria-labelledby="fundamentals-source-title">
|
|
||||||
<div className={`h-0.5 ${enabled ? 'bg-gradient-to-r from-sky-500 via-cyan-300 to-emerald-400' : 'bg-white/[0.06]'}`} />
|
|
||||||
<div className="space-y-5 p-5">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 id="fundamentals-source-title" className="text-sm font-semibold text-gray-200">
|
|
||||||
Fundamentals data source
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${
|
|
||||||
enabled
|
|
||||||
? 'border-cyan-400/25 bg-cyan-400/10 text-cyan-300'
|
|
||||||
: 'border-white/10 bg-white/[0.04] text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{enabled ? 'SEC + Dolt active' : 'Legacy cache'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-gray-500">
|
|
||||||
Controls what repopulates <span className="num text-gray-400">fundamental_data</span>, the
|
|
||||||
compatibility cache used by scoring. SEC filings supply P/E, growth and estimated market
|
|
||||||
cap; Dolt supplies earnings dates and surprises. Everything is derived locally from PostgreSQL.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_5rem_minmax(0,1fr)] items-center gap-3 rounded-xl border border-white/[0.06] bg-black/10 px-4 py-3">
|
|
||||||
<div className={enabled ? 'text-gray-600' : 'text-amber-200/90'}>
|
|
||||||
<div className="num text-[10px] uppercase tracking-[0.16em]">Legacy APIs</div>
|
|
||||||
<div className="mt-0.5 text-[11px]">FMP / Finnhub / Alpha Vantage</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-px bg-white/10" aria-hidden="true">
|
|
||||||
<span
|
|
||||||
className={`absolute top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border-2 border-[#0e120f] transition-all duration-300 ${
|
|
||||||
enabled
|
|
||||||
? 'right-0 bg-cyan-300 shadow-[0_0_12px_rgba(103,232,249,0.55)]'
|
|
||||||
: 'left-0 bg-amber-300'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={`text-right ${enabled ? 'text-cyan-200' : 'text-gray-600'}`}>
|
|
||||||
<div className="num text-[10px] uppercase tracking-[0.16em]">SEC + Dolt</div>
|
|
||||||
<div className="mt-0.5 text-[11px]">Bulk imports → PostgreSQL cache</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 border-t border-white/[0.06] pt-4 md:grid-cols-2">
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-xl bg-white/[0.025] p-3.5">
|
|
||||||
<div>
|
|
||||||
<div className="num text-[10px] uppercase tracking-[0.14em] text-gray-600">1 · Source</div>
|
|
||||||
<div className="mt-1 text-sm text-gray-200">Use SEC + Dolt for scoring inputs</div>
|
|
||||||
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
|
|
||||||
While active, the weekly legacy collector is skipped so it cannot overwrite the new cache.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={enabled}
|
|
||||||
aria-label="Use SEC and Dolt fundamentals"
|
|
||||||
onClick={changeSource}
|
|
||||||
disabled={update.isPending}
|
|
||||||
className={`relative mt-1 inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400/70 focus:ring-offset-2 focus:ring-offset-[#0e120f] disabled:cursor-wait disabled:opacity-50 ${
|
|
||||||
enabled ? 'bg-gradient-to-r from-sky-500 to-cyan-400' : 'bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
|
||||||
enabled ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-white/[0.025] p-3.5">
|
|
||||||
<div className="num text-[10px] uppercase tracking-[0.14em] text-gray-600">2 · Refresh</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-200">Apply the source now</div>
|
|
||||||
<p className="mt-1 text-[11px] text-gray-500">
|
|
||||||
{secJob?.running
|
|
||||||
? 'SEC import and cache refresh are running.'
|
|
||||||
: secJob?.runtime_message || `Last SEC run: ${formatRun(secJob?.runtime_finished_at)}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => trigger.mutate(SEC_JOB)}
|
|
||||||
disabled={
|
|
||||||
!enabled ||
|
|
||||||
trigger.isPending ||
|
|
||||||
Boolean(secJob?.running) ||
|
|
||||||
refreshBlocked ||
|
|
||||||
secJob?.enabled === false
|
|
||||||
}
|
|
||||||
className="btn-primary px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{secJob?.running
|
|
||||||
? 'Refreshing…'
|
|
||||||
: trigger.isPending
|
|
||||||
? 'Starting…'
|
|
||||||
: refreshBlocked
|
|
||||||
? 'Another job is running'
|
|
||||||
: 'Run refresh now'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!enabled && (
|
|
||||||
<p className="mt-2 text-[11px] text-amber-300/70">Activate the source before running the refresh.</p>
|
|
||||||
)}
|
|
||||||
{enabled && secJob?.enabled === false && (
|
|
||||||
<p className="mt-2 text-[11px] text-amber-300/70">Enable the SEC Fundamentals job on the Jobs tab first.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] leading-relaxed text-gray-600">
|
|
||||||
Rollback pauses future writes only. To restore pre-cutover values, use the database backup or
|
|
||||||
pause this source and manually run the legacy collector while its provider keys remain installed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
getFundamentalsParityCsv,
|
|
||||||
getFundamentalsParityJson,
|
|
||||||
} from '../../api/admin';
|
|
||||||
import { useFundamentalsParityReport } from '../../hooks/useAdmin';
|
|
||||||
import { SkeletonTable } from '../ui/Skeleton';
|
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
|
||||||
pe_ratio: 'P/E',
|
|
||||||
revenue_growth: 'Revenue growth',
|
|
||||||
earnings_surprise: 'Earnings surprise',
|
|
||||||
};
|
|
||||||
|
|
||||||
function downloadText(filename: string, content: string, type: string) {
|
|
||||||
const blob = new Blob([content], { type });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FundamentalsParityPanel() {
|
|
||||||
const { data: report, isLoading, isError, error } = useFundamentalsParityReport();
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
|
|
||||||
if (isLoading) return <SkeletonTable rows={2} cols={4} />;
|
|
||||||
if (isError) {
|
|
||||||
return <p className="text-sm text-red-400">{(error as Error).message}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return (
|
|
||||||
<div className="glass p-5">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
No report yet. Trigger “Fundamentals Parity Report (read-only)” below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = report.summary;
|
|
||||||
const generated = new Date(report.generated_at).toLocaleString();
|
|
||||||
|
|
||||||
async function downloadCsv() {
|
|
||||||
setDownloading(true);
|
|
||||||
try {
|
|
||||||
const artifact = await getFundamentalsParityCsv();
|
|
||||||
if (artifact) downloadText(artifact.filename, artifact.content, 'text/csv;charset=utf-8');
|
|
||||||
} finally {
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadJson() {
|
|
||||||
setDownloading(true);
|
|
||||||
try {
|
|
||||||
const artifact = await getFundamentalsParityJson();
|
|
||||||
if (artifact) downloadText(artifact.filename, artifact.content, 'application/json;charset=utf-8');
|
|
||||||
} finally {
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass p-5 space-y-4">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
|
|
||||||
<span className="rounded-full border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-amber-300">
|
|
||||||
approval pending
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-cyan-300">
|
|
||||||
read-only
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Generated {generated} · as of {report.as_of_date} · {summary.universe_count} tracked tickers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white"
|
|
||||||
onClick={downloadJson}
|
|
||||||
disabled={downloading}
|
|
||||||
>
|
|
||||||
Download JSON
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white disabled:opacity-50"
|
|
||||||
onClick={downloadCsv}
|
|
||||||
disabled={downloading}
|
|
||||||
>
|
|
||||||
{downloading ? 'Preparing…' : 'Download CSV'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Summary label="Candidate score coverage" value={`${summary.candidate_fundamental_score_available}/${summary.universe_count}`} />
|
|
||||||
<Summary label="Scores compared" value={summary.fundamental_scores_compared} />
|
|
||||||
<Summary label="Material score moves" value={summary.fundamental_score_material_changes} />
|
|
||||||
<Summary label="Fundamental rank moves" value={summary.fundamental_rank_changes} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-xs">
|
|
||||||
<thead className="text-[10px] uppercase tracking-wider text-gray-500">
|
|
||||||
<tr>
|
|
||||||
<th className="pb-2 pr-4 font-medium">Field</th>
|
|
||||||
<th className="pb-2 px-3 font-medium">Legacy</th>
|
|
||||||
<th className="pb-2 px-3 font-medium">Candidate</th>
|
|
||||||
<th className="pb-2 px-3 font-medium">Compared</th>
|
|
||||||
<th className="pb-2 px-3 font-medium">Material</th>
|
|
||||||
<th className="pb-2 pl-3 font-medium">Median |Δ|</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-white/[0.06] text-gray-300">
|
|
||||||
{Object.entries(summary.field_stats).map(([key, stats]) => (
|
|
||||||
<tr key={key}>
|
|
||||||
<td className="py-2.5 pr-4">{FIELD_LABELS[key] ?? key}</td>
|
|
||||||
<td className="py-2.5 px-3 num">{stats.legacy_available}</td>
|
|
||||||
<td className="py-2.5 px-3 num">{stats.candidate_available}</td>
|
|
||||||
<td className="py-2.5 px-3 num">{stats.both_available}</td>
|
|
||||||
<td className="py-2.5 px-3 num">{stats.material_differences}</td>
|
|
||||||
<td className="py-2.5 pl-3 num">
|
|
||||||
{stats.median_absolute_delta == null ? 'n/a' : stats.median_absolute_delta.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] leading-relaxed text-gray-500">
|
|
||||||
Materiality bands highlight review candidates only. They do not approve a cutover or write fundamentals,
|
|
||||||
scores, rankings, or qualification state.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Summary({ label, value }: { label: string; value: string | number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-white/[0.07] bg-white/[0.025] px-3 py-2.5">
|
|
||||||
<div className="text-[10px] uppercase tracking-wider text-gray-500">{label}</div>
|
|
||||||
<div className="mt-1 num text-lg text-gray-200">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin';
|
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin';
|
||||||
|
import type { JobCategory, JobStatus } from '../../api/admin';
|
||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
|
|
||||||
function formatNextRun(iso: string | null): string {
|
function formatNextRun(iso: string | null): string {
|
||||||
@@ -10,7 +11,8 @@ function formatNextRun(iso: string | null): string {
|
|||||||
const mins = Math.round(diffMs / 60_000);
|
const mins = Math.round(diffMs / 60_000);
|
||||||
if (mins < 60) return `in ${mins}m`;
|
if (mins < 60) return `in ${mins}m`;
|
||||||
const hrs = Math.round(mins / 60);
|
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 {
|
function formatAgo(iso: string | null | undefined): string {
|
||||||
@@ -29,85 +31,95 @@ function lastRunColor(status: string | null | undefined): string {
|
|||||||
return 'text-gray-500';
|
return 'text-gray-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobControls() {
|
/** The four kinds of job, in the order the API already sorts them. A job whose
|
||||||
const { data: jobs, isLoading } = useJobs();
|
* category the client does not recognise still renders, under "Other" — better
|
||||||
const toggleJob = useToggleJob();
|
* a stray section than a job that silently vanishes from the admin page. */
|
||||||
const triggerJob = useTriggerJob();
|
const SECTIONS: { key: JobCategory; title: string; hint: string }[] = [
|
||||||
const anyJobRunning = (jobs ?? []).some((job) => job.running);
|
{
|
||||||
const runningJob = jobs?.find((job) => job.running);
|
key: 'pipeline',
|
||||||
const pausedJob = jobs?.find((job) => !job.running && job.runtime_status === 'rate_limited');
|
title: 'Pipelines',
|
||||||
const runningJobLabel = runningJob?.label;
|
hint: 'own schedule · run their steps in order',
|
||||||
|
},
|
||||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
{
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<span className={muted}>
|
||||||
{runningJob && (
|
Next via {labels[job.via_next_run_job] ?? job.via_next_run_job}{' '}
|
||||||
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
|
{formatNextRun(job.via_next_run_at)}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
</span>
|
||||||
<div>
|
);
|
||||||
<div className="text-xs font-semibold text-blue-300">
|
}
|
||||||
Active job: {runningJob.label}
|
if (!job.next_run_at) return null;
|
||||||
</div>
|
return <span className={muted}>Next run {formatNextRun(job.next_run_at)}</span>;
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!runningJob && pausedJob && (
|
/** Membership, shown rather than nested: a step can belong to several pipelines
|
||||||
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3">
|
* (data_collector is in all four), so duplicating rows under each parent would
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
* render Trigger buttons that are not distinct actions. */
|
||||||
<div>
|
function Membership({ job, labels }: { job: JobStatus; labels: Record<string, string> }) {
|
||||||
<div className="text-xs font-semibold text-amber-300">
|
const name = (id: string) => labels[id] ?? id;
|
||||||
Last run paused: {pausedJob.label}
|
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>
|
||||||
<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>
|
);
|
||||||
<div className="text-[11px] text-amber-200">
|
}
|
||||||
{pausedJob.runtime_processed ?? 0}
|
return null;
|
||||||
{typeof pausedJob.runtime_total === 'number'
|
}
|
||||||
? ` / ${pausedJob.runtime_total}`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{jobs?.map((job) => (
|
interface JobCardProps {
|
||||||
<div key={job.name} className="glass p-4 glass-hover">
|
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 flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
@@ -122,7 +134,9 @@ export function JobControls() {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-200">{job.label}</span>
|
<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
|
<span
|
||||||
className={`text-[11px] font-medium ${
|
className={`text-[11px] font-medium ${
|
||||||
job.running
|
job.running
|
||||||
@@ -148,26 +162,23 @@ export function JobControls() {
|
|||||||
? 'Active'
|
? 'Active'
|
||||||
: 'Inactive'}
|
: 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
{job.via_pipeline ? (
|
{job.enabled && <NextRun job={job} labels={labels} />}
|
||||||
<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.registered && (
|
{!job.registered && (
|
||||||
<span className="text-[11px] text-red-400">Not registered</span>
|
<span className="text-[11px] text-red-400">Not registered</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!job.running && job.runtime_finished_at && (
|
<Membership job={job} labels={labels} />
|
||||||
<div className={`mt-1 text-[11px] ${lastRunColor(job.runtime_status)}`}>
|
{/* Persisted, so this survives a deploy — unlike runtime_* above. */}
|
||||||
Last run {formatAgo(job.runtime_finished_at)}
|
{!job.running && job.last_run_at && (
|
||||||
{job.runtime_status ? ` · ${job.runtime_status}` : ''}
|
<div className={`mt-1 text-[11px] ${lastRunColor(job.last_run_status)}`}>
|
||||||
{job.runtime_message ? ` — ${job.runtime_message}` : ''}
|
Last run {formatAgo(job.last_run_at)}
|
||||||
|
{job.last_run_status ? ` · ${job.last_run_status}` : ''}
|
||||||
|
{job.last_run_message ? ` — ${job.last_run_message}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!job.running && !job.last_run_at && (
|
||||||
|
<div className="mt-1 text-[11px] text-gray-600">No run recorded yet</div>
|
||||||
|
)}
|
||||||
{job.running && (
|
{job.running && (
|
||||||
<div className="mt-2 space-y-1.5">
|
<div className="mt-2 space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-[11px] text-gray-400">
|
<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>
|
<span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<div
|
||||||
className="h-full bg-blue-400 transition-all duration-500"
|
className="h-full bg-blue-400 transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
@@ -203,8 +214,8 @@ export function JobControls() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
|
onClick={() => onToggle(job)}
|
||||||
disabled={toggleJob.isPending}
|
disabled={togglePending}
|
||||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
|
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
|
||||||
job.enabled
|
job.enabled
|
||||||
? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20'
|
? '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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => triggerJob.mutate(job.name)}
|
onClick={() => onTrigger(job)}
|
||||||
disabled={triggerJob.isPending || !job.enabled || anyJobRunning}
|
disabled={triggerPending || !job.enabled || anyJobRunning}
|
||||||
className="btn-primary px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{job.running
|
{job.running
|
||||||
? 'Running…'
|
? 'Running…'
|
||||||
: triggerJob.isPending
|
: triggerPending
|
||||||
? 'Triggering…'
|
? 'Triggering…'
|
||||||
: anyJobRunning
|
: anyJobRunning
|
||||||
? 'Blocked'
|
? 'Blocked'
|
||||||
@@ -237,7 +248,128 @@ export function JobControls() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ const DEFAULTS: ScheduleConfig = {
|
|||||||
schedule_daily_pipeline_cron: '0 2 * * *',
|
schedule_daily_pipeline_cron: '0 2 * * *',
|
||||||
schedule_dolt_earnings_cron: '30 2 * * *',
|
schedule_dolt_earnings_cron: '30 2 * * *',
|
||||||
schedule_sec_fundamentals_cron: '0 4 * * *',
|
schedule_sec_fundamentals_cron: '0 4 * * *',
|
||||||
schedule_fundamentals_parity_cron: '30 5 * * *',
|
|
||||||
schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
|
schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
|
||||||
schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
|
schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
|
||||||
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
|
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
|
||||||
schedule_fundamentals_cron: '0 1 * * mon',
|
schedule_backtest_cron: '0 3 * * sun',
|
||||||
|
schedule_ticker_universe_cron: '0 1 * * *',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
|
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',
|
key: 'schedule_daily_pipeline_cron',
|
||||||
label: 'Morning pipeline',
|
label: 'Morning pipeline',
|
||||||
hint: 'OHLCV → benchmark → sentiment → regime → alerts (no R:R scan). Default 02:00 ET so regime-quadrant changes hit Telegram in the morning.',
|
hint: 'OHLCV → benchmark → sentiment → trend/risk → alerts (no R:R scan). Default 02:00 ET so risk-quadrant changes hit Telegram in the morning.',
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'schedule_dolt_earnings_cron',
|
key: 'schedule_dolt_earnings_cron',
|
||||||
label: 'Dolt earnings',
|
label: 'Dolt earnings',
|
||||||
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The activated cache refresh uses these local events.',
|
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The fundamentals cache refresh uses these local events.',
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'schedule_sec_fundamentals_cron',
|
key: 'schedule_sec_fundamentals_cron',
|
||||||
label: 'SEC fundamentals',
|
label: 'SEC fundamentals',
|
||||||
hint: 'Import tracked-universe SEC facts daily at 04:00 ET and refresh the scoring cache when the cutover is active.',
|
hint: 'Import tracked-universe SEC facts daily at 04:00 ET, then refresh the fundamentals cache scoring reads. Disabling the job stops the SEC fetch only — the local cache refresh still runs.',
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'schedule_fundamentals_parity_cron',
|
|
||||||
label: 'Fundamentals parity report',
|
|
||||||
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the bulk imports.',
|
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,9 +58,15 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
|
|||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'schedule_fundamentals_cron',
|
key: 'schedule_backtest_cron',
|
||||||
label: 'Legacy fundamentals (weekly)',
|
label: 'Backtest',
|
||||||
hint: 'Fallback provider chain. Automatically skipped while the SEC + Dolt cutover is active.',
|
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,
|
mono: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
|
|||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
import type { SystemSetting } from '../../lib/types';
|
import type { SystemSetting } from '../../lib/types';
|
||||||
|
|
||||||
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
|
|
||||||
|
|
||||||
export function SettingsForm() {
|
export function SettingsForm() {
|
||||||
const { data: settings, isLoading, isError, error } = useSettings();
|
const { data: settings, isLoading, isError, error } = useSettings();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
@@ -34,11 +32,10 @@ export function SettingsForm() {
|
|||||||
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
|
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
|
||||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
|
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
|
||||||
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
|
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
|
||||||
const visibleSettings = settings.filter((setting) => !MANAGED_SETTINGS.has(setting.key));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{visibleSettings.map((setting) => (
|
{settings.map((setting) => (
|
||||||
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
|
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
|
||||||
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
|
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
|
||||||
{setting.key === 'registration' ? (
|
{setting.key === 'registration' ? (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const navItems = [
|
|||||||
{ to: '/', label: 'Overview', end: true },
|
{ to: '/', label: 'Overview', end: true },
|
||||||
{ to: '/market', label: 'Market', end: false },
|
{ to: '/market', label: 'Market', end: false },
|
||||||
{ to: '/signals', label: 'Signals', end: false },
|
{ to: '/signals', label: 'Signals', end: false },
|
||||||
{ to: '/regime', label: 'Regime', end: false },
|
{ to: '/regime', label: 'Risk', end: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MobileNav() {
|
export default function MobileNav() {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const navItems = [
|
|||||||
{ to: '/', label: 'Overview', end: true },
|
{ to: '/', label: 'Overview', end: true },
|
||||||
{ to: '/market', label: 'Market', end: false },
|
{ to: '/market', label: 'Market', end: false },
|
||||||
{ to: '/signals', label: 'Signals', end: false },
|
{ to: '/signals', label: 'Signals', end: false },
|
||||||
{ to: '/regime', label: 'Regime', end: false },
|
// Route stays /regime so existing links keep working; only the label changes.
|
||||||
|
{ to: '/regime', label: 'Risk', end: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const linkClasses = (isActive: boolean) =>
|
const linkClasses = (isActive: boolean) =>
|
||||||
@@ -84,7 +85,7 @@ export default function TopBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-5">
|
<div className="ml-auto flex items-center gap-5">
|
||||||
{/* Market regime — ambient status; the full picture lives on /regime */}
|
{/* SPY trend — ambient status; the full picture lives on /regime */}
|
||||||
{regime.data && (
|
{regime.data && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/regime"
|
to="/regime"
|
||||||
@@ -99,7 +100,7 @@ export default function TopBar() {
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${regimeDot(regime.data.label)}`} />
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${regimeDot(regime.data.label)}`} />
|
||||||
<span className="text-[11px] capitalize text-gray-500 transition-colors group-hover:text-gray-300">
|
<span className="text-[11px] capitalize text-gray-500 transition-colors group-hover:text-gray-300">
|
||||||
{regime.data.label} regime
|
{regime.data.label} trend
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceArea,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Scatter,
|
||||||
|
ScatterChart,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
ZAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
|
||||||
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { SkeletonCard } from '../ui/Skeleton';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
|
||||||
|
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
||||||
|
// Time and Path are two projections of one series, so they share a card and a
|
||||||
|
// query rather than sitting in two panels that look like different data.
|
||||||
|
|
||||||
|
const VIEWS = ['Time', 'Path'] as const;
|
||||||
|
type View = (typeof VIEWS)[number];
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ key: '1M', days: 30 },
|
||||||
|
{ key: '3M', days: 90 },
|
||||||
|
{ key: '6M', days: 182 },
|
||||||
|
{ key: 'All', days: Number.POSITIVE_INFINITY },
|
||||||
|
] as const;
|
||||||
|
type RangeKey = (typeof RANGES)[number]['key'];
|
||||||
|
|
||||||
|
/** Sessions drawn in Path view. The full series is unreadable as a path. */
|
||||||
|
const PATH_TRAIL = 60;
|
||||||
|
|
||||||
|
const STATE_COLOR = '#60a5fa';
|
||||||
|
const WARNING_COLOR = '#fb923c';
|
||||||
|
|
||||||
|
// Fall back to the shipped 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,35 +9,8 @@ import { Disclosure } from '../ui/Disclosure';
|
|||||||
import { Dropdown } from '../ui/Dropdown';
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
import { BacktestRecommendationCard } from './BacktestRecommendationCard';
|
||||||
|
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
|
||||||
function fmtR(v: number | null | undefined): string {
|
|
||||||
if (v === null || v === undefined) return '—';
|
|
||||||
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
function fmtPct(v: number | null): string {
|
|
||||||
return v === null ? '—' : `${v.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
function fmtMoney(v: number | null | undefined): string {
|
|
||||||
if (v === null || v === undefined) return '—';
|
|
||||||
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
function fmtSignedPct(v: number | null | undefined): string {
|
|
||||||
if (v === null || v === undefined) return '—';
|
|
||||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
function fmtDrawdown(v: number | null | undefined): string {
|
|
||||||
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
function fmtDays(v: number | null | undefined): string {
|
|
||||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
|
||||||
}
|
|
||||||
function rColor(v: number | null): string {
|
|
||||||
if (v === null) return 'text-gray-400';
|
|
||||||
if (v > 0) return 'text-emerald-400';
|
|
||||||
if (v < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(iso: string): string {
|
function timeAgo(iso: string): string {
|
||||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||||
@@ -48,95 +21,14 @@ function timeAgo(iso: string): string {
|
|||||||
return `${Math.floor(hrs / 24)}d ago`;
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
const TARGET_MODEL_OPTIONS = [
|
||||||
label: string; value: string; valueClass?: string; sub?: string;
|
{ value: 'production_gtl', label: 'Live GTL — production' },
|
||||||
}) {
|
{ value: 'structural_sr', label: 'Structural S/R — comparison' },
|
||||||
return (
|
];
|
||||||
<div className="glass p-4">
|
const CADENCE_OPTIONS = [
|
||||||
<p className="section-index">{label}</p>
|
{ value: 'weekly', label: 'Weekly — default' },
|
||||||
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
{ value: 'daily', label: 'Daily — research' },
|
||||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
];
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function curvePath(
|
|
||||||
points: BacktestCurvePoint[],
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
pad: number,
|
|
||||||
startMs: number,
|
|
||||||
endMs: number,
|
|
||||||
): string {
|
|
||||||
if (points.length < 2) return '';
|
|
||||||
const span = Math.max(max - min, 1);
|
|
||||||
const timeSpan = Math.max(endMs - startMs, 1);
|
|
||||||
return points
|
|
||||||
.map((p, i) => {
|
|
||||||
const t = new Date(p.date).getTime();
|
|
||||||
const x = pad + ((t - startMs) / timeSpan) * (w - pad * 2);
|
|
||||||
const value = p.return_pct ?? 0;
|
|
||||||
const y = pad + (1 - (value - min) / span) * (h - pad * 2);
|
|
||||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
|
|
||||||
const portfolio = run.equity_curve ?? [];
|
|
||||||
const benchmark = run.benchmark_curve ?? [];
|
|
||||||
const values = [...portfolio, ...benchmark]
|
|
||||||
.map((p) => p.return_pct)
|
|
||||||
.filter((v): v is number => v !== null && v !== undefined);
|
|
||||||
if (portfolio.length < 2 || values.length === 0) {
|
|
||||||
return <Callout variant="empty">No equity curve points for this selection.</Callout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const min = Math.min(0, ...values);
|
|
||||||
const max = Math.max(0, ...values);
|
|
||||||
const times = [...portfolio, ...benchmark]
|
|
||||||
.map((p) => new Date(p.date).getTime())
|
|
||||||
.filter((v) => Number.isFinite(v));
|
|
||||||
if (times.length === 0) {
|
|
||||||
return <Callout variant="empty">No dated equity curve points for this selection.</Callout>;
|
|
||||||
}
|
|
||||||
const startMs = Math.min(...times);
|
|
||||||
const endMs = Math.max(...times);
|
|
||||||
const w = 720;
|
|
||||||
const h = 240;
|
|
||||||
const pad = 28;
|
|
||||||
const portfolioPath = curvePath(portfolio, min, max, w, h, pad, startMs, endMs);
|
|
||||||
const benchmarkPath = curvePath(benchmark, min, max, w, h, pad, startMs, endMs);
|
|
||||||
const lastPortfolio = portfolio[portfolio.length - 1]?.return_pct ?? null;
|
|
||||||
const lastBenchmark = benchmark[benchmark.length - 1]?.return_pct ?? run.spy_return_pct;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass overflow-hidden">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/[0.05] px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-gray-100">{run.label}</p>
|
|
||||||
<p className="text-[11px] text-gray-500">{run.start_date} - {run.end_date}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 text-xs">
|
|
||||||
<span className="text-blue-300">Portfolio {fmtSignedPct(lastPortfolio)}</span>
|
|
||||||
<span className="text-gray-400">S&P 500 {fmtSignedPct(lastBenchmark)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-64 w-full" role="img" aria-label="Portfolio return compared with S&P 500">
|
|
||||||
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
|
||||||
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
|
||||||
{benchmarkPath && (
|
|
||||||
<path d={benchmarkPath} fill="none" stroke="rgba(156,163,175,0.9)" strokeWidth="2" strokeDasharray="5 5" />
|
|
||||||
)}
|
|
||||||
<path d={portfolioPath} fill="none" stroke="rgb(96,165,250)" strokeWidth="3" />
|
|
||||||
<text x={pad} y={pad - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(max)}</text>
|
|
||||||
<text x={pad} y={h - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(min)}</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacktestPanel() {
|
export function BacktestPanel() {
|
||||||
const { data: report, isLoading } = useBacktestReport();
|
const { data: report, isLoading } = useBacktestReport();
|
||||||
@@ -187,114 +79,58 @@ export function BacktestPanel() {
|
|||||||
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
||||||
so read it as directional.
|
so read it as directional.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 max-w-2xl text-xs text-gray-400">
|
||||||
|
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the
|
||||||
|
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison
|
||||||
|
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps
|
||||||
|
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
|
||||||
|
{' '}is roughly 5× the replay work.
|
||||||
|
</p>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:items-end">
|
|
||||||
<fieldset className="grid w-full grid-cols-1 gap-2 sm:w-[34rem] sm:grid-cols-2">
|
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
|
||||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
narrow viewport otherwise. */}
|
||||||
Target model for this run
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
</legend>
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
<label
|
<label htmlFor="backtest-target-model">Target model</label>
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
<Dropdown
|
||||||
targetModel === 'production_gtl'
|
id="backtest-target-model"
|
||||||
? 'border-blue-400/60 bg-blue-500/10'
|
className="w-56 normal-case tracking-normal"
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
value={targetModel}
|
||||||
}`}
|
onChange={(v) => setTargetModel(v as BacktestTargetModel)}
|
||||||
>
|
options={TARGET_MODEL_OPTIONS}
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-target-model"
|
|
||||||
value="production_gtl"
|
|
||||||
checked={targetModel === 'production_gtl'}
|
|
||||||
onChange={() => setTargetModel('production_gtl')}
|
|
||||||
/>
|
/>
|
||||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
</div>
|
||||||
Live GTL
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
<label htmlFor="backtest-cadence">Entry cadence</label>
|
||||||
Production
|
<Dropdown
|
||||||
</span>
|
id="backtest-cadence"
|
||||||
</span>
|
className="w-44 normal-case tracking-normal"
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
value={cadence}
|
||||||
Exact target path used by the live scanner and scheduled backtest.
|
onChange={(v) => setCadence(v as BacktestCadence)}
|
||||||
</span>
|
options={CADENCE_OPTIONS}
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
|
||||||
targetModel === 'structural_sr'
|
|
||||||
? 'border-amber-400/50 bg-amber-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-target-model"
|
|
||||||
value="structural_sr"
|
|
||||||
checked={targetModel === 'structural_sr'}
|
|
||||||
onChange={() => setTargetModel('structural_sr')}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-200">Structural S/R</span>
|
</div>
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
|
||||||
Comparison only; uses chart structure as the target source.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset className="grid w-full grid-cols-2 gap-2 sm:w-[34rem]">
|
|
||||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Entry cadence
|
|
||||||
</legend>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
|
||||||
cadence === 'weekly'
|
|
||||||
? 'border-blue-400/60 bg-blue-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-cadence"
|
|
||||||
value="weekly"
|
|
||||||
checked={cadence === 'weekly'}
|
|
||||||
onChange={() => setCadence('weekly')}
|
|
||||||
/>
|
|
||||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
|
||||||
Weekly
|
|
||||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
|
||||||
Resource-safe server run at five-session intervals.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
|
||||||
cadence === 'daily'
|
|
||||||
? 'border-amber-400/50 bg-amber-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-cadence"
|
|
||||||
value="daily"
|
|
||||||
checked={cadence === 'daily'}
|
|
||||||
onChange={() => setCadence('daily')}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-200">Daily</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-amber-300/80">
|
|
||||||
Research run: roughly 5× the replay work; prefer the offline snapshot runner.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Only surfaced for non-default choices — zero noise on the common path,
|
||||||
|
but a non-production selection still announces itself, which is what
|
||||||
|
the old always-amber cards were really for. */}
|
||||||
|
{(cadence === 'daily' || targetModel === 'structural_sr') && (
|
||||||
|
<div className="space-y-1 text-[11px] text-amber-300/80">
|
||||||
|
{cadence === 'daily' && (
|
||||||
|
<p>Daily replays ~5× the work — prefer the offline snapshot runner.</p>
|
||||||
|
)}
|
||||||
|
{targetModel === 'structural_sr' && (
|
||||||
|
<p>Comparison arm — not the live scanner's target path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
||||||
|
|
||||||
{!isLoading && !report && (
|
{!isLoading && !report && (
|
||||||
@@ -319,118 +155,18 @@ export function BacktestPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{monitor && monitorRun ? (
|
<PortfolioMonitorPanel
|
||||||
<div className="space-y-3">
|
monitor={monitor}
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
monitorRun={monitorRun}
|
||||||
<div>
|
activeStrategy={activeStrategy}
|
||||||
<p className="section-index">Portfolio monitor</p>
|
activeLookback={activeLookback}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
onStrategyChange={setSelectedStrategy}
|
||||||
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
onLookbackChange={setSelectedLookback}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
|
||||||
<label htmlFor="monitor-strategy">Strategy</label>
|
|
||||||
<Dropdown
|
|
||||||
id="monitor-strategy"
|
|
||||||
className="w-64 normal-case tracking-normal"
|
|
||||||
value={activeStrategy}
|
|
||||||
onChange={setSelectedStrategy}
|
|
||||||
options={monitor.strategies.map((s) => ({
|
|
||||||
value: s.strategy,
|
|
||||||
label: `${s.is_production ? 'Production: ' : ''}${s.label}`,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
|
||||||
<label htmlFor="monitor-lookback">Lookback</label>
|
|
||||||
<Dropdown
|
|
||||||
id="monitor-lookback"
|
|
||||||
className="w-36 normal-case tracking-normal"
|
|
||||||
value={activeLookback}
|
|
||||||
onChange={setSelectedLookback}
|
|
||||||
options={monitor.lookbacks.map((l) => ({ value: l.lookback, label: l.label }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
{report.recommendation && (
|
||||||
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
<BacktestRecommendationCard recommendation={report.recommendation} />
|
||||||
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
|
|
||||||
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
|
||||||
<Stat
|
|
||||||
label="Total Return"
|
|
||||||
value={fmtSignedPct(monitorRun.total_return_pct)}
|
|
||||||
valueClass={rColor(monitorRun.total_return_pct)}
|
|
||||||
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
|
||||||
/>
|
|
||||||
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EquityCurveChart run={monitorRun} />
|
|
||||||
|
|
||||||
<p className="text-[11px] text-gray-500">
|
|
||||||
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
|
||||||
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
|
||||||
{monitorRun.reentry_policy === 'gate_reset' ? (
|
|
||||||
<> · Re-entry after gate failure and fresh qualification</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
|
||||||
<div className="glass overflow-x-auto p-4">
|
|
||||||
<p className="section-index mb-2">Per-year returns</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{monitorRun.yearly_returns.map((y) => (
|
|
||||||
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
|
||||||
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
|
||||||
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
|
||||||
{fmtSignedPct(y.return_pct)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Callout variant="empty">
|
|
||||||
This report predates the portfolio monitor — re-run the backtest to populate it.
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report.recommendation && report.recommendation.items.length > 0 && (
|
|
||||||
<div className="glass border border-blue-400/20 p-4">
|
|
||||||
<p className="section-index">What this backtest recommends</p>
|
|
||||||
{report.recommendation.headline && (
|
|
||||||
<p className="mt-1.5 text-sm font-semibold text-gray-100">
|
|
||||||
{report.recommendation.headline}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{report.recommendation.items.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.topic + item.text}
|
|
||||||
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{report.recommendation.note && (
|
|
||||||
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-[11px] text-gray-600">
|
|
||||||
Strategy research — gate tuning, exit sweeps, factor rank-IC — now runs locally against a
|
|
||||||
database snapshot (see README). This page keeps only what says whether the promoted strategy
|
|
||||||
is worth trading; your realized results up top show what it is actually delivering.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
|
import type { BacktestRecommendation } from '../../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The verdict, ahead of the tuning detail.
|
||||||
|
*
|
||||||
|
* All eight findings used to render as equal-weight bullets, so "does this
|
||||||
|
* strategy work" sat in the same visual register as "which cutoff scored best".
|
||||||
|
* `topic` splits them: the three that answer the question stay inline, the rest
|
||||||
|
* collapse.
|
||||||
|
*
|
||||||
|
* No topic chips — every backend string already self-prefixes ("Gate: …",
|
||||||
|
* "Robustness: …"), so a chip would render "GATE │ Gate: …", and stripping the
|
||||||
|
* prefix would drop real information ("(3y)" carries the lookback, "Legacy"
|
||||||
|
* qualifies the diagnostic).
|
||||||
|
*/
|
||||||
|
const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors how the backend phrases a bad result — `_build_recommendation` emits
|
||||||
|
* "Robustness WARNING: …" and "Book vs SPY: LAGS …". There is deliberately no
|
||||||
|
* `severity` field on the payload; if that changes, this is the one place to fix.
|
||||||
|
*/
|
||||||
|
function isWarning(text: string): boolean {
|
||||||
|
return text.includes('WARNING') || text.includes('LAGS');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacktestRecommendationCard({
|
||||||
|
recommendation,
|
||||||
|
}: {
|
||||||
|
recommendation: BacktestRecommendation;
|
||||||
|
}) {
|
||||||
|
const items = recommendation.items;
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
// A warning is always visible, whatever its topic — burying "the edge
|
||||||
|
// disappears without the top 5% of winners" behind a disclosure would defeat
|
||||||
|
// the point of surfacing it at all.
|
||||||
|
const primary = items.filter((i) => PRIMARY_TOPICS.has(i.topic) || isWarning(i.text));
|
||||||
|
const secondary = items.filter((i) => !PRIMARY_TOPICS.has(i.topic) && !isWarning(i.text));
|
||||||
|
const warningCount = items.filter((i) => isWarning(i.text)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="glass border border-blue-400/20 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="section-index">What this backtest recommends</p>
|
||||||
|
{warningCount > 0 && (
|
||||||
|
<span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-300">
|
||||||
|
⚠ {warningCount} warning{warningCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recommendation.headline && (
|
||||||
|
<p className="mt-1.5 text-sm font-semibold text-gray-100">{recommendation.headline}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{primary.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1.5 border-t border-white/[0.06] pt-3">
|
||||||
|
{primary.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.topic + item.text}
|
||||||
|
className={`text-xs ${isWarning(item.text) ? 'text-amber-400' : 'text-gray-300'}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recommendation.note && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outside the card body on purpose: Disclosure renders its own glass-sm
|
||||||
|
panel, so nesting it inside the bordered card double-frames it. */}
|
||||||
|
{secondary.length > 0 && (
|
||||||
|
<Disclosure summary={`Gate, exit and cutoff detail (${secondary.length})`}>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{secondary.map((item) => (
|
||||||
|
<li key={item.topic + item.text} className="text-xs text-gray-400">
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { fmtSignedPct } from '../../lib/format';
|
||||||
|
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio return vs S&P 500 for one monitor run.
|
||||||
|
*
|
||||||
|
* Hand-rolled SVG on purpose: two polylines and two axis rules do not justify a
|
||||||
|
* charting dependency, and the shape is fixed. Lives in `signals/` rather than
|
||||||
|
* `ui/` because it is typed to the backtest payload — generalising it for a
|
||||||
|
* single caller would be the wrong trade.
|
||||||
|
*/
|
||||||
|
function curvePath(
|
||||||
|
points: BacktestCurvePoint[],
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
pad: number,
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
): string {
|
||||||
|
if (points.length < 2) return '';
|
||||||
|
const span = Math.max(max - min, 1);
|
||||||
|
const timeSpan = Math.max(endMs - startMs, 1);
|
||||||
|
return points
|
||||||
|
.map((p, i) => {
|
||||||
|
const t = new Date(p.date).getTime();
|
||||||
|
const x = pad + ((t - startMs) / timeSpan) * (w - pad * 2);
|
||||||
|
const value = p.return_pct ?? 0;
|
||||||
|
const y = pad + (1 - (value - min) / span) * (h - pad * 2);
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
|
||||||
|
const portfolio = run.equity_curve ?? [];
|
||||||
|
const benchmark = run.benchmark_curve ?? [];
|
||||||
|
const values = [...portfolio, ...benchmark]
|
||||||
|
.map((p) => p.return_pct)
|
||||||
|
.filter((v): v is number => v !== null && v !== undefined);
|
||||||
|
if (portfolio.length < 2 || values.length === 0) {
|
||||||
|
return <Callout variant="empty">No equity curve points for this selection.</Callout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(0, ...values);
|
||||||
|
const max = Math.max(0, ...values);
|
||||||
|
const times = [...portfolio, ...benchmark]
|
||||||
|
.map((p) => new Date(p.date).getTime())
|
||||||
|
.filter((v) => Number.isFinite(v));
|
||||||
|
if (times.length === 0) {
|
||||||
|
return <Callout variant="empty">No dated equity curve points for this selection.</Callout>;
|
||||||
|
}
|
||||||
|
const startMs = Math.min(...times);
|
||||||
|
const endMs = Math.max(...times);
|
||||||
|
const w = 720;
|
||||||
|
const h = 240;
|
||||||
|
const pad = 28;
|
||||||
|
const portfolioPath = curvePath(portfolio, min, max, w, h, pad, startMs, endMs);
|
||||||
|
const benchmarkPath = curvePath(benchmark, min, max, w, h, pad, startMs, endMs);
|
||||||
|
const lastPortfolio = portfolio[portfolio.length - 1]?.return_pct ?? null;
|
||||||
|
const lastBenchmark = benchmark[benchmark.length - 1]?.return_pct ?? run.spy_return_pct;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass overflow-hidden">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/[0.05] px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-100">{run.label}</p>
|
||||||
|
<p className="text-[11px] text-gray-500">{run.start_date} - {run.end_date}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span className="text-blue-300">Portfolio {fmtSignedPct(lastPortfolio)}</span>
|
||||||
|
<span className="text-gray-400">S&P 500 {fmtSignedPct(lastBenchmark)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} className="h-64 w-full" role="img" aria-label="Portfolio return compared with S&P 500">
|
||||||
|
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
||||||
|
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
||||||
|
{benchmarkPath && (
|
||||||
|
<path d={benchmarkPath} fill="none" stroke="rgba(156,163,175,0.9)" strokeWidth="2" strokeDasharray="5 5" />
|
||||||
|
)}
|
||||||
|
<path d={portfolioPath} fill="none" stroke="rgb(96,165,250)" strokeWidth="3" />
|
||||||
|
<text x={pad} y={pad - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(max)}</text>
|
||||||
|
<text x={pad} y={h - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(min)}</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-24
@@ -5,8 +5,7 @@ import { triggerJob, resetTrackRecord } from '../../api/admin';
|
|||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { BacktestPanel } from './BacktestPanel';
|
import { fmtR, rColor } from '../../lib/format';
|
||||||
import { MyTradesPanel } from './MyTradesPanel';
|
|
||||||
|
|
||||||
// Need at least this many matured setups before the pipeline check means anything;
|
// Need at least this many matured setups before the pipeline check means anything;
|
||||||
// below it the live sample is too noisy to compare.
|
// below it the live sample is too noisy to compare.
|
||||||
@@ -16,18 +15,6 @@ const DRIFT_TOLERANCE_R = 0.2;
|
|||||||
|
|
||||||
type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
||||||
|
|
||||||
function fmtR(value: number | null): string {
|
|
||||||
if (value === null) return '—';
|
|
||||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rColor(value: number | null): string {
|
|
||||||
if (value === null) return 'text-gray-400';
|
|
||||||
if (value > 0) return 'text-emerald-400';
|
|
||||||
if (value < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusChip({ status }: { status: PipelineStatus }) {
|
function StatusChip({ status }: { status: PipelineStatus }) {
|
||||||
const styles: Record<PipelineStatus, { cls: string; label: string }> = {
|
const styles: Record<PipelineStatus, { cls: string; label: string }> = {
|
||||||
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
|
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
|
||||||
@@ -39,7 +26,7 @@ function StatusChip({ status }: { status: PipelineStatus }) {
|
|||||||
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackRecordPanel() {
|
export function EvaluationPanel() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -101,19 +88,14 @@ export function TrackRecordPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Your real, realized results come first; the strategy simulation follows. */}
|
<Disclosure summary="Setup-grading diagnostic & maintenance">
|
||||||
<MyTradesPanel />
|
|
||||||
<div className="border-t border-white/[0.06]" />
|
|
||||||
<BacktestPanel />
|
|
||||||
|
|
||||||
<Disclosure summary="Track-record maintenance">
|
|
||||||
<div className="space-y-4 pt-1">
|
<div className="space-y-4 pt-1">
|
||||||
<p className="max-w-2xl text-xs text-gray-500">
|
<p className="max-w-2xl text-xs text-gray-500">
|
||||||
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
||||||
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
|
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
|
||||||
initial stop / ATR trail / max hold — see paper trades and the portfolio monitor above.
|
initial stop / ATR trail / max hold — see the Paper Trades tab and the portfolio monitor
|
||||||
Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
|
above. Target before stop = win, stop first = loss (same-bar both = loss), neither in 30
|
||||||
days = expired at 0R. Only matured windows count. Scores{' '}
|
trading days = expired at 0R. Only matured windows count. Scores{' '}
|
||||||
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
|
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -2,22 +2,10 @@ import { useMemo } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { usePaperTrades } from '../../hooks/usePaperTrades';
|
import { usePaperTrades } from '../../hooks/usePaperTrades';
|
||||||
import { tradePnl } from '../../lib/paperTrade';
|
import { tradePnl } from '../../lib/paperTrade';
|
||||||
import { formatPrice } from '../../lib/format';
|
import { formatPrice, fmtR, fmtSignedMoney, rColor } from '../../lib/format';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { StatTile } from '../ui/StatTile';
|
||||||
function money(v: number): string {
|
|
||||||
return `${v >= 0 ? '+' : '−'}$${Math.abs(v).toFixed(2)}`;
|
|
||||||
}
|
|
||||||
function fmtR(v: number | null): string {
|
|
||||||
return v === null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
function color(v: number | null): string {
|
|
||||||
if (v === null) return 'text-gray-400';
|
|
||||||
if (v > 0) return 'text-emerald-400';
|
|
||||||
if (v < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
// How the trade was closed — useful context on real trades at almost no cost.
|
// How the trade was closed — useful context on real trades at almost no cost.
|
||||||
function reasonMeta(reason: string | null): { label: string; cls: string } {
|
function reasonMeta(reason: string | null): { label: string; cls: string } {
|
||||||
@@ -31,18 +19,6 @@ function reasonMeta(reason: string | null): { label: string; cls: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
|
||||||
label: string; value: string; valueClass?: string; sub?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="glass p-4">
|
|
||||||
<p className="section-index">{label}</p>
|
|
||||||
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
|
||||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MyTradesPanel() {
|
export function MyTradesPanel() {
|
||||||
const { data: closed, isLoading } = usePaperTrades('closed');
|
const { data: closed, isLoading } = usePaperTrades('closed');
|
||||||
|
|
||||||
@@ -70,7 +46,10 @@ export function MyTradesPanel() {
|
|||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="My Trades" hint="your realized paper-trading results">
|
<Section
|
||||||
|
title="Closed Trades"
|
||||||
|
hint="realized paper-trading results — open positions are on the Dashboard"
|
||||||
|
>
|
||||||
{stats.total === 0 ? (
|
{stats.total === 0 ? (
|
||||||
<Callout variant="empty">
|
<Callout variant="empty">
|
||||||
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
|
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
|
||||||
@@ -79,11 +58,11 @@ export function MyTradesPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
<StatTile label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||||||
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
<StatTile label="Expectancy" value={fmtR(stats.avgR)} valueClass={rColor(stats.avgR)} sub="avg R per closed trade" />
|
||||||
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
<StatTile label="Total R" value={fmtR(stats.totalR)} valueClass={rColor(stats.totalR)} sub={`${stats.total} closed`} />
|
||||||
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
|
<StatTile label="Total P&L" value={fmtSignedMoney(stats.totalPnl)} valueClass={rColor(stats.totalPnl)} sub="realized, all closed" />
|
||||||
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
<StatTile label="Alpha vs S&P 500" value={stats.totalAlpha != null ? fmtSignedMoney(stats.totalAlpha) : '—'} valueClass={rColor(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
@@ -112,9 +91,9 @@ export function MyTradesPanel() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? rColor(p.pnl) : 'text-gray-500'}`}>{p ? fmtSignedMoney(p.pnl) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? rColor(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? rColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed">
|
<span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed">
|
||||||
{reasonMeta(t.close_reason).label}
|
{reasonMeta(t.close_reason).label}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
|
import { StatTile } from '../ui/StatTile';
|
||||||
|
import { EquityCurveChart } from './EquityCurveChart';
|
||||||
|
import {
|
||||||
|
fmtDays,
|
||||||
|
fmtDrawdown,
|
||||||
|
fmtPct,
|
||||||
|
fmtR,
|
||||||
|
fmtRatio,
|
||||||
|
fmtSignedMoney,
|
||||||
|
fmtSignedPct,
|
||||||
|
rColor,
|
||||||
|
} from '../../lib/format';
|
||||||
|
import type {
|
||||||
|
BacktestPortfolioMonitor,
|
||||||
|
BacktestPortfolioMonitorRun,
|
||||||
|
} from '../../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The simulated book for one strategy/lookback selection, against the S&P 500.
|
||||||
|
*
|
||||||
|
* Selection state deliberately stays in BacktestPanel — it also resolves which
|
||||||
|
* run this panel receives, so splitting it here would mean resolving twice.
|
||||||
|
*/
|
||||||
|
export function PortfolioMonitorPanel({
|
||||||
|
monitor,
|
||||||
|
monitorRun,
|
||||||
|
activeStrategy,
|
||||||
|
activeLookback,
|
||||||
|
onStrategyChange,
|
||||||
|
onLookbackChange,
|
||||||
|
}: {
|
||||||
|
monitor: BacktestPortfolioMonitor | null | undefined;
|
||||||
|
monitorRun: BacktestPortfolioMonitorRun | null | undefined;
|
||||||
|
activeStrategy: string;
|
||||||
|
activeLookback: string;
|
||||||
|
onStrategyChange: (v: string) => void;
|
||||||
|
onLookbackChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
if (!monitor || !monitorRun) {
|
||||||
|
return (
|
||||||
|
<Callout variant="empty">
|
||||||
|
This report predates the portfolio monitor — re-run the backtest to populate it.
|
||||||
|
</Callout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key ABSENT (not null) means the cached report predates these metrics.
|
||||||
|
// Gated on sortino specifically: calmar and avg_trade_pnl have always been
|
||||||
|
// emitted, so testing those would half-populate the row with dashes.
|
||||||
|
const isLegacyRun = monitorRun.sortino === undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="section-index">Portfolio monitor</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
<label htmlFor="monitor-strategy">Strategy</label>
|
||||||
|
<Dropdown
|
||||||
|
id="monitor-strategy"
|
||||||
|
className="w-64 normal-case tracking-normal"
|
||||||
|
value={activeStrategy}
|
||||||
|
onChange={onStrategyChange}
|
||||||
|
options={monitor.strategies.map((s) => ({
|
||||||
|
value: s.strategy,
|
||||||
|
label: `${s.is_production ? 'Production: ' : ''}${s.label}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
<label htmlFor="monitor-lookback">Lookback</label>
|
||||||
|
<Dropdown
|
||||||
|
id="monitor-lookback"
|
||||||
|
className="w-36 normal-case tracking-normal"
|
||||||
|
value={activeLookback}
|
||||||
|
onChange={onLookbackChange}
|
||||||
|
options={monitor.lookbacks.map((l) => ({ value: l.lookback, label: l.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier 1 — what the book returned. */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatTile
|
||||||
|
label="Total Return"
|
||||||
|
value={fmtSignedPct(monitorRun.total_return_pct)}
|
||||||
|
valueClass={rColor(monitorRun.total_return_pct)}
|
||||||
|
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
||||||
|
/>
|
||||||
|
<StatTile label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
||||||
|
<StatTile label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
||||||
|
<StatTile label="Sharpe" value={fmtRatio(monitorRun.sharpe)} />
|
||||||
|
<StatTile label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier 2 — how good that return was. Smaller and labelled on purpose:
|
||||||
|
ten equal tiles would read as ten equally important facts. */}
|
||||||
|
{isLegacyRun ? (
|
||||||
|
<p className="text-[11px] text-gray-600">
|
||||||
|
Risk-adjusted quality metrics appear after the next backtest run.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="section-index">Risk-adjusted quality</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatTile
|
||||||
|
size="sm"
|
||||||
|
label="Sortino"
|
||||||
|
value={fmtRatio(monitorRun.sortino)}
|
||||||
|
title="Return per unit of downside deviation (annualized)."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
size="sm"
|
||||||
|
label="Calmar (MAR)"
|
||||||
|
value={fmtRatio(monitorRun.calmar)}
|
||||||
|
title="CAGR divided by maximum drawdown."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
size="sm"
|
||||||
|
label="Gain / Pain"
|
||||||
|
value={fmtRatio(monitorRun.gain_to_pain)}
|
||||||
|
title="Sum of monthly returns divided by the absolute sum of the negative ones."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
size="sm"
|
||||||
|
label="Profit Factor ($)"
|
||||||
|
value={fmtRatio(monitorRun.profit_factor)}
|
||||||
|
title="Gross winning dollars divided by gross losing dollars, across closed trades."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
size="sm"
|
||||||
|
label="EV / trade"
|
||||||
|
value={fmtSignedMoney(monitorRun.avg_trade_pnl)}
|
||||||
|
valueClass={rColor(monitorRun.avg_trade_pnl)}
|
||||||
|
title="Average realized P&L per closed trade."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EquityCurveChart run={monitorRun} />
|
||||||
|
|
||||||
|
{/* avg_trade_pnl is a tile now (EV / trade) — not repeated here. */}
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||||
|
{fmtR(monitorRun.worst_trade_r)}
|
||||||
|
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||||
|
<> · Re-entry after gate failure and fresh qualification</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||||
|
<div className="glass overflow-x-auto p-4">
|
||||||
|
<p className="section-index mb-2">Per-year returns</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{monitorRun.yearly_returns.map((y) => (
|
||||||
|
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
||||||
|
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
||||||
|
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
||||||
|
{fmtSignedPct(y.return_pct)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ const sizeClasses: Record<Size, string> = {
|
|||||||
md: 'px-4 py-2 text-sm',
|
md: 'px-4 py-2 text-sm',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Spinner({ className = 'h-4 w-4' }: { className?: string }) {
|
function Spinner({ className = 'h-4 w-4' }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
const pulse = 'animate-pulse rounded-lg bg-white/[0.05]';
|
const pulse = 'animate-pulse rounded-lg bg-white/[0.05]';
|
||||||
|
|
||||||
export function SkeletonLine({ className = '' }: { className?: string }) {
|
|
||||||
return <div className={`${pulse} h-4 w-full ${className}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonCard({ className = '' }: { className?: string }) {
|
export function SkeletonCard({ className = '' }: { className?: string }) {
|
||||||
return <div className={`${pulse} h-32 w-full ${className}`} />;
|
return <div className={`${pulse} h-32 w-full ${className}`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* One labelled metric. Lifted from the byte-identical `Stat` that lived in both
|
||||||
|
* BacktestPanel and MyTradesPanel.
|
||||||
|
*
|
||||||
|
* `size` is the hierarchy lever: `md` (default) is the headline look those two
|
||||||
|
* panels already had; `sm` marks a metric as supporting detail, which is what
|
||||||
|
* keeps a second row of ratios from reading as equally important as the returns
|
||||||
|
* above it.
|
||||||
|
*/
|
||||||
|
export function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClass = 'text-gray-100',
|
||||||
|
sub,
|
||||||
|
title,
|
||||||
|
size = 'md',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClass?: string;
|
||||||
|
sub?: string;
|
||||||
|
/** Native tooltip — how the metric is defined. */
|
||||||
|
title?: string;
|
||||||
|
size?: 'md' | 'sm';
|
||||||
|
}) {
|
||||||
|
const pad = size === 'sm' ? 'p-3' : 'p-4';
|
||||||
|
const text = size === 'sm' ? 'text-lg' : 'text-2xl';
|
||||||
|
return (
|
||||||
|
<div className={`glass ${pad}`} title={title}>
|
||||||
|
<p className="section-index">{label}</p>
|
||||||
|
<p className={`num mt-1.5 ${text} font-semibold ${valueClass}`}>{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/* Dev-only visual harness for FundamentalsPanel. Served at /harness.html by
|
|
||||||
* `vite`. Not imported by the app. Renders the three key states so desktop and
|
|
||||||
* mobile can be eyeballed with representative fixtures. */
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import '../styles/globals.css';
|
|
||||||
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
|
|
||||||
import type { FundamentalResponse, MetricItem } from '../lib/types';
|
|
||||||
|
|
||||||
function h(period: string, value: number | null) {
|
|
||||||
return { period_end: period, value };
|
|
||||||
}
|
|
||||||
const P = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-28'];
|
|
||||||
|
|
||||||
function dateFromToday(days: number): string {
|
|
||||||
const date = new Date();
|
|
||||||
date.setHours(12, 0, 0, 0);
|
|
||||||
date.setDate(date.getDate() + days);
|
|
||||||
return [
|
|
||||||
date.getFullYear(),
|
|
||||||
String(date.getMonth() + 1).padStart(2, '0'),
|
|
||||||
String(date.getDate()).padStart(2, '0'),
|
|
||||||
].join('-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function metric(key: string, value: number | null, hist: (number | null)[],
|
|
||||||
industry: MetricItem['industry'] = null,
|
|
||||||
caveat: string | null = null): MetricItem {
|
|
||||||
return {
|
|
||||||
key: key as MetricItem['key'], value,
|
|
||||||
history: hist.map((v, i) => h(P[i], v)),
|
|
||||||
industry, period_end: '2026-03-28', filed_date: '2026-05-01', caveat,
|
|
||||||
source: 'sec',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const ind = (median: number, favorable_percentile: number) =>
|
|
||||||
({ label: 'SIC 35 peers', median, favorable_percentile, peer_count: 12 });
|
|
||||||
|
|
||||||
const legacy = {
|
|
||||||
pe_ratio: null, revenue_growth: null, earnings_surprise: null, market_cap: null,
|
|
||||||
next_earnings_date: null, fetched_at: null, unavailable_fields: {},
|
|
||||||
setup_eligible: true, setup_block_code: null, setup_block_reason: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const full: FundamentalResponse = {
|
|
||||||
symbol: 'AAPL', ...legacy,
|
|
||||||
earnings: {
|
|
||||||
next: { date: dateFromToday(12), session: 'amc', days_until: 12 },
|
|
||||||
recent: [
|
|
||||||
{ announce_date: '2025-08-01', period_end: '2025-06-30', eps_estimate: 1.4, eps_actual: 1.6, surprise_pct: 14.3 },
|
|
||||||
{ announce_date: '2025-11-01', period_end: '2025-09-30', eps_estimate: 1.7, eps_actual: 1.9, surprise_pct: 11.8 },
|
|
||||||
{ announce_date: '2026-02-01', period_end: '2025-12-31', eps_estimate: 2.6, eps_actual: 2.4, surprise_pct: -7.7 },
|
|
||||||
{ announce_date: '2026-05-01', period_end: '2026-03-28', eps_estimate: 1.5, eps_actual: 1.65, surprise_pct: 10.0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
metrics: [
|
|
||||||
metric('revenue_growth_yoy', 18, [8, 11, 15, 18], ind(11, 82)),
|
|
||||||
metric('eps_growth_yoy', 24, [10, 18, 22, 24], ind(15, 70)),
|
|
||||||
metric('operating_margin', 32, [30, 31, 31, 32], ind(22, 88)),
|
|
||||||
metric('fcf_margin', 28, [24, 25, 27, 28], ind(18, 80)),
|
|
||||||
metric('net_debt', 16.2e9, [46e9, 44e9, 24e9, 16.2e9], null),
|
|
||||||
metric('net_debt_to_ebitda', 1.4, [1.9, 1.7, 1.5, 1.4], ind(2.1, 68)),
|
|
||||||
metric('share_count_change_yoy', -1.7, [-2.4, -2.2, -2.3, -1.7], null),
|
|
||||||
],
|
|
||||||
valuation: {
|
|
||||||
pe: 29.2, fcf_yield: 3.8, market_cap_est: 3.2e12,
|
|
||||||
pe_industry: ind(23.5, 30), fcf_yield_industry: ind(3.1, 70), price_date: '2026-05-01',
|
|
||||||
},
|
|
||||||
reads: {
|
|
||||||
header: 'growth accelerating · margins improving · valuation priced above peers',
|
|
||||||
by_key: {
|
|
||||||
revenue_growth_yoy: 'accelerating', eps_growth_yoy: 'accelerating',
|
|
||||||
operating_margin: 'improving', fcf_margin: 'improving',
|
|
||||||
share_count_change_yoy: 'buying back', net_debt_to_ebitda: 'conservative leverage',
|
|
||||||
pe: 'priced above peers', fcf_yield: 'above peers', net_debt: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const partial: FundamentalResponse = {
|
|
||||||
symbol: 'NEWCO', ...legacy,
|
|
||||||
earnings: { next: { date: dateFromToday(0), session: 'unknown', days_until: 0 }, recent: [] },
|
|
||||||
metrics: [
|
|
||||||
metric('revenue_growth_yoy', 12, [null, 8, 10, 12], null),
|
|
||||||
metric(
|
|
||||||
'eps_growth_yoy',
|
|
||||||
null,
|
|
||||||
[null, null, null, null],
|
|
||||||
null,
|
|
||||||
'Not comparable: share count changed at least 25%; possible split or corporate action.',
|
|
||||||
),
|
|
||||||
metric('operating_margin', 25, [24, 24, 25, 25], null),
|
|
||||||
metric('fcf_margin', null, [null, null, null, null], null),
|
|
||||||
metric('net_debt', null, [], null),
|
|
||||||
metric('net_debt_to_ebitda', 1.9, [1.7, 1.8, 1.9, 1.9], null),
|
|
||||||
metric(
|
|
||||||
'share_count_change_yoy',
|
|
||||||
null,
|
|
||||||
[1.8, 2.0, 2.0, null],
|
|
||||||
null,
|
|
||||||
'Not comparable: share count changed at least 25%; possible split or corporate action.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
valuation: {
|
|
||||||
pe: 15.2, fcf_yield: null, market_cap_est: 5.4e8,
|
|
||||||
pe_industry: null, fcf_yield_industry: null, price_date: '2026-05-01',
|
|
||||||
},
|
|
||||||
reads: {
|
|
||||||
header: 'growth steady · margins stable',
|
|
||||||
by_key: {
|
|
||||||
revenue_growth_yoy: 'steady', operating_margin: 'stable',
|
|
||||||
share_count_change_yoy: '2.1% dilution', net_debt_to_ebitda: null,
|
|
||||||
pe: null, fcf_yield: null, eps_growth_yoy: null, fcf_margin: null, net_debt: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const empty: FundamentalResponse = {
|
|
||||||
symbol: 'ADR', ...legacy,
|
|
||||||
earnings: { next: null, recent: [] },
|
|
||||||
metrics: [
|
|
||||||
'revenue_growth_yoy', 'eps_growth_yoy', 'operating_margin', 'fcf_margin',
|
|
||||||
'net_debt', 'net_debt_to_ebitda', 'share_count_change_yoy',
|
|
||||||
].map((k) => metric(k, null, [])),
|
|
||||||
valuation: null,
|
|
||||||
reads: { header: null, by_key: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
function Case({ title, data }: { title: string; data: FundamentalResponse }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 text-[11px] uppercase tracking-widest text-gray-500">{title}</div>
|
|
||||||
<FundamentalsPanel data={data} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
|
||||||
<p className="text-[11px] uppercase tracking-widest text-gray-500">
|
|
||||||
Desktop width (~768px, two columns). Resize the browser to ~390px to check mobile (single column).
|
|
||||||
</p>
|
|
||||||
<Case title="Full" data={full} />
|
|
||||||
<Case title="Partial · insufficient peers" data={partial} />
|
|
||||||
<Case title="Empty" data={empty} />
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
@@ -90,36 +90,6 @@ export function useUpdateSetting() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFundamentalsCutoverSettings() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'fundamentals-cutover'],
|
|
||||||
queryFn: () => adminApi.getFundamentalsCutoverSettings(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateFundamentalsCutoverSettings() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { addToast } = useToast();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (enabled: boolean) =>
|
|
||||||
adminApi.updateFundamentalsCutoverSettings(enabled),
|
|
||||||
onSuccess: (config) => {
|
|
||||||
qc.setQueryData(['admin', 'fundamentals-cutover'], config);
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
|
||||||
addToast(
|
|
||||||
config.enabled ? 'success' : 'info',
|
|
||||||
config.enabled
|
|
||||||
? 'SEC + Dolt fundamentals activated'
|
|
||||||
: 'SEC + Dolt cache refresh paused',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
addToast('error', error.message || 'Failed to update fundamentals data source');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRecommendationSettings() {
|
export function useRecommendationSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'recommendation-settings'],
|
queryKey: ['admin', 'recommendation-settings'],
|
||||||
@@ -346,14 +316,6 @@ export function useJobs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFundamentalsParityReport() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'fundamentals-parity'],
|
|
||||||
queryFn: () => adminApi.getFundamentalsParityReport(),
|
|
||||||
refetchInterval: 15_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePipelineReadiness() {
|
export function usePipelineReadiness() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'pipeline-readiness'],
|
queryKey: ['admin', 'pipeline-readiness'],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { ExitPolicy, TradeSetup } from './types';
|
|||||||
* Guarded by test_prod_strategy_parity.py so a backend change can't silently
|
* Guarded by test_prod_strategy_parity.py so a backend change can't silently
|
||||||
* desync this.
|
* desync this.
|
||||||
*/
|
*/
|
||||||
export const SETUP_STOP_ATR_MULTIPLIER = 1.5;
|
const SETUP_STOP_ATR_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
export interface ExitPlan {
|
export interface ExitPlan {
|
||||||
mode: ExitPolicy['mode'];
|
mode: ExitPolicy['mode'];
|
||||||
|
|||||||
@@ -72,3 +72,58 @@ export function formatDateTime(d: string): string {
|
|||||||
hour12: true,
|
hour12: true,
|
||||||
})}`;
|
})}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Metric display helpers ─────────────────────────────────────────────────
|
||||||
|
// Shared by the Signals backtest/paper-trade panels. Dashboard and
|
||||||
|
// OpenTradesPanel deliberately still carry their own copies — migrating them is
|
||||||
|
// a separate change, not drive-by scope.
|
||||||
|
|
||||||
|
/** R-multiple with an explicit sign. e.g. 1.2 → "+1.20R", null → "—" */
|
||||||
|
export function fmtR(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. 12.34 → "12.3%" */
|
||||||
|
export function fmtPct(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. 12.34 → "+12.3%" */
|
||||||
|
export function fmtSignedPct(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always rendered negative, whatever sign the source uses. 17.3 → "-17.3%" */
|
||||||
|
export function fmtDrawdown(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. 15.3 → "15.3d" */
|
||||||
|
export function fmtDays(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unitless ratios — Sharpe, Sortino, Calmar, Gain/Pain, profit factor. */
|
||||||
|
export function fmtRatio(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed currency, using U+2212 for negatives. e.g. -12.3 → "−$12.30"
|
||||||
|
* Use wherever a value can go negative and the unit is money.
|
||||||
|
* (For a bare unsigned amount there is already `formatPrice` above.)
|
||||||
|
*/
|
||||||
|
export function fmtSignedMoney(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v >= 0 ? '+' : '−'}$${Math.abs(v).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green above zero, red below, neutral at zero or null. */
|
||||||
|
export function rColor(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return 'text-gray-400';
|
||||||
|
if (v > 0) return 'text-emerald-400';
|
||||||
|
if (v < 0) return 'text-red-400';
|
||||||
|
return 'text-gray-300';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Fundamental dimension readouts for the Fundamentals tab.
|
|
||||||
*
|
|
||||||
* Scoring mirrors app/services/scoring_service.py _compute_fundamental_score:
|
|
||||||
* equal-weighted average of available P/E, revenue growth, and earnings
|
|
||||||
* surprise (need ≥2 metrics). Market cap is display-only, not scored.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface FundamentalMetrics {
|
|
||||||
pe_ratio: number | null;
|
|
||||||
revenue_growth: number | null;
|
|
||||||
earnings_surprise: number | null;
|
|
||||||
market_cap?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatusRead {
|
|
||||||
text: string;
|
|
||||||
tone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp = (v: number, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, v));
|
|
||||||
|
|
||||||
/** P/E sub-score: lower is better. PE 15 → 100, 30 → 50, 45 → 0. */
|
|
||||||
export function peSubScore(pe: number): number {
|
|
||||||
return clamp(100 - (pe - 15) * (100 / 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Revenue growth sub-score: 0% → 50, +20% → 100, −20% → 0. */
|
|
||||||
export function revenueGrowthSubScore(growthPct: number): number {
|
|
||||||
return clamp(50 + growthPct * 2.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Earnings surprise sub-score: 0% → 50, +10% → 100, −10% → 0. */
|
|
||||||
export function earningsSurpriseSubScore(surprisePct: number): number {
|
|
||||||
return clamp(50 + surprisePct * 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overall fundamental score, or null when fewer than 2 scored metrics.
|
|
||||||
* Matches backend MIN_METRICS = 2.
|
|
||||||
*/
|
|
||||||
export function fundamentalScore(m: FundamentalMetrics): number | null {
|
|
||||||
const parts: number[] = [];
|
|
||||||
if (m.pe_ratio != null && m.pe_ratio > 0) parts.push(peSubScore(m.pe_ratio));
|
|
||||||
if (m.revenue_growth != null) parts.push(revenueGrowthSubScore(m.revenue_growth));
|
|
||||||
if (m.earnings_surprise != null) parts.push(earningsSurpriseSubScore(m.earnings_surprise));
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
return parts.reduce((a, b) => a + b, 0) / parts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function overallFundamentalStatus(score: number | null): StatusRead {
|
|
||||||
if (score == null) {
|
|
||||||
return { text: 'incomplete data', tone: 'text-amber-300' };
|
|
||||||
}
|
|
||||||
if (score >= 70) return { text: 'strong fundamentals', tone: 'text-emerald-300' };
|
|
||||||
if (score >= 55) return { text: 'healthy', tone: 'text-emerald-300' };
|
|
||||||
if (score >= 45) return { text: 'mixed / average', tone: 'text-gray-400' };
|
|
||||||
if (score >= 30) return { text: 'soft', tone: 'text-amber-300' };
|
|
||||||
return { text: 'weak fundamentals', tone: 'text-red-300' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function peStatus(pe: number | null): StatusRead | null {
|
|
||||||
if (pe == null || !(pe > 0)) return null;
|
|
||||||
if (pe <= 15) return { text: 'cheap / attractive', tone: 'text-emerald-300' };
|
|
||||||
if (pe <= 25) return { text: 'fair', tone: 'text-gray-400' };
|
|
||||||
if (pe <= 35) return { text: 'expensive', tone: 'text-amber-300' };
|
|
||||||
return { text: 'rich', tone: 'text-red-300' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function revenueGrowthStatus(growthPct: number | null): StatusRead | null {
|
|
||||||
if (growthPct == null) return null;
|
|
||||||
if (growthPct >= 20) return { text: 'strong growth', tone: 'text-emerald-300' };
|
|
||||||
if (growthPct >= 5) return { text: 'solid growth', tone: 'text-emerald-300' };
|
|
||||||
if (growthPct >= -5) return { text: 'flat', tone: 'text-gray-400' };
|
|
||||||
if (growthPct >= -20) return { text: 'contracting', tone: 'text-amber-300' };
|
|
||||||
return { text: 'deep contraction', tone: 'text-red-300' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function earningsSurpriseStatus(surprisePct: number | null): StatusRead | null {
|
|
||||||
if (surprisePct == null) return null;
|
|
||||||
if (surprisePct >= 10) return { text: 'beat (large)', tone: 'text-emerald-300' };
|
|
||||||
if (surprisePct >= 2) return { text: 'beat', tone: 'text-emerald-300' };
|
|
||||||
if (surprisePct >= -2) return { text: 'in line', tone: 'text-gray-400' };
|
|
||||||
if (surprisePct >= -10) return { text: 'miss', tone: 'text-amber-300' };
|
|
||||||
return { text: 'miss (large)', tone: 'text-red-300' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Size band only — not good/bad, not part of the score. */
|
|
||||||
export function marketCapStatus(marketCap: number | null): StatusRead | null {
|
|
||||||
if (marketCap == null || !(marketCap > 0)) return null;
|
|
||||||
if (marketCap >= 200e9) return { text: 'mega cap', tone: 'text-gray-400' };
|
|
||||||
if (marketCap >= 10e9) return { text: 'large cap', tone: 'text-gray-400' };
|
|
||||||
if (marketCap >= 2e9) return { text: 'mid cap', tone: 'text-gray-400' };
|
|
||||||
if (marketCap >= 300e6) return { text: 'small cap', tone: 'text-gray-400' };
|
|
||||||
return { text: 'micro cap', tone: 'text-gray-400' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function metricStatus(
|
|
||||||
key: 'pe_ratio' | 'revenue_growth' | 'earnings_surprise' | 'market_cap',
|
|
||||||
value: number | null,
|
|
||||||
): StatusRead | null {
|
|
||||||
switch (key) {
|
|
||||||
case 'pe_ratio':
|
|
||||||
return peStatus(value);
|
|
||||||
case 'revenue_growth':
|
|
||||||
return revenueGrowthStatus(value);
|
|
||||||
case 'earnings_surprise':
|
|
||||||
return earningsSurpriseStatus(value);
|
|
||||||
case 'market_cap':
|
|
||||||
return marketCapStatus(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
import type { MarketRegime } from './types';
|
import type { MarketRegime } from './types';
|
||||||
|
|
||||||
export function regimeColor(label: MarketRegime['label']): string {
|
|
||||||
switch (label) {
|
|
||||||
case 'bullish':
|
|
||||||
return 'text-emerald-400';
|
|
||||||
case 'bearish':
|
|
||||||
return 'text-red-400';
|
|
||||||
case 'neutral':
|
|
||||||
return 'text-amber-400';
|
|
||||||
default:
|
|
||||||
return 'text-gray-400';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regimeDot(label: MarketRegime['label']): string {
|
export function regimeDot(label: MarketRegime['label']): string {
|
||||||
switch (label) {
|
switch (label) {
|
||||||
case 'bullish':
|
case 'bullish':
|
||||||
@@ -36,7 +23,7 @@ export function regimeHeadline(r: MarketRegime): string {
|
|||||||
return `${b} ${r.label}${pct}`;
|
return `${b} ${r.label}${pct}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a setup direction fights the prevailing market regime. */
|
/** Whether a setup direction fights the prevailing SPY trend. */
|
||||||
export function isCounterTrend(direction: string, label: MarketRegime['label']): boolean {
|
export function isCounterTrend(direction: string, label: MarketRegime['label']): boolean {
|
||||||
if (label === 'bullish') return direction === 'short';
|
if (label === 'bullish') return direction === 'short';
|
||||||
if (label === 'bearish') return direction === 'long';
|
if (label === 'bearish') return direction === 'long';
|
||||||
|
|||||||
@@ -187,21 +187,17 @@ export interface ActivationConfig {
|
|||||||
exclude_neutral: boolean;
|
exclude_neutral: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FundamentalsCutoverConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cron schedule for morning / near-close / after-close / intraday + fundamentals
|
// Cron schedule for morning / near-close / after-close / intraday + fundamentals
|
||||||
export interface ScheduleConfig {
|
export interface ScheduleConfig {
|
||||||
schedule_timezone: string;
|
schedule_timezone: string;
|
||||||
schedule_daily_pipeline_cron: string;
|
schedule_daily_pipeline_cron: string;
|
||||||
schedule_dolt_earnings_cron: string;
|
schedule_dolt_earnings_cron: string;
|
||||||
schedule_sec_fundamentals_cron: string;
|
schedule_sec_fundamentals_cron: string;
|
||||||
schedule_fundamentals_parity_cron: string;
|
|
||||||
schedule_near_close_pipeline_cron: string;
|
schedule_near_close_pipeline_cron: string;
|
||||||
schedule_after_close_pipeline_cron: string;
|
schedule_after_close_pipeline_cron: string;
|
||||||
schedule_intraday_pipeline_cron: string;
|
schedule_intraday_pipeline_cron: string;
|
||||||
schedule_fundamentals_cron: string;
|
schedule_backtest_cron: string;
|
||||||
|
schedule_ticker_universe_cron: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime sentiment LLM configuration
|
// Runtime sentiment LLM configuration
|
||||||
@@ -299,6 +295,20 @@ export interface BacktestPortfolioPolicy {
|
|||||||
cagr_pct: number | null;
|
cagr_pct: number | null;
|
||||||
max_drawdown_pct: number;
|
max_drawdown_pct: number;
|
||||||
sharpe: number | null;
|
sharpe: number | null;
|
||||||
|
sharpe_se?: number | null;
|
||||||
|
psr?: number | null;
|
||||||
|
/** CAGR / max drawdown — the same number commonly called MAR. */
|
||||||
|
calmar?: number | null;
|
||||||
|
/**
|
||||||
|
* Optional because reports cached before these landed lack the keys entirely.
|
||||||
|
* An ABSENT `sortino` is how the UI detects such a report — distinct from
|
||||||
|
* `null`, which means "computed, undefined for this run".
|
||||||
|
*/
|
||||||
|
sortino?: number | null;
|
||||||
|
/** Schwager, on monthly returns. */
|
||||||
|
gain_to_pain?: number | null;
|
||||||
|
/** DOLLAR-based. Not the R-based profit_factor on BacktestBucket. */
|
||||||
|
profit_factor?: number | null;
|
||||||
trades: number;
|
trades: number;
|
||||||
win_rate: number | null;
|
win_rate: number | null;
|
||||||
avg_trade_pnl: number | null;
|
avg_trade_pnl: number | null;
|
||||||
@@ -506,6 +516,9 @@ export interface RegimeFundamentalOverlay {
|
|||||||
reasoning: string | null;
|
reasoning: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
fetched_at: string | null;
|
fetched_at: string | null;
|
||||||
|
/** Whether anything was actually collected. Live reading only; the snapshot's
|
||||||
|
* point-in-time overlay omits it. */
|
||||||
|
observed?: boolean;
|
||||||
observed_in_snapshot?: boolean;
|
observed_in_snapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,12 +568,15 @@ export interface RegimeMonitor {
|
|||||||
inputs_fresh: boolean;
|
inputs_fresh: boolean;
|
||||||
snapshot_age_days?: number;
|
snapshot_age_days?: number;
|
||||||
is_fresh?: boolean;
|
is_fresh?: boolean;
|
||||||
|
/** Upstream history spans, so a silently truncated series is visible. */
|
||||||
|
credit_history_days?: number | null;
|
||||||
|
vix_history_days?: number | null;
|
||||||
};
|
};
|
||||||
quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
|
quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegimeFundamentals {
|
export interface RegimeFundamentals {
|
||||||
methodology: 'v3';
|
methodology: 'v4';
|
||||||
f1_score: number | null;
|
f1_score: number | null;
|
||||||
f3_score: number | null;
|
f3_score: number | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
@@ -856,6 +872,10 @@ export interface Ticker {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Set once the symbol stopped trading: excluded from signals, history kept. */
|
||||||
|
delisted_on: string | null;
|
||||||
|
/** How the delisting was learned: "form_25" (SEC confirmed) | "manual". */
|
||||||
|
delisted_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
@@ -892,7 +912,7 @@ export interface TickerUniverseSetting {
|
|||||||
|
|
||||||
export interface TickerUniverseBootstrapResult {
|
export interface TickerUniverseBootstrapResult {
|
||||||
universe: TickerUniverse;
|
universe: TickerUniverse;
|
||||||
/** Where the member list came from: wikipedia_sp500 | fmp | cache | seed | … */
|
/** Where the member list came from: wikipedia_sp500 | nasdaq_trader | cache | seed | … */
|
||||||
source?: string;
|
source?: string;
|
||||||
total_universe_symbols: number;
|
total_universe_symbols: number;
|
||||||
added: number;
|
added: number;
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { AlertSettings } from '../components/admin/AlertSettings';
|
|||||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||||
import { JobControls } from '../components/admin/JobControls';
|
import { JobControls } from '../components/admin/JobControls';
|
||||||
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
|
|
||||||
import { FundamentalsCutoverSettings } from '../components/admin/FundamentalsCutoverSettings';
|
|
||||||
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
|
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
|
||||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||||
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
||||||
@@ -37,7 +35,6 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'Tickers' && <TickerManagement />}
|
{activeTab === 'Tickers' && <TickerManagement />}
|
||||||
{activeTab === 'Settings' && (
|
{activeTab === 'Settings' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FundamentalsCutoverSettings />
|
|
||||||
<ActivationSettings />
|
<ActivationSettings />
|
||||||
<ExitPolicySettings />
|
<ExitPolicySettings />
|
||||||
<PerformanceSettings />
|
<PerformanceSettings />
|
||||||
@@ -51,7 +48,6 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'Jobs' && (
|
{activeTab === 'Jobs' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ScheduleSettings />
|
<ScheduleSettings />
|
||||||
<FundamentalsParityPanel />
|
|
||||||
<JobControls />
|
<JobControls />
|
||||||
<PipelineReadinessPanel />
|
<PipelineReadinessPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import type {
|
|||||||
RegimeFundamentalOverlay,
|
RegimeFundamentalOverlay,
|
||||||
RegimeFundamentals,
|
RegimeFundamentals,
|
||||||
RegimeFundamentalsUpdate,
|
RegimeFundamentalsUpdate,
|
||||||
|
RegimeMonitor,
|
||||||
RegimeReading,
|
RegimeReading,
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
|
|
||||||
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
|
const RegimeChart = lazy(() => import('../components/regime/RegimeChart'));
|
||||||
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
|
|
||||||
|
|
||||||
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
|
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
|
||||||
stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' },
|
stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' },
|
||||||
@@ -53,12 +53,10 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
|
|||||||
function ScoreGauge({
|
function ScoreGauge({
|
||||||
label,
|
label,
|
||||||
reading,
|
reading,
|
||||||
divider,
|
|
||||||
footnote,
|
footnote,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
reading: RegimeReading | undefined;
|
reading: RegimeReading | undefined;
|
||||||
divider?: number;
|
|
||||||
footnote: ReactNode;
|
footnote: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const score = reading?.score;
|
const score = reading?.score;
|
||||||
@@ -66,7 +64,9 @@ function ScoreGauge({
|
|||||||
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
|
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
|
||||||
const position = Math.min(100, Math.max(0, score ?? 0));
|
const position = Math.min(100, Math.max(0, score ?? 0));
|
||||||
const bands = reading?.bands;
|
const bands = reading?.bands;
|
||||||
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80];
|
// No fallback ticks: the two axes have different thresholds, so guessing a
|
||||||
|
// shared set would mislabel one of them. Render none rather than wrong ones.
|
||||||
|
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
|
||||||
return (
|
return (
|
||||||
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
@@ -92,10 +92,10 @@ function ScoreGauge({
|
|||||||
</div>
|
</div>
|
||||||
{score != null && (
|
{score != null && (
|
||||||
<>
|
<>
|
||||||
|
{/* The quadrant divider is each axis's watch/elevated boundary, so it
|
||||||
|
is already the middle tick below — drawing it again was two marks
|
||||||
|
for one threshold. */}
|
||||||
<div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
<div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
||||||
{divider != null && (
|
|
||||||
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`}
|
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`}
|
||||||
style={{ left: `${position}%` }}
|
style={{ left: `${position}%` }}
|
||||||
@@ -113,7 +113,7 @@ function ScoreGauge({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>
|
<p className="mt-4 text-xs text-gray-500">{footnote}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,30 +125,48 @@ const CAPEX_TONE: Record<CapexState, string> = {
|
|||||||
unknown: 'text-gray-500',
|
unknown: 'text-gray-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
|
||||||
|
|
||||||
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
||||||
const capex = overlay.capex ?? {};
|
const capex = overlay.capex ?? {};
|
||||||
const reaction = overlay.good_news_stock_down;
|
const reaction = overlay.good_news_stock_down;
|
||||||
|
|
||||||
|
// Nothing collected: the stored default is "unknown" for every hyperscaler
|
||||||
|
// and "mixed" for the reaction, which are placeholders, not a reading.
|
||||||
|
if (overlay.observed === false) {
|
||||||
|
return (
|
||||||
|
<div className="glass border border-white/[0.06] p-5">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
||||||
|
<p className="mt-3 text-xs text-gray-500">
|
||||||
|
No observation collected yet. An admin can collect one under Admin · Monitor settings. It is
|
||||||
|
context only — it never enters State or Warning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass border border-white/[0.06] p-5">
|
<div className="glass border border-white/[0.06] p-5">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
||||||
Fundamental overlay · context, not scored
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||||||
{overlay.source && <span>{overlay.source}</span>}
|
{overlay.source && <span>{overlay.source}</span>}
|
||||||
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>}
|
{/* When pending, the line below is the single carrier of this date. */}
|
||||||
|
{overlay.effective_date && !overlay.pending && <span>· effective {overlay.effective_date}</span>}
|
||||||
{overlay.pending && <Badge label="pending" variant="manual" />}
|
{overlay.pending && <Badge label="pending" variant="manual" />}
|
||||||
{overlay.stale && <Badge label="stale" variant="manual" />}
|
{overlay.stale && <Badge label="stale" variant="manual" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{overlay.pending ? (
|
{/* A pending observation is still shown — it is the freshest read we
|
||||||
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
|
have, and nothing here is scored. The date says when the stored
|
||||||
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
|
point-in-time record picks it up. */}
|
||||||
Observations are never backdated, so the reading below appears from that session onward.
|
{overlay.pending && (
|
||||||
|
<p className="mt-3 text-xs text-amber-400/90">
|
||||||
|
Shown as collected. The point-in-time record picks it up{' '}
|
||||||
|
{overlay.effective_date ?? 'next session'} — observations are never backdated.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
<div className="mb-2 flex items-baseline justify-between text-xs">
|
||||||
@@ -174,24 +192,20 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{overlay.reasoning && (
|
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
||||||
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
|
|
||||||
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
|
|
||||||
Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even
|
|
||||||
when both were pegged — so they are reported here rather than diluted into a daily score.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
|
/** One table for both axes — they share a shape, and two panels invited
|
||||||
|
* comparing numbers that are not on the same scale. */
|
||||||
|
function PillarTable({ state, warning }: { state: RegimeReading; warning: RegimeReading }) {
|
||||||
|
const groups: { title: string; reading: RegimeReading }[] = [
|
||||||
|
{ title: 'State', reading: state },
|
||||||
|
{ title: 'Warning', reading: warning },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
|
<Disclosure summary="Pillars & sensors · what drives each score">
|
||||||
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -202,7 +216,16 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
|
|||||||
<th className="px-4 py-3 text-right font-medium">Contribution</th>
|
<th className="px-4 py-3 text-right font-medium">Contribution</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
{groups.map(({ title, reading }) => (
|
||||||
|
<tbody key={title}>
|
||||||
|
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
|
||||||
|
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
|
||||||
|
{title}
|
||||||
|
<span className="ml-2 normal-case tracking-normal text-gray-600">
|
||||||
|
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{reading.pillars.map((pillar) => (
|
{reading.pillars.map((pillar) => (
|
||||||
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -218,16 +241,53 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
|
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
|
||||||
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
|
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
|
||||||
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : '—'}</td>
|
<td className="px-4 py-3 text-right num text-gray-300">
|
||||||
|
{pillar.available ? pillar.contribution.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
))}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-[11px] text-gray-500" title={title}>
|
||||||
|
{label} <span className="num text-gray-400">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provenance strip — replaces three separate prose blocks. */
|
||||||
|
function MetaStrip({ data }: { data: RegimeMonitor }) {
|
||||||
|
const quality = data.data_quality;
|
||||||
|
const basket = data.basket;
|
||||||
|
const days = (value: number | null | undefined) => (value == null ? '—' : `${value}d`);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<MetaChip label="as of" value={data.date ?? '—'} />
|
||||||
|
<MetaChip label="oldest input" value={days(quality?.oldest_market_input_age_days)} />
|
||||||
|
{basket && (
|
||||||
|
<MetaChip
|
||||||
|
label="basket"
|
||||||
|
value={`${basket.members_available ?? '—'}/${basket.members_expected} · frozen ${basket.basket_asof}`}
|
||||||
|
title={`hash ${basket.hash}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MetaChip
|
||||||
|
label="credit history"
|
||||||
|
value={days(quality?.credit_history_days)}
|
||||||
|
title="Upstream span actually available. ICE caps the HY OAS series at 3 rolling years."
|
||||||
|
/>
|
||||||
|
<MetaChip label="VIX history" value={days(quality?.vix_history_days)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||||
const metrics = report.metrics;
|
const metrics = report.metrics;
|
||||||
return (
|
return (
|
||||||
@@ -278,9 +338,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
|||||||
<p>
|
<p>
|
||||||
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
||||||
{report.reliability.events_detected} detected corrections fall in the test period (
|
{report.reliability.events_detected} detected corrections fall in the test period (
|
||||||
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially
|
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
|
||||||
different headline, and which events flip is usually decided by where the frozen threshold
|
|
||||||
lands rather than by what the score saw. Read the direction, not the ratio.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{report.reliability.sensor_coverage_mismatch && (
|
{report.reliability.sensor_coverage_mismatch && (
|
||||||
@@ -290,17 +348,12 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
|||||||
{report.reliability.sensors_expected} Warning sensors versus{' '}
|
{report.reliability.sensors_expected} Warning sensors versus{' '}
|
||||||
{report.reliability.holdout_full_sensor_share}% of test sessions
|
{report.reliability.holdout_full_sensor_share}% of test sessions
|
||||||
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`}
|
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`}
|
||||||
. The score renormalises over what is available, so the threshold was frozen on a partly
|
. The threshold was frozen on a partly different construct than it is measured against.
|
||||||
different construct than it is measured against.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] leading-relaxed text-gray-600">
|
|
||||||
The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
|
|
||||||
pre-freeze basket history remains exploratory.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -375,7 +428,7 @@ function FundamentalsEditor({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only — this does not enter Warning.</p>
|
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
@@ -450,14 +503,17 @@ export default function RegimePage() {
|
|||||||
const isAdmin = useAuthStore((state) => state.role) === 'admin';
|
const isAdmin = useAuthStore((state) => state.role) === 'admin';
|
||||||
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
||||||
const data = monitor.data;
|
const data = monitor.data;
|
||||||
|
const inputs = data?.inputs;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-slide-up">
|
<div className="space-y-6 animate-slide-up">
|
||||||
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" />
|
<PageHeader
|
||||||
<Callout variant="info"><strong>Risk thermometer — not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout>
|
title="AI/Tech Risk Monitor"
|
||||||
|
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
|
||||||
|
/>
|
||||||
|
|
||||||
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
|
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
|
||||||
{monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>}
|
{monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>}
|
||||||
{data && !data.available && <Callout variant="empty">V2 is not computed yet — run “Regime Monitor” from Admin → Jobs or wait for the daily pipeline.</Callout>}
|
{data && !data.available && <Callout variant="empty">Not computed yet — run “AI/Tech Risk Monitor” from Admin → Jobs or wait for the daily pipeline.</Callout>}
|
||||||
|
|
||||||
{data?.available && data.state && data.warning && (
|
{data?.available && data.state && data.warning && (
|
||||||
<>
|
<>
|
||||||
@@ -467,39 +523,36 @@ export default function RegimePage() {
|
|||||||
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
|
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<ScoreGauge
|
<ScoreGauge
|
||||||
label="State · current structural stress"
|
label="State · stress right now"
|
||||||
reading={data.state}
|
reading={data.state}
|
||||||
divider={data.quadrant_config?.state_divider}
|
footnote={
|
||||||
footnote={<>One capped price vote plus fixed-basket breadth, HY credit, and volatility. As of {data.date}. VIX {data.inputs?.vix ?? '—'} · HY OAS {data.inputs?.hy_oas ?? '—'}.</>}
|
<>
|
||||||
|
Price, breadth, credit and volatility levels · VIX{' '}
|
||||||
|
<span className="num text-gray-400">{inputs?.vix ?? '—'}</span> · HY OAS{' '}
|
||||||
|
<span className="num text-gray-400">{inputs?.hy_oas ?? '—'}</span> · breadth{' '}
|
||||||
|
<span className="num text-gray-400">
|
||||||
|
{inputs?.breadth_pct_above_200 == null ? '—' : `${inputs.breadth_pct_above_200}%`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ScoreGauge
|
<ScoreGauge
|
||||||
label="Warning · deterioration & divergence"
|
label="Warning · deterioration & divergence"
|
||||||
reading={data.warning}
|
reading={data.warning}
|
||||||
divider={data.quadrant_config?.warning_divider}
|
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
|
||||||
footnote={<>Breadth divergence, SMH/SPY rollover, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
Data quality · oldest market input:{' '}
|
|
||||||
{data.data_quality?.oldest_market_input_age_days == null
|
|
||||||
? 'unavailable'
|
|
||||||
: `${data.data_quality.oldest_market_input_age_days}d`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense>
|
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
||||||
<Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
|
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<PillarTable state={data.state} warning={data.warning} />
|
||||||
<PillarBreakdown title="State" reading={data.state} />
|
|
||||||
<PillarBreakdown title="Warning" reading={data.warning} />
|
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
||||||
</div>
|
|
||||||
{data.basket && (
|
<MetaStrip data={data} />
|
||||||
<p className="text-xs leading-relaxed text-gray-600">
|
|
||||||
Fixed basket {data.basket.members_available ?? '—'}/{data.basket.members_expected} available · hash {data.basket.hash} · frozen {data.basket.basket_asof}. History reconstructed before the freeze date is retrospective/exploratory; readings after it form the trustworthy forward series.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,61 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import { Tabs } from '../components/ui/Tabs';
|
import { Tabs } from '../components/ui/Tabs';
|
||||||
import { SetupsPanel } from '../components/signals/SetupsPanel';
|
import { SetupsPanel } from '../components/signals/SetupsPanel';
|
||||||
import { TrackRecordPanel } from '../components/signals/TrackRecordPanel';
|
import { MyTradesPanel } from '../components/signals/MyTradesPanel';
|
||||||
|
import { BacktestPanel } from '../components/signals/BacktestPanel';
|
||||||
|
import { EvaluationPanel } from '../components/signals/EvaluationPanel';
|
||||||
|
|
||||||
const tabs = ['Setups', 'Track Record'] as const;
|
const tabs = ['Setups', 'Paper Trades', 'Backtest'] as const;
|
||||||
type Tab = (typeof tabs)[number];
|
type Tab = (typeof tabs)[number];
|
||||||
|
|
||||||
|
// `track` stays the Paper Trades slug: App.tsx redirects the legacy /performance
|
||||||
|
// route to ?tab=track, and that is where realized results live.
|
||||||
|
const SLUG_TO_TAB: Record<string, Tab> = {
|
||||||
|
track: 'Paper Trades',
|
||||||
|
backtest: 'Backtest',
|
||||||
|
};
|
||||||
|
const TAB_TO_SLUG: Record<Tab, string> = {
|
||||||
|
Setups: '',
|
||||||
|
'Paper Trades': 'track',
|
||||||
|
Backtest: 'backtest',
|
||||||
|
};
|
||||||
|
const SUBTITLE: Record<Tab, string> = {
|
||||||
|
Setups: 'Detected trade setups from the latest scan',
|
||||||
|
'Paper Trades': 'What the strategy actually delivered on trades you took',
|
||||||
|
Backtest: 'Whether the promoted strategy is worth trading, replayed over history',
|
||||||
|
};
|
||||||
|
|
||||||
export default function SignalsPage() {
|
export default function SignalsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const activeTab: Tab = searchParams.get('tab') === 'track' ? 'Track Record' : 'Setups';
|
const activeTab: Tab = SLUG_TO_TAB[searchParams.get('tab') ?? ''] ?? 'Setups';
|
||||||
|
|
||||||
const setTab = (tab: Tab) => {
|
const setTab = (tab: Tab) => {
|
||||||
setSearchParams(tab === 'Track Record' ? { tab: 'track' } : {}, { replace: true });
|
const slug = TAB_TO_SLUG[tab];
|
||||||
|
setSearchParams(slug ? { tab: slug } : {}, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: Record<Tab, ReactNode> = {
|
||||||
|
Setups: <SetupsPanel />,
|
||||||
|
'Paper Trades': <MyTradesPanel />,
|
||||||
|
// The backtest and the diagnostic that checks it against live outcomes.
|
||||||
|
Backtest: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BacktestPanel />
|
||||||
|
<EvaluationPanel />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-slide-up">
|
<div className="space-y-6 animate-slide-up">
|
||||||
<PageHeader
|
<PageHeader title="Signals" subtitle={SUBTITLE[activeTab]} />
|
||||||
title="Signals"
|
|
||||||
subtitle="Detected trade setups and how past signals actually performed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
||||||
|
|
||||||
<div className="animate-fade-in" key={activeTab}>
|
<div className="animate-fade-in" key={activeTab}>
|
||||||
{activeTab === 'Setups' ? <SetupsPanel /> : <TrackRecordPanel />}
|
{body[activeTab]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ interface DataStatusItem {
|
|||||||
available: boolean;
|
available: boolean;
|
||||||
timestamp?: string | null;
|
timestamp?: string | null;
|
||||||
timestampLabel?: string | null;
|
timestampLabel?: string | null;
|
||||||
selector: FetchSelector; // what a refresh of this row fetches
|
selector?: FetchSelector; // what a refresh fetches; omit for rows with no manual refresh
|
||||||
paid?: boolean; // provider call that may cost money/quota
|
paid?: boolean; // provider call that may cost money/quota
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ function DataFreshnessBar({
|
|||||||
) : !item.available ? (
|
) : !item.available ? (
|
||||||
<span className="text-[10px] text-gray-600">no data</span>
|
<span className="text-[10px] text-gray-600">no data</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{item.selector && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onRefresh(item)}
|
onClick={() => onRefresh(item)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -146,6 +147,7 @@ function DataFreshnessBar({
|
|||||||
>
|
>
|
||||||
<RefreshIcon spinning={pendingLabel === item.label} />
|
<RefreshIcon spinning={pendingLabel === item.label} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
|
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -226,11 +228,11 @@ export default function TickerDetailPage() {
|
|||||||
paid: true,
|
paid: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Rebuilt for the whole universe by the nightly SEC + Dolt imports —
|
||||||
|
// there is no per-ticker fetch to offer here.
|
||||||
label: 'Fundamentals',
|
label: 'Fundamentals',
|
||||||
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
|
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
|
||||||
timestamp: fundamentals.data?.fetched_at,
|
timestamp: fundamentals.data?.fetched_at,
|
||||||
selector: ['fundamentals'] as FetchSelector,
|
|
||||||
paid: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'S/R Levels',
|
label: 'S/R Levels',
|
||||||
@@ -247,6 +249,7 @@ export default function TickerDetailPage() {
|
|||||||
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
|
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
|
||||||
|
|
||||||
const handleRefresh = (item: DataStatusItem) => {
|
const handleRefresh = (item: DataStatusItem) => {
|
||||||
|
if (!item.selector) return;
|
||||||
setRefreshingLabel(item.label);
|
setRefreshingLabel(item.label);
|
||||||
ingestion.mutate(
|
ingestion.mutate(
|
||||||
{ symbol, sources: item.selector },
|
{ symbol, sources: item.selector },
|
||||||
|
|||||||
@@ -40,3 +40,21 @@ include = ["app*"]
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# Pinned explicitly rather than inherited. CI installs ruff unpinned, and the
|
||||||
|
# default rule set is not stable across releases: 0.16 broadened it so far that
|
||||||
|
# `ruff check app/` went from 0 findings to 376 -- 168 of them B008 flagging
|
||||||
|
# FastAPI's `Depends()` in a signature default, which is the framework's
|
||||||
|
# documented idiom and not a defect. An unpinned linter with drifting defaults
|
||||||
|
# fails the deploy pipeline on code nobody touched, so the rule set is the thing
|
||||||
|
# to pin; the ruff version can then float freely.
|
||||||
|
#
|
||||||
|
# E4 imports, E7 statements, E9 syntax/IO errors, F pyflakes. This is the set the
|
||||||
|
# tree was already clean under, now applied repo-wide instead of to app/ alone.
|
||||||
|
# Adding rules is welcome -- do it here, deliberately, with the fixes in the same
|
||||||
|
# commit.
|
||||||
|
select = ["E4", "E7", "E9", "F"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
# Regime Monitor v4 calibration
|
||||||
|
|
||||||
|
Generated 2026-08-08T23:16:44 at `43ee619`, 2024-12-05 → 2026-07-24.
|
||||||
|
|
||||||
|
Source hashes (sha256, first 16):
|
||||||
|
|
||||||
|
- `app/services/regime_monitor_service.py` — `3b307e3c2045f35b`
|
||||||
|
- `app/services/breadth_service.py` — `b9ceb93d68d8f01b`
|
||||||
|
- `scripts/run_regime_monitor_calibration.py` — `9bc925256cc6f567`
|
||||||
|
|
||||||
|
## Hard gates
|
||||||
|
|
||||||
|
| gate | expected | measured | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| symbols_fetched | 33 | 33 | ok |
|
||||||
|
| per_symbol_warmup_252_bars | all | 33 | ok |
|
||||||
|
| per_symbol_reaches_last_session | 2026-07-24 | 33 | ok |
|
||||||
|
| breadth_counts_full_basket | 30 | 408/408 sessions | ok |
|
||||||
|
| sessions_scored | 408 | 408 | ok |
|
||||||
|
| last_scored_date | 2026-07-24 | 2026-07-24 | ok |
|
||||||
|
| w1_available_every_session | 408 | 408 | ok |
|
||||||
|
| state_coverage_100_every_row | 0 | 0 | ok |
|
||||||
|
| no_stale_inputs | 0 | 0 | ok |
|
||||||
|
| first_scored_date | 2024-12-05 | 2024-12-05 | ok |
|
||||||
|
| state_v4_le_v3_every_row | 0 | 0 | ok |
|
||||||
|
|
||||||
|
## Distributions
|
||||||
|
|
||||||
|
| variant | avg | median | p80 | p90 | max |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| v2_reconstruction | 22.68 | 16.15 | 35.1 | 65.63 | 91.2 |
|
||||||
|
| v2_reconstruction_oas400 | 26.54 | 18.65 | 42.52 | 81.3 | 100.0 |
|
||||||
|
| v3 | 18.13 | 9.1 | 31.36 | 65.0 | 87.4 |
|
||||||
|
| v4 | 14.78 | 8.35 | 21.7 | 43.63 | 83.5 |
|
||||||
|
| v4-vix-only | 16.64 | 8.35 | 28.6 | 61.59 | 86.6 |
|
||||||
|
| v4-p1-only | 16.28 | 9.1 | 25.44 | 45.63 | 84.0 |
|
||||||
|
| v4-vix-b | 15.24 | 8.55 | 22.62 | 44.33 | 83.6 |
|
||||||
|
| v4-p1-capped | 14.73 | 8.35 | 21.7 | 43.63 | 80.1 |
|
||||||
|
|
||||||
|
## Saturation census (sessions pegged at 100)
|
||||||
|
|
||||||
|
| variant | P1 | P2 | P3 | V1 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| v2_reconstruction | 46 | 0 | 39 | 14 |
|
||||||
|
| v2_reconstruction_oas400 | 46 | 0 | 39 | 14 |
|
||||||
|
| v3 | 46 | 0 | 0 | 14 |
|
||||||
|
| v4 | 0 | 0 | 0 | 0 |
|
||||||
|
| v4-vix-only | 46 | 0 | 0 | 0 |
|
||||||
|
| v4-p1-only | 0 | 0 | 0 | 14 |
|
||||||
|
| v4-vix-b | 0 | 0 | 0 | 0 |
|
||||||
|
| v4-p1-capped | 0 | 0 | 0 | 0 |
|
||||||
|
|
||||||
|
## Reproduction gates — v2_reconstruction
|
||||||
|
|
||||||
|
| figure | published | measured | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| v2_state_avg | 22.6 | 22.68 | ok |
|
||||||
|
| v2_state_p80 | 35.1 | 35.1 | ok |
|
||||||
|
| v2_state_max | 91.2 | 91.2 | ok |
|
||||||
|
| v2_p3_pegged | 39 | 39 | ok |
|
||||||
|
| w1_live_sessions | 108 | 108 | ok |
|
||||||
|
|
||||||
|
## Reproduction gates — v3
|
||||||
|
|
||||||
|
| figure | published | measured | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| v3_state_max | 87.4 | 87.4 | ok |
|
||||||
|
|
||||||
|
## v4 band-share grid (watch 20 / elevated 50)
|
||||||
|
|
||||||
|
| breaking | stable | watch | elevated | breaking |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 60 | 78.9 | 13.0 | 2.9 | 5.1 |
|
||||||
|
| 65 | 78.9 | 13.0 | 4.7 | 3.4 |
|
||||||
|
| 70 | 78.9 | 13.0 | 6.9 | 1.2 |
|
||||||
|
|
||||||
|
## Scenarios (pillar arithmetic, explicit sensor scores)
|
||||||
|
|
||||||
|
| scenario | price | breadth | C1 | V1 | State |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| S1 ordinary tape | 7.5 | 0.0 | 0.0 | 4.0 | **3.6** |
|
||||||
|
| S2 10% correction, calm credit | 31.25 | 62.5 | 0.0 | 34.4 | **33.28** |
|
||||||
|
| S3a 2022-style, calm credit, no death cross | 90.83 | 100.0 | 0.0 | 60.0 | **70.33** |
|
||||||
|
| S3b 2022-style, calm credit, death cross | 100.0 | 100.0 | 0.0 | 60.0 | **74.0** |
|
||||||
|
| S4 credit event on top | 100.0 | 100.0 | 75.0 | 86.67 | **93.0** |
|
||||||
|
| S5 March 2020, everything pegged | 100.0 | 100.0 | 100.0 | 100.0 | **100.0** |
|
||||||
|
|
||||||
|
Recommendation: `{'state_bands_candidate': [20.0, 50.0, 65.0], 'provisional': False, 'note': 'confirm against band_grid + scenarios before shipping'}`
|
||||||
@@ -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.
|
"""Return sorted unique symbols and source labels.
|
||||||
|
|
||||||
Offline-safe: does **not** use production Postgres or SystemSetting cache
|
Offline-safe: does **not** use production Postgres or SystemSetting cache
|
||||||
(those require a schema). Public sources first, then FMP, then seeds.
|
(those require a schema). Public sources first, then seeds.
|
||||||
"""
|
"""
|
||||||
from app.services.ticker_universe_service import (
|
from app.services.ticker_universe_service import (
|
||||||
_SEED_UNIVERSES,
|
_SEED_UNIVERSES,
|
||||||
_fetch_universe_symbols_from_fmp,
|
|
||||||
_fetch_universe_symbols_from_public,
|
_fetch_universe_symbols_from_public,
|
||||||
_normalise_symbols,
|
_normalise_symbols,
|
||||||
)
|
)
|
||||||
@@ -150,19 +149,11 @@ async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
|
|||||||
cleaned = _normalise_symbols(public_symbols)
|
cleaned = _normalise_symbols(public_symbols)
|
||||||
if cleaned:
|
if cleaned:
|
||||||
src = public_source or "public"
|
src = public_source or "public"
|
||||||
else:
|
elif public_failures:
|
||||||
if public_failures:
|
|
||||||
print(
|
print(
|
||||||
f" WARNING: public fetch {universe}: "
|
f" WARNING: public fetch {universe}: "
|
||||||
f"{'; '.join(public_failures[:3])}"
|
f"{'; '.join(public_failures[:3])}"
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
fmp_symbols = await _fetch_universe_symbols_from_fmp(universe)
|
|
||||||
cleaned = _normalise_symbols(fmp_symbols)
|
|
||||||
if cleaned:
|
|
||||||
src = "fmp"
|
|
||||||
except Exception as exc:
|
|
||||||
print(f" WARNING: FMP fetch {universe}: {exc}")
|
|
||||||
|
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, []))
|
cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, []))
|
||||||
|
|||||||
@@ -1,764 +0,0 @@
|
|||||||
'''Pure helpers for the focused daily portfolio-capacity research matrix.'''
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import statistics
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import Any, Iterable
|
|
||||||
|
|
||||||
|
|
||||||
ARMS: tuple[dict[str, Any], ...] = (
|
|
||||||
{
|
|
||||||
'id': 'cap10_incumbent',
|
|
||||||
'label': 'Cap 10, arrival-order incumbents',
|
|
||||||
'max_positions': 10,
|
|
||||||
'min_initial_risk_fraction': None,
|
|
||||||
'weekly_top_n_rebalance': False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'cash_unbounded',
|
|
||||||
'label': 'Cash-constrained, no count cap',
|
|
||||||
'max_positions': None,
|
|
||||||
'min_initial_risk_fraction': 0.005,
|
|
||||||
'weekly_top_n_rebalance': False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'cap10_weekly_top10',
|
|
||||||
'label': 'Cap 10, weekly current-rank top 10',
|
|
||||||
'max_positions': 10,
|
|
||||||
'min_initial_risk_fraction': None,
|
|
||||||
'weekly_top_n_rebalance': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'cap15_incumbent',
|
|
||||||
'label': 'Cap 15, arrival-order incumbents',
|
|
||||||
'max_positions': 15,
|
|
||||||
'min_initial_risk_fraction': None,
|
|
||||||
'weekly_top_n_rebalance': False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ARM_BY_ID = {arm['id']: arm for arm in ARMS}
|
|
||||||
RISK_FLOOR_ARMS: tuple[dict[str, Any], ...] = (
|
|
||||||
ARMS[0],
|
|
||||||
{
|
|
||||||
'id': 'cap10_min_risk_005',
|
|
||||||
'label': 'Cap 10, 0.5% minimum effective initial risk',
|
|
||||||
'max_positions': 10,
|
|
||||||
'min_initial_risk_fraction': 0.005,
|
|
||||||
'weekly_top_n_rebalance': False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
COSTS_PER_SIDE_PCT = (0.1, 0.2)
|
|
||||||
ANCHOR_YEARS = tuple(range(2019, 2026))
|
|
||||||
SCORING_SESSIONS = 504
|
|
||||||
MEASUREMENT_SESSIONS = 252
|
|
||||||
RESIDUAL_BENCHMARK_SESSIONS = 252
|
|
||||||
WARM_SEED_MIN_OFFSET = 63
|
|
||||||
WARM_SEED_MAX_OFFSET = 126
|
|
||||||
BOOTSTRAP_REPLICATES = 10_000
|
|
||||||
BOOTSTRAP_SEED = 20260805
|
|
||||||
PRIMARY_METRICS = (
|
|
||||||
'ev_net_r',
|
|
||||||
'calmar',
|
|
||||||
'profit_factor',
|
|
||||||
'gain_to_pain',
|
|
||||||
'sortino',
|
|
||||||
)
|
|
||||||
PAIRED_METRICS = (
|
|
||||||
*PRIMARY_METRICS,
|
|
||||||
'cagr_pct',
|
|
||||||
'max_drawdown_pct',
|
|
||||||
'total_return_pct',
|
|
||||||
'sharpe',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _end_exclusive(
|
|
||||||
sessions: list[date], start_index: int, count: int
|
|
||||||
) -> date:
|
|
||||||
end_index = start_index + count
|
|
||||||
if end_index < len(sessions):
|
|
||||||
return sessions[end_index]
|
|
||||||
return sessions[-1] + timedelta(days=1)
|
|
||||||
|
|
||||||
|
|
||||||
def build_cohort_manifest(session_dates: Iterable[date]) -> dict[str, Any]:
|
|
||||||
sessions = sorted(set(session_dates))
|
|
||||||
minimum = RESIDUAL_BENCHMARK_SESSIONS + SCORING_SESSIONS
|
|
||||||
if len(sessions) <= minimum + MEASUREMENT_SESSIONS:
|
|
||||||
raise ValueError('Snapshot is too short for the frozen cohort design')
|
|
||||||
|
|
||||||
index_of = {session: index for index, session in enumerate(sessions)}
|
|
||||||
first_eligible_index = RESIDUAL_BENCHMARK_SESSIONS - 1 + SCORING_SESSIONS
|
|
||||||
last_eligible_index = len(sessions) - MEASUREMENT_SESSIONS
|
|
||||||
|
|
||||||
first_by_month: dict[tuple[int, int], date] = {}
|
|
||||||
for session in sessions:
|
|
||||||
first_by_month.setdefault((session.year, session.month), session)
|
|
||||||
|
|
||||||
empty: list[dict[str, Any]] = []
|
|
||||||
for (year, month), session in sorted(first_by_month.items()):
|
|
||||||
index = index_of[session]
|
|
||||||
if year not in ANCHOR_YEARS:
|
|
||||||
continue
|
|
||||||
if index < first_eligible_index or index > last_eligible_index:
|
|
||||||
continue
|
|
||||||
empty.append({
|
|
||||||
'protocol': 'empty_book',
|
|
||||||
'path_id': f'empty-{year:04d}-{month:02d}',
|
|
||||||
'cluster': year,
|
|
||||||
'simulation_start': session.isoformat(),
|
|
||||||
'measurement_start': session.isoformat(),
|
|
||||||
'hard_end_exclusive': _end_exclusive(
|
|
||||||
sessions, index, MEASUREMENT_SESSIONS
|
|
||||||
).isoformat(),
|
|
||||||
})
|
|
||||||
|
|
||||||
first_by_year: dict[int, date] = {}
|
|
||||||
for session in sessions:
|
|
||||||
first_by_year.setdefault(session.year, session)
|
|
||||||
|
|
||||||
warm: list[dict[str, Any]] = []
|
|
||||||
warm_seed_counts: dict[str, int] = {}
|
|
||||||
for year in ANCHOR_YEARS:
|
|
||||||
anchor = first_by_year.get(year)
|
|
||||||
if anchor is None:
|
|
||||||
continue
|
|
||||||
anchor_index = index_of[anchor]
|
|
||||||
if (
|
|
||||||
anchor_index < WARM_SEED_MAX_OFFSET
|
|
||||||
or anchor_index > last_eligible_index
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
seed_window = sessions[
|
|
||||||
anchor_index - WARM_SEED_MAX_OFFSET:
|
|
||||||
anchor_index - WARM_SEED_MIN_OFFSET + 1
|
|
||||||
]
|
|
||||||
first_by_iso_week: dict[tuple[int, int], date] = {}
|
|
||||||
for session in seed_window:
|
|
||||||
iso = session.isocalendar()
|
|
||||||
first_by_iso_week.setdefault((iso.year, iso.week), session)
|
|
||||||
seeds = sorted(first_by_iso_week.values())
|
|
||||||
warm_seed_counts[str(year)] = len(seeds)
|
|
||||||
for seed_index, seed in enumerate(seeds, 1):
|
|
||||||
warm.append({
|
|
||||||
'protocol': 'warm_book',
|
|
||||||
'path_id': f'warm-{year}-seed-{seed_index:02d}',
|
|
||||||
'cluster': year,
|
|
||||||
'simulation_start': seed.isoformat(),
|
|
||||||
'measurement_start': anchor.isoformat(),
|
|
||||||
'hard_end_exclusive': _end_exclusive(
|
|
||||||
sessions, anchor_index, MEASUREMENT_SESSIONS
|
|
||||||
).isoformat(),
|
|
||||||
'seed_offset_sessions': anchor_index - index_of[seed],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'snapshot_first_session': sessions[0].isoformat(),
|
|
||||||
'snapshot_last_session': sessions[-1].isoformat(),
|
|
||||||
'session_count': len(sessions),
|
|
||||||
'expected_clusters': list(ANCHOR_YEARS),
|
|
||||||
'empty_book': empty,
|
|
||||||
'warm_book': warm,
|
|
||||||
'empty_cluster_counts': dict(
|
|
||||||
sorted(
|
|
||||||
(
|
|
||||||
str(year),
|
|
||||||
sum(1 for row in empty if row['cluster'] == year),
|
|
||||||
)
|
|
||||||
for year in {row['cluster'] for row in empty}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'warm_seed_counts': warm_seed_counts,
|
|
||||||
'empty_cluster_count': len({row['cluster'] for row in empty}),
|
|
||||||
'warm_cluster_count': len({row['cluster'] for row in warm}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_cohort_manifest(manifest: dict[str, Any]) -> list[str]:
|
|
||||||
errors: list[str] = []
|
|
||||||
expected = set(ANCHOR_YEARS)
|
|
||||||
empty_clusters = {row['cluster'] for row in manifest['empty_book']}
|
|
||||||
warm_clusters = {row['cluster'] for row in manifest['warm_book']}
|
|
||||||
if empty_clusters != expected:
|
|
||||||
errors.append(
|
|
||||||
f'empty-book clusters {sorted(empty_clusters)} != {sorted(expected)}'
|
|
||||||
)
|
|
||||||
if warm_clusters != expected:
|
|
||||||
errors.append(
|
|
||||||
f'warm-book clusters {sorted(warm_clusters)} != {sorted(expected)}'
|
|
||||||
)
|
|
||||||
for year in ANCHOR_YEARS:
|
|
||||||
seed_count = int(manifest['warm_seed_counts'].get(str(year), 0))
|
|
||||||
if seed_count < 12:
|
|
||||||
errors.append(f'warm anchor {year} has only {seed_count} seeds')
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def build_cells(
|
|
||||||
manifest: dict[str, Any],
|
|
||||||
*,
|
|
||||||
arms: tuple[dict[str, Any], ...] = ARMS,
|
|
||||||
protocols: tuple[str, ...] = ('empty_book', 'warm_book'),
|
|
||||||
costs: tuple[float, ...] = COSTS_PER_SIDE_PCT,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
paths = [
|
|
||||||
path
|
|
||||||
for protocol in protocols
|
|
||||||
for path in manifest[protocol]
|
|
||||||
]
|
|
||||||
cells: list[dict[str, Any]] = []
|
|
||||||
for cost in costs:
|
|
||||||
for path in paths:
|
|
||||||
for arm in arms:
|
|
||||||
cell_id = (
|
|
||||||
f'{arm["id"]}|{path["protocol"]}|{path["path_id"]}'
|
|
||||||
f'|cost={cost:.1f}'
|
|
||||||
)
|
|
||||||
cells.append({
|
|
||||||
**path,
|
|
||||||
'cell_id': cell_id,
|
|
||||||
'arm_id': arm['id'],
|
|
||||||
'cost_per_side_pct': cost,
|
|
||||||
})
|
|
||||||
return cells
|
|
||||||
|
|
||||||
|
|
||||||
def percentile(values: Iterable[float], probability: float) -> float | None:
|
|
||||||
ordered = sorted(float(value) for value in values if value is not None)
|
|
||||||
if not ordered:
|
|
||||||
return None
|
|
||||||
if len(ordered) == 1:
|
|
||||||
return ordered[0]
|
|
||||||
location = (len(ordered) - 1) * probability
|
|
||||||
lower = math.floor(location)
|
|
||||||
upper = math.ceil(location)
|
|
||||||
if lower == upper:
|
|
||||||
return ordered[lower]
|
|
||||||
weight = location - lower
|
|
||||||
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
|
||||||
|
|
||||||
|
|
||||||
def iqr(values: Iterable[float]) -> float | None:
|
|
||||||
clean: list[float] = []
|
|
||||||
for value in values:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
parsed = float(value)
|
|
||||||
if math.isfinite(parsed):
|
|
||||||
clean.append(parsed)
|
|
||||||
q25 = percentile(clean, 0.25)
|
|
||||||
q75 = percentile(clean, 0.75)
|
|
||||||
if q25 is None or q75 is None:
|
|
||||||
return None
|
|
||||||
return q75 - q25
|
|
||||||
|
|
||||||
|
|
||||||
def median(values: Iterable[float | None]) -> float | None:
|
|
||||||
clean = [float(value) for value in values if value is not None]
|
|
||||||
return statistics.median(clean) if clean else None
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_ratio(numerator: float | None, denominator: float | None) -> float | None:
|
|
||||||
if numerator is None or denominator is None:
|
|
||||||
return None
|
|
||||||
if abs(denominator) <= 1e-12:
|
|
||||||
return 1.0 if abs(numerator) <= 1e-12 else None
|
|
||||||
return numerator / denominator
|
|
||||||
|
|
||||||
|
|
||||||
def _stable_seed(*parts: object) -> int:
|
|
||||||
digest = hashlib.sha256('|'.join(map(str, parts)).encode('utf-8')).digest()
|
|
||||||
return BOOTSTRAP_SEED + int.from_bytes(digest[:4], 'big')
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_median_interval(
|
|
||||||
values: Iterable[float | None],
|
|
||||||
*,
|
|
||||||
seed_parts: tuple[object, ...],
|
|
||||||
replicates: int = BOOTSTRAP_REPLICATES,
|
|
||||||
) -> dict[str, float | int | None]:
|
|
||||||
clean = [float(value) for value in values if value is not None]
|
|
||||||
if not clean:
|
|
||||||
return {'n': 0, 'point': None, 'p05': None, 'p95': None}
|
|
||||||
rng = random.Random(_stable_seed(*seed_parts))
|
|
||||||
draws = [
|
|
||||||
statistics.median(rng.choices(clean, k=len(clean)))
|
|
||||||
for _ in range(replicates)
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
'n': len(clean),
|
|
||||||
'replicates': replicates,
|
|
||||||
'point': statistics.median(clean),
|
|
||||||
'p05': percentile(draws, 0.05),
|
|
||||||
'p95': percentile(draws, 0.95),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _monthly_returns(
|
|
||||||
equity_curve: list[dict[str, Any]], base_equity: float
|
|
||||||
) -> list[float]:
|
|
||||||
month_ends: dict[tuple[int, int], float] = {}
|
|
||||||
for point in equity_curve:
|
|
||||||
point_date = date.fromisoformat(str(point['date']))
|
|
||||||
month_ends[(point_date.year, point_date.month)] = float(point['equity'])
|
|
||||||
previous = float(base_equity)
|
|
||||||
returns: list[float] = []
|
|
||||||
for month in sorted(month_ends):
|
|
||||||
equity = month_ends[month]
|
|
||||||
if previous > 0:
|
|
||||||
returns.append(equity / previous - 1.0)
|
|
||||||
previous = equity
|
|
||||||
return returns
|
|
||||||
|
|
||||||
|
|
||||||
def _time_underwater(equities: list[float]) -> tuple[int, float]:
|
|
||||||
peak = float('-inf')
|
|
||||||
current = 0
|
|
||||||
longest = 0
|
|
||||||
underwater = 0
|
|
||||||
for equity in equities:
|
|
||||||
peak = max(peak, equity)
|
|
||||||
if peak > 0 and equity < peak - 1e-9:
|
|
||||||
current += 1
|
|
||||||
underwater += 1
|
|
||||||
longest = max(longest, current)
|
|
||||||
else:
|
|
||||||
current = 0
|
|
||||||
percentage = underwater / len(equities) * 100.0 if equities else 0.0
|
|
||||||
return longest, percentage
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_simulation(sim: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
trades = list(sim.get('trade_details') or [])
|
|
||||||
equity_curve = list(sim.get('equity_curve') or [])
|
|
||||||
net_rs = [float(trade['net_r']) for trade in trades]
|
|
||||||
positive_rs = [value for value in net_rs if value > 0]
|
|
||||||
negative_rs = [value for value in net_rs if value < 0]
|
|
||||||
ev_net_r = statistics.fmean(net_rs) if net_rs else None
|
|
||||||
profit_factor = (
|
|
||||||
sum(positive_rs) / abs(sum(negative_rs))
|
|
||||||
if negative_rs
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
base_equity = float(
|
|
||||||
sim.get('measurement_start_equity') or sim.get('starting_capital') or 0.0
|
|
||||||
)
|
|
||||||
curve_equities = [float(point['equity']) for point in equity_curve]
|
|
||||||
daily_equities = [base_equity, *curve_equities]
|
|
||||||
daily_returns = [
|
|
||||||
current / previous - 1.0
|
|
||||||
for previous, current in zip(daily_equities, daily_equities[1:])
|
|
||||||
if previous > 0
|
|
||||||
]
|
|
||||||
downside_deviation = (
|
|
||||||
math.sqrt(
|
|
||||||
statistics.fmean(min(value, 0.0) ** 2 for value in daily_returns)
|
|
||||||
)
|
|
||||||
if daily_returns
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
sortino = (
|
|
||||||
statistics.fmean(daily_returns) / downside_deviation * math.sqrt(252.0)
|
|
||||||
if downside_deviation is not None and downside_deviation > 0
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
monthly_returns = _monthly_returns(equity_curve, base_equity)
|
|
||||||
negative_monthly = sum(value for value in monthly_returns if value < 0)
|
|
||||||
gain_to_pain = (
|
|
||||||
sum(monthly_returns) / abs(negative_monthly)
|
|
||||||
if negative_monthly < 0
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
longest_underwater, underwater_pct = _time_underwater(daily_equities)
|
|
||||||
|
|
||||||
transaction_cost = sum(
|
|
||||||
float(trade.get('transaction_cost') or 0.0) for trade in trades
|
|
||||||
)
|
|
||||||
traded_notional = sum(
|
|
||||||
float(trade.get('shares') or 0.0)
|
|
||||||
* (float(trade.get('entry') or 0.0) + float(trade.get('fill') or 0.0))
|
|
||||||
for trade in trades
|
|
||||||
)
|
|
||||||
turnover_multiple = (
|
|
||||||
traded_notional / base_equity if base_equity > 0 else None
|
|
||||||
)
|
|
||||||
|
|
||||||
ordered_rs = sorted(net_rs, reverse=True)
|
|
||||||
ev_without_best: dict[str, float | None] = {}
|
|
||||||
for count in (1, 5, 10):
|
|
||||||
remaining = ordered_rs[count:]
|
|
||||||
ev_without_best[str(count)] = (
|
|
||||||
statistics.fmean(remaining) if remaining else None
|
|
||||||
)
|
|
||||||
|
|
||||||
events = list(sim.get('weekly_rebalance_events') or [])
|
|
||||||
entrant_sizes = [int(event['fresh_entrant_pool']) for event in events]
|
|
||||||
eligible_sizes = [
|
|
||||||
int(event['rank_eligible_entrant_pool']) for event in events
|
|
||||||
]
|
|
||||||
replacements = [int(event['replacements']) for event in events]
|
|
||||||
|
|
||||||
capacity_skips = int(
|
|
||||||
sim.get('measurement_skipped_book_full', sim.get('skipped_book_full', 0))
|
|
||||||
)
|
|
||||||
opened = int(sim.get('opened_positions', sim.get('trades', 0)))
|
|
||||||
capacity_opportunities = opened + capacity_skips
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'start_date': sim.get('start_date'),
|
|
||||||
'end_date': sim.get('end_date'),
|
|
||||||
'simulation_start_date': sim.get('simulation_start_date'),
|
|
||||||
'measurement_start_equity': base_equity,
|
|
||||||
'measurement_start_positions': sim.get('measurement_start_positions', 0),
|
|
||||||
'trades': len(trades),
|
|
||||||
'ev_net_r': ev_net_r,
|
|
||||||
'profit_factor': profit_factor,
|
|
||||||
'gain_to_pain': gain_to_pain,
|
|
||||||
'sortino': sortino,
|
|
||||||
'ev_without_best': ev_without_best,
|
|
||||||
'total_return_pct': sim.get('total_return_pct'),
|
|
||||||
'cagr_pct': sim.get('cagr_pct'),
|
|
||||||
'max_drawdown_pct': sim.get('max_drawdown_pct'),
|
|
||||||
'calmar': sim.get('calmar'),
|
|
||||||
'sharpe': sim.get('sharpe'),
|
|
||||||
'win_rate': sim.get('win_rate'),
|
|
||||||
'avg_hold_days': sim.get('avg_hold_days'),
|
|
||||||
'longest_underwater_sessions': longest_underwater,
|
|
||||||
'underwater_pct': underwater_pct,
|
|
||||||
'transaction_cost': transaction_cost,
|
|
||||||
'turnover_multiple': turnover_multiple,
|
|
||||||
'skipped_book_full': capacity_skips,
|
|
||||||
'opened_positions': opened,
|
|
||||||
'capacity_opportunities': capacity_opportunities,
|
|
||||||
'blocked_fraction': (
|
|
||||||
capacity_skips / capacity_opportunities
|
|
||||||
if capacity_opportunities
|
|
||||||
else 0.0
|
|
||||||
),
|
|
||||||
'skipped_min_initial_risk': int(
|
|
||||||
sim.get('measurement_skipped_min_initial_risk', 0)
|
|
||||||
),
|
|
||||||
'avg_positions': sim.get('avg_positions'),
|
|
||||||
'peak_positions': sim.get('peak_positions'),
|
|
||||||
'sessions_at_capacity': sim.get('sessions_at_capacity'),
|
|
||||||
'sessions_measured': sim.get('sessions_measured'),
|
|
||||||
'avg_cash_pct': sim.get('avg_cash_pct'),
|
|
||||||
'avg_gross_exposure_pct': sim.get('avg_gross_exposure_pct'),
|
|
||||||
'exit_reasons': sim.get('exit_reasons'),
|
|
||||||
}
|
|
||||||
if events:
|
|
||||||
result['weekly_rebalance'] = {
|
|
||||||
'events': len(events),
|
|
||||||
'zero_entrant_fraction': (
|
|
||||||
sum(1 for value in entrant_sizes if value == 0) / len(events)
|
|
||||||
),
|
|
||||||
'entrant_pool_mean': statistics.fmean(entrant_sizes),
|
|
||||||
'entrant_pool_median': statistics.median(entrant_sizes),
|
|
||||||
'entrant_pool_p90': percentile(entrant_sizes, 0.9),
|
|
||||||
'eligible_pool_mean': statistics.fmean(eligible_sizes),
|
|
||||||
'replacements': sum(replacements),
|
|
||||||
'weekly_rank_rejected_entries': int(
|
|
||||||
sim.get('weekly_rank_rejected_entries', 0)
|
|
||||||
),
|
|
||||||
'reentries_within_5_sessions': int(
|
|
||||||
sim.get('rebalance_reentries_within_5_sessions', 0)
|
|
||||||
),
|
|
||||||
'reentries_within_10_sessions': int(
|
|
||||||
sim.get('rebalance_reentries_within_10_sessions', 0)
|
|
||||||
),
|
|
||||||
'reentries_within_20_sessions': int(
|
|
||||||
sim.get('rebalance_reentries_within_20_sessions', 0)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _cluster_rows(
|
|
||||||
cells: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
arm_id: str,
|
|
||||||
protocol: str,
|
|
||||||
cost: float,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
treatment = {
|
|
||||||
row['path_id']: row
|
|
||||||
for row in cells
|
|
||||||
if row['arm_id'] == arm_id
|
|
||||||
and row['protocol'] == protocol
|
|
||||||
and float(row['cost_per_side_pct']) == cost
|
|
||||||
}
|
|
||||||
control = {
|
|
||||||
row['path_id']: row
|
|
||||||
for row in cells
|
|
||||||
if row['arm_id'] == 'cap10_incumbent'
|
|
||||||
and row['protocol'] == protocol
|
|
||||||
and float(row['cost_per_side_pct']) == cost
|
|
||||||
}
|
|
||||||
shared_paths = sorted(set(treatment) & set(control))
|
|
||||||
by_cluster: dict[int, list[tuple[dict, dict]]] = defaultdict(list)
|
|
||||||
for path_id in shared_paths:
|
|
||||||
row = treatment[path_id]
|
|
||||||
by_cluster[int(row['cluster'])].append((row, control[path_id]))
|
|
||||||
|
|
||||||
summaries: list[dict[str, Any]] = []
|
|
||||||
for cluster, pairs in sorted(by_cluster.items()):
|
|
||||||
metrics: dict[str, Any] = {}
|
|
||||||
for metric in PAIRED_METRICS:
|
|
||||||
arm_values = [
|
|
||||||
pair[0]['metrics'].get(metric)
|
|
||||||
for pair in pairs
|
|
||||||
if pair[0]['metrics'].get(metric) is not None
|
|
||||||
and math.isfinite(float(pair[0]['metrics'][metric]))
|
|
||||||
]
|
|
||||||
control_values = [
|
|
||||||
pair[1]['metrics'].get(metric)
|
|
||||||
for pair in pairs
|
|
||||||
if pair[1]['metrics'].get(metric) is not None
|
|
||||||
and math.isfinite(float(pair[1]['metrics'][metric]))
|
|
||||||
]
|
|
||||||
deltas = [
|
|
||||||
float(arm['metrics'][metric])
|
|
||||||
- float(base['metrics'][metric])
|
|
||||||
for arm, base in pairs
|
|
||||||
if arm['metrics'].get(metric) is not None
|
|
||||||
and base['metrics'].get(metric) is not None
|
|
||||||
and math.isfinite(float(arm['metrics'][metric]))
|
|
||||||
and math.isfinite(float(base['metrics'][metric]))
|
|
||||||
]
|
|
||||||
arm_median = median(arm_values)
|
|
||||||
control_median = median(control_values)
|
|
||||||
metrics[metric] = {
|
|
||||||
'arm_median': arm_median,
|
|
||||||
'control_median': control_median,
|
|
||||||
'paired_delta_median': median(deltas),
|
|
||||||
'arm_control_ratio': _safe_ratio(
|
|
||||||
arm_median, control_median
|
|
||||||
),
|
|
||||||
'paired_paths': len(deltas),
|
|
||||||
}
|
|
||||||
summaries.append({
|
|
||||||
'cluster': cluster,
|
|
||||||
'paths': len(pairs),
|
|
||||||
'metrics': metrics,
|
|
||||||
})
|
|
||||||
return summaries
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_results(
|
|
||||||
cells: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
arms: tuple[dict[str, Any], ...] = ARMS,
|
|
||||||
protocols: tuple[str, ...] = ('empty_book', 'warm_book'),
|
|
||||||
costs: tuple[float, ...] = COSTS_PER_SIDE_PCT,
|
|
||||||
include_warm_dispersion: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
paired: list[dict[str, Any]] = []
|
|
||||||
path_distributions: list[dict[str, Any]] = []
|
|
||||||
for cost in costs:
|
|
||||||
for protocol in protocols:
|
|
||||||
control_by_path = {
|
|
||||||
row['path_id']: row
|
|
||||||
for row in cells
|
|
||||||
if row['arm_id'] == 'cap10_incumbent'
|
|
||||||
and row['protocol'] == protocol
|
|
||||||
and float(row['cost_per_side_pct']) == float(cost)
|
|
||||||
}
|
|
||||||
for arm in arms:
|
|
||||||
arm_id = str(arm['id'])
|
|
||||||
clusters = _cluster_rows(
|
|
||||||
cells,
|
|
||||||
arm_id=arm_id,
|
|
||||||
protocol=protocol,
|
|
||||||
cost=float(cost),
|
|
||||||
)
|
|
||||||
headline: dict[str, Any] = {}
|
|
||||||
for metric in PAIRED_METRICS:
|
|
||||||
deltas = [
|
|
||||||
cluster['metrics'][metric]['paired_delta_median']
|
|
||||||
for cluster in clusters
|
|
||||||
]
|
|
||||||
arm_levels = [
|
|
||||||
cluster['metrics'][metric]['arm_median']
|
|
||||||
for cluster in clusters
|
|
||||||
]
|
|
||||||
control_levels = [
|
|
||||||
cluster['metrics'][metric]['control_median']
|
|
||||||
for cluster in clusters
|
|
||||||
]
|
|
||||||
arm_level = median(arm_levels)
|
|
||||||
control_level = median(control_levels)
|
|
||||||
metric_summary: dict[str, Any] = {
|
|
||||||
'paired_delta_median': median(deltas),
|
|
||||||
'arm_median': arm_level,
|
|
||||||
'control_median': control_level,
|
|
||||||
'arm_control_ratio': _safe_ratio(
|
|
||||||
arm_level, control_level
|
|
||||||
),
|
|
||||||
}
|
|
||||||
if metric in ('ev_net_r', 'calmar'):
|
|
||||||
metric_summary['bootstrap_90'] = (
|
|
||||||
bootstrap_median_interval(
|
|
||||||
deltas,
|
|
||||||
seed_parts=(
|
|
||||||
arm_id,
|
|
||||||
protocol,
|
|
||||||
cost,
|
|
||||||
metric,
|
|
||||||
'paired-delta',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
headline[metric] = metric_summary
|
|
||||||
paired.append({
|
|
||||||
'arm_id': arm_id,
|
|
||||||
'protocol': protocol,
|
|
||||||
'cost_per_side_pct': cost,
|
|
||||||
'clusters': clusters,
|
|
||||||
'headline': headline,
|
|
||||||
})
|
|
||||||
treatment_by_path = {
|
|
||||||
row['path_id']: row
|
|
||||||
for row in cells
|
|
||||||
if row['arm_id'] == arm_id
|
|
||||||
and row['protocol'] == protocol
|
|
||||||
and float(row['cost_per_side_pct']) == float(cost)
|
|
||||||
}
|
|
||||||
shared_paths = sorted(
|
|
||||||
set(treatment_by_path) & set(control_by_path)
|
|
||||||
)
|
|
||||||
path_metrics: dict[str, Any] = {}
|
|
||||||
for metric in PAIRED_METRICS:
|
|
||||||
deltas = [
|
|
||||||
float(treatment_by_path[path_id]['metrics'][metric])
|
|
||||||
- float(control_by_path[path_id]['metrics'][metric])
|
|
||||||
for path_id in shared_paths
|
|
||||||
if treatment_by_path[path_id]['metrics'].get(metric)
|
|
||||||
is not None
|
|
||||||
and control_by_path[path_id]['metrics'].get(metric)
|
|
||||||
is not None
|
|
||||||
and math.isfinite(
|
|
||||||
float(treatment_by_path[path_id]['metrics'][metric])
|
|
||||||
)
|
|
||||||
and math.isfinite(
|
|
||||||
float(control_by_path[path_id]['metrics'][metric])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
path_metrics[metric] = {
|
|
||||||
'paired_paths': len(deltas),
|
|
||||||
'paired_delta_mean': (
|
|
||||||
statistics.fmean(deltas) if deltas else None
|
|
||||||
),
|
|
||||||
'paired_delta_median': median(deltas),
|
|
||||||
'paired_delta_p25': percentile(deltas, 0.25),
|
|
||||||
'paired_delta_p75': percentile(deltas, 0.75),
|
|
||||||
'positive_fraction': (
|
|
||||||
sum(delta > 0.0 for delta in deltas) / len(deltas)
|
|
||||||
if deltas
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
'identical_fraction': (
|
|
||||||
sum(abs(delta) <= 1e-12 for delta in deltas)
|
|
||||||
/ len(deltas)
|
|
||||||
if deltas
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
path_distributions.append({
|
|
||||||
'arm_id': arm_id,
|
|
||||||
'protocol': protocol,
|
|
||||||
'cost_per_side_pct': cost,
|
|
||||||
'metrics': path_metrics,
|
|
||||||
})
|
|
||||||
|
|
||||||
warm_rows = [
|
|
||||||
row for row in cells if row['protocol'] == 'warm_book'
|
|
||||||
]
|
|
||||||
warm_dispersion: list[dict[str, Any]] = []
|
|
||||||
for cost in costs:
|
|
||||||
for arm in arms:
|
|
||||||
arm_id = str(arm['id'])
|
|
||||||
anchor_rows: list[dict[str, Any]] = []
|
|
||||||
for cluster in ANCHOR_YEARS:
|
|
||||||
arm_paths = [
|
|
||||||
row
|
|
||||||
for row in warm_rows
|
|
||||||
if row['arm_id'] == arm_id
|
|
||||||
and int(row['cluster']) == cluster
|
|
||||||
and float(row['cost_per_side_pct']) == float(cost)
|
|
||||||
]
|
|
||||||
control_by_path = {
|
|
||||||
row['path_id']: row
|
|
||||||
for row in warm_rows
|
|
||||||
if row['arm_id'] == 'cap10_incumbent'
|
|
||||||
and int(row['cluster']) == cluster
|
|
||||||
and float(row['cost_per_side_pct']) == float(cost)
|
|
||||||
}
|
|
||||||
metric_rows: dict[str, Any] = {}
|
|
||||||
for metric in ('ev_net_r', 'calmar'):
|
|
||||||
arm_spread = iqr(
|
|
||||||
row['metrics'].get(metric) for row in arm_paths
|
|
||||||
)
|
|
||||||
control_spread = iqr(
|
|
||||||
control_by_path[row['path_id']]['metrics'].get(metric)
|
|
||||||
for row in arm_paths
|
|
||||||
if row['path_id'] in control_by_path
|
|
||||||
)
|
|
||||||
metric_rows[metric] = {
|
|
||||||
'arm_iqr': arm_spread,
|
|
||||||
'control_iqr': control_spread,
|
|
||||||
'iqr_ratio': _safe_ratio(
|
|
||||||
arm_spread, control_spread
|
|
||||||
),
|
|
||||||
}
|
|
||||||
anchor_rows.append({
|
|
||||||
'cluster': cluster,
|
|
||||||
'seeds': len(arm_paths),
|
|
||||||
'metrics': metric_rows,
|
|
||||||
})
|
|
||||||
|
|
||||||
headline: dict[str, Any] = {}
|
|
||||||
for metric in ('ev_net_r', 'calmar'):
|
|
||||||
ratios = [
|
|
||||||
row['metrics'][metric]['iqr_ratio']
|
|
||||||
for row in anchor_rows
|
|
||||||
]
|
|
||||||
headline[metric] = {
|
|
||||||
'median_iqr_ratio': median(ratios),
|
|
||||||
'bootstrap_90': bootstrap_median_interval(
|
|
||||||
ratios,
|
|
||||||
seed_parts=(
|
|
||||||
arm_id,
|
|
||||||
cost,
|
|
||||||
metric,
|
|
||||||
'warm-iqr-ratio',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
warm_dispersion.append({
|
|
||||||
'arm_id': arm_id,
|
|
||||||
'cost_per_side_pct': cost,
|
|
||||||
'anchors': anchor_rows,
|
|
||||||
'headline': headline,
|
|
||||||
})
|
|
||||||
|
|
||||||
if not include_warm_dispersion:
|
|
||||||
warm_dispersion = []
|
|
||||||
|
|
||||||
return {
|
|
||||||
'paired_per_year': paired,
|
|
||||||
'paired_path_distributions': path_distributions,
|
|
||||||
'warm_seed_dispersion': warm_dispersion,
|
|
||||||
'bootstrap': {
|
|
||||||
'replicates': BOOTSTRAP_REPLICATES,
|
|
||||||
'seed': BOOTSTRAP_SEED,
|
|
||||||
'interval': 'central 90% percentile, context only',
|
|
||||||
'resampling_unit': 'seven annual paired summaries',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -15,10 +15,10 @@ Cost: a reparse cannot be served from the database -- the facts a fixed parser n
|
|||||||
accepts were never stored -- so it refetches Company Facts for every tracked issuer
|
accepts were never stored -- so it refetches Company Facts for every tracked issuer
|
||||||
under the SEC fair-access throttle. Expect a long run and a lot of network.
|
under the SEC fair-access throttle. Expect a long run and a lot of network.
|
||||||
|
|
||||||
Scope note: this rewrites ``fundamental_snapshots`` only. As of the A5 gate those
|
Scope note: this rewrites ``fundamental_snapshots`` only. Those rows now feed both
|
||||||
rows feed the fundamentals API/UI and the parity report; scoring still reads the
|
the fundamentals API/UI *and* — through the nightly ``fundamental_data`` refresh —
|
||||||
legacy ``fundamental_data`` table, so a reparse does not move composite scores or
|
the fundamental dimension of the composite score, so a reparse does move scores
|
||||||
backtests until the cutover happens.
|
and backtests. Run it deliberately.
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ if str(ROOT) not in sys.path:
|
|||||||
|
|
||||||
from scripts.research_rankings import ( # noqa: E402
|
from scripts.research_rankings import ( # noqa: E402
|
||||||
_live_universe_rank_map,
|
_live_universe_rank_map,
|
||||||
_period_percentiles,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
POLICY_NAMES = (
|
POLICY_NAMES = (
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ if str(ROOT) not in sys.path:
|
|||||||
|
|
||||||
from scripts.research_rankings import ( # noqa: E402
|
from scripts.research_rankings import ( # noqa: E402
|
||||||
_live_universe_rank_map,
|
_live_universe_rank_map,
|
||||||
_period_percentiles,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Must match Phase A cache when reusing research-cands.pkl
|
# Must match Phase A cache when reusing research-cands.pkl
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user