Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b41629e7 | ||
|
|
1c6ccceb12 | ||
|
|
3483797e75 | ||
|
|
7dc804be2b | ||
|
|
46ace501a2 |
@@ -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
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ METHODOLOGY = "v3"
|
|||||||
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
||||||
# collected by hand/LLM and carried across it when the format is compatible.
|
# collected by hand/LLM and carried across it when the format is compatible.
|
||||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
||||||
REBUILD_SESSIONS = 400
|
|
||||||
|
# Bumped when a fix changes what historical rows *should* contain without
|
||||||
|
# changing the live formula, so stored history needs one reseed. Deliberately
|
||||||
|
# not METHODOLOGY: that partitions the history API and discards the cached event
|
||||||
|
# study, neither of which is warranted here -- the study recomputes its Warning
|
||||||
|
# series from source rather than reading snapshots, so a reseed cannot stale it.
|
||||||
|
# Snapshots written before this marker existed carry no key and read as 1.
|
||||||
|
SENSOR_REVISION = 2
|
||||||
MIN_COVERAGE = 75.0
|
MIN_COVERAGE = 75.0
|
||||||
SOURCE_MAX_LAG_DAYS = 7
|
SOURCE_MAX_LAG_DAYS = 7
|
||||||
|
|
||||||
@@ -81,7 +88,24 @@ HY_OAS_STRESSED = 7.0
|
|||||||
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
|
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
|
||||||
# encode the long-run distribution, so the credit *level* is now purely anchored
|
# encode the long-run distribution, so the credit *level* is now purely anchored
|
||||||
# and credit *dynamics* live in W3 on the Warning axis where they belong.
|
# and credit *dynamics* live in W3 on the Warning axis where they belong.
|
||||||
HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now
|
# Calendar days, and it must cover the oldest date a rebuild replays -- not just
|
||||||
|
# W3's lookback. REBUILD_SESSIONS is 400 *trading* sessions (~579 calendar
|
||||||
|
# days), so a 400-calendar-day fetch left the oldest ~180 days of a rebuild with
|
||||||
|
# no OAS at all: C1 and W3 both returned None, State landed at 80% coverage and
|
||||||
|
# Warning at exactly MIN_COVERAGE, and *both still published bands* -- a series
|
||||||
|
# that looks homogeneous while its oldest rows were scored without credit.
|
||||||
|
# Widening only prepends older observations; C1 reads [-1] and W3 reads [-21], so
|
||||||
|
# live scores are unchanged and this needs no methodology bump. Stays under
|
||||||
|
# ICE's ~3-year cap so FRED still honours the request.
|
||||||
|
HY_OAS_WINDOW_DAYS = 700
|
||||||
|
|
||||||
|
# A rebuild replays every session inside this window. Bounded by calendar days
|
||||||
|
# rather than a session count because the binding constraint is the OAS fetch:
|
||||||
|
# each replayed row needs W3's 20-business-day lookback (~28 calendar days)
|
||||||
|
# inside HY_OAS_WINDOW_DAYS, so replaying further back would recreate the exact
|
||||||
|
# credit gap a reseed exists to close. 672 days is ~464 trading sessions, which
|
||||||
|
# comfortably covers the 400-session series the v3 cutover wrote.
|
||||||
|
REBUILD_LOOKBACK_DAYS = HY_OAS_WINDOW_DAYS - 28
|
||||||
W3_OAS_LOOKBACK = 20
|
W3_OAS_LOOKBACK = 20
|
||||||
W3_OAS_FULL_SCALE_PCT = 35.0
|
W3_OAS_FULL_SCALE_PCT = 35.0
|
||||||
|
|
||||||
@@ -477,17 +501,29 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
|
|||||||
return _next_weekday(fetched) if fetched else None
|
return _next_weekday(fetched) if fetched else None
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_timing(
|
||||||
|
overrides: dict, config: dict, as_of: date
|
||||||
|
) -> tuple[date | None, bool, int | None, bool]:
|
||||||
|
"""Shared effective-date arithmetic: (effective, pending, age_days, stale)."""
|
||||||
|
effective = _fundamental_effective_date(overrides)
|
||||||
|
pending = effective is None or as_of < effective
|
||||||
|
age = None if pending else (as_of - effective).days
|
||||||
|
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
|
||||||
|
return effective, pending, age, stale
|
||||||
|
|
||||||
|
|
||||||
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
|
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
|
||||||
|
|
||||||
The effective-date gate stays even though nothing is scored from this: the
|
The effective-date gate stays even though nothing is scored from this: the
|
||||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
400-session rebuild replays historical dates, and stamping today's LLM read
|
||||||
onto 2024 snapshots would be plain lookahead in the stored record.
|
onto 2024 snapshots would be plain lookahead in the stored record.
|
||||||
|
|
||||||
|
This is the *record*. For "what do we know right now", use
|
||||||
|
``current_observation`` -- do not add a bypass flag here, because this runs
|
||||||
|
for every replayed date during a rebuild.
|
||||||
"""
|
"""
|
||||||
effective = _fundamental_effective_date(overrides)
|
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||||
pending = effective is None or as_of < effective
|
|
||||||
age = None if pending else (as_of - effective).days
|
|
||||||
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
|
|
||||||
return {
|
return {
|
||||||
"available": not pending and not stale,
|
"available": not pending and not stale,
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
@@ -504,6 +540,43 @@ def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
|
"""The observation as it stands now, for the live reading only.
|
||||||
|
|
||||||
|
Same shape as ``fundamental_overlay``, but the effective date is *reported*
|
||||||
|
rather than used to blank the content. A refresh stamps
|
||||||
|
``_next_weekday(today)``, so gating the live card hid a just-collected read
|
||||||
|
for one day -- three over a weekend -- and refreshing appeared to do
|
||||||
|
nothing. Nothing here is scored, so showing it early cannot leak into a
|
||||||
|
published number; the stored snapshot keeps the gate.
|
||||||
|
"""
|
||||||
|
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||||
|
# The default override carries "unknown"/"mixed" placeholders for every
|
||||||
|
# hyperscaler. Those are the absence of an observation, not an observation
|
||||||
|
# of absence, and must never be presented as collected. ``fetched_at`` is
|
||||||
|
# the collection timestamp and is the only field written on every path that
|
||||||
|
# produces real content (LLM refresh and manual save both stamp it).
|
||||||
|
observed = bool(overrides.get("fetched_at"))
|
||||||
|
return {
|
||||||
|
"observed": observed,
|
||||||
|
# Live availability is about usefulness, not effectiveness: a pending
|
||||||
|
# observation is the freshest thing we have -- but nothing collected is
|
||||||
|
# never available.
|
||||||
|
"available": observed and not stale,
|
||||||
|
"pending": pending,
|
||||||
|
"stale": stale,
|
||||||
|
"effective_date": effective.isoformat() if effective else None,
|
||||||
|
"age_days": age,
|
||||||
|
"capex": overrides.get("capex") if observed else None,
|
||||||
|
"good_news_stock_down": overrides.get("good_news_stock_down") if observed else None,
|
||||||
|
"capex_stress": overrides.get("f1_score") if observed else None,
|
||||||
|
"earnings_stress": overrides.get("f3_score") if observed else None,
|
||||||
|
"reasoning": overrides.get("reasoning") if observed else None,
|
||||||
|
"source": overrides.get("source"),
|
||||||
|
"fetched_at": overrides.get("fetched_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _basket_hash(symbols: list[str]) -> str:
|
def _basket_hash(symbols: list[str]) -> str:
|
||||||
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
|
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
|
||||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||||
@@ -630,6 +703,8 @@ def _compute_index(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"methodology": METHODOLOGY,
|
"methodology": METHODOLOGY,
|
||||||
|
# Not part of the history filter -- only the reseed trigger.
|
||||||
|
"sensor_revision": SENSOR_REVISION,
|
||||||
"date": as_of.isoformat(),
|
"date": as_of.isoformat(),
|
||||||
"state": state,
|
"state": state,
|
||||||
"warning": warning,
|
"warning": warning,
|
||||||
@@ -889,7 +964,7 @@ async def _upsert_snapshot(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
result: dict,
|
result: dict,
|
||||||
*,
|
*,
|
||||||
rewrite_existing_v2: bool,
|
rewrite_existing: bool,
|
||||||
) -> tuple[bool, dict]:
|
) -> tuple[bool, dict]:
|
||||||
snapshot_date = date.fromisoformat(result["date"])
|
snapshot_date = date.fromisoformat(result["date"])
|
||||||
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date))
|
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date))
|
||||||
@@ -906,15 +981,23 @@ async def _upsert_snapshot(
|
|||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
existing_v2 = _parse_snapshot(row.breakdown_json)
|
existing_parsed = _parse_snapshot(row.breakdown_json)
|
||||||
if existing_v2 is not None and not rewrite_existing_v2:
|
if existing_parsed is not None and not rewrite_existing:
|
||||||
return False, existing_v2
|
return False, existing_parsed
|
||||||
row.total_score = float(state_score or 0.0)
|
row.total_score = float(state_score or 0.0)
|
||||||
row.band = state_band or "unavailable"
|
row.band = state_band or "unavailable"
|
||||||
row.breakdown_json = payload
|
row.breakdown_json = payload
|
||||||
return True, result
|
return True, result
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_revision(snapshot: dict) -> int:
|
||||||
|
"""Sensor revision of a stored snapshot; pre-marker rows read as 1."""
|
||||||
|
try:
|
||||||
|
return int(snapshot.get("sensor_revision") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _parse_snapshot(raw: str) -> dict | None:
|
def _parse_snapshot(raw: str) -> dict | None:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
@@ -934,7 +1017,9 @@ 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"):
|
||||||
@@ -968,10 +1053,18 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
|||||||
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc)
|
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc)
|
||||||
breadth, breadth_counts, divergence = {}, {}, {}
|
breadth, breadth_counts, divergence = {}, {}, {}
|
||||||
|
|
||||||
latest_v2 = await _latest_snapshot_row(db)
|
latest_snapshot = await _latest_snapshot_row(db)
|
||||||
rebuilding = latest_v2 is None and bool(leader_series)
|
# A stored series written under an older sensor revision is reseeded once.
|
||||||
|
# Without this, raising HY_OAS_WINDOW_DAYS would only ever reach newly
|
||||||
|
# computed rows: routine runs touch the latest date alone, so every older row
|
||||||
|
# would keep the credit gap indefinitely.
|
||||||
|
rebuilding = bool(leader_series) and (
|
||||||
|
latest_snapshot is None
|
||||||
|
or _snapshot_revision(latest_snapshot[1]) < SENSOR_REVISION
|
||||||
|
)
|
||||||
if rebuilding:
|
if rebuilding:
|
||||||
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]]
|
floor = end - timedelta(days=rebuild_lookback_days)
|
||||||
|
dates = [d for d, _ in leader_series if d >= floor] or [latest_date]
|
||||||
else:
|
else:
|
||||||
# Routine PIT rule: only the latest trading date may be inserted/updated.
|
# Routine PIT rule: only the latest trading date may be inserted/updated.
|
||||||
dates = [latest_date]
|
dates = [latest_date]
|
||||||
@@ -995,7 +1088,9 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
|||||||
written, latest_result = await _upsert_snapshot(
|
written, latest_result = await _upsert_snapshot(
|
||||||
db,
|
db,
|
||||||
computed,
|
computed,
|
||||||
rewrite_existing_v2=rebuilding or snapshot_date == latest_date,
|
# True for *every* replayed date on a reseed, or it would write one
|
||||||
|
# row and leave the rest at the old revision.
|
||||||
|
rewrite_existing=rebuilding or snapshot_date == latest_date,
|
||||||
)
|
)
|
||||||
snapshots_written += int(written)
|
snapshots_written += int(written)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -1042,7 +1137,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
|
|||||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||||
latest = await _latest_snapshot_row(db)
|
latest = await _latest_snapshot_row(db)
|
||||||
if latest is None:
|
if latest is None:
|
||||||
return {"available": False, "reason": "v2 not computed yet"}
|
return {"available": False, "reason": "not computed yet"}
|
||||||
row, result = latest
|
row, result = latest
|
||||||
basket_hash = (result.get("basket") or {}).get("hash")
|
basket_hash = (result.get("basket") or {}).get("hash")
|
||||||
previous_7 = await _result_at_or_before(
|
previous_7 = await _result_at_or_before(
|
||||||
@@ -1071,7 +1166,9 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
|||||||
# session, because otherwise refreshing it looks like it did nothing.
|
# session, because otherwise refreshing it looks like it did nothing.
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(db)
|
overrides = await get_fundamental_overrides(db)
|
||||||
live = fundamental_overlay(overrides, config, date.today())
|
live = current_observation(overrides, config, date.today())
|
||||||
|
# Deliberately reads the *snapshot's* overlay, not the live one: this is how
|
||||||
|
# the reader tells "shown here" from "in the stored record".
|
||||||
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
|
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
|
||||||
result["fundamental_context"] = live
|
result["fundamental_context"] = live
|
||||||
result["available"] = True
|
result["available"] = True
|
||||||
|
|||||||
@@ -140,13 +140,42 @@ The fundamental overlay keeps its effective date (normally the next session afte
|
|||||||
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
||||||
observation onto historical snapshots. Because the observation is stored in a
|
observation onto historical snapshots. Because the observation is stored in a
|
||||||
single slot, a refresh replaces the previously effective record: the snapshot
|
single slot, a refresh replaces the previously effective record: the snapshot
|
||||||
therefore reports the overlay as `pending` until the new effective date, and the
|
therefore reports the overlay as `pending` until the new effective date.
|
||||||
live reading additionally carries `fundamental_context` so a just-collected
|
|
||||||
observation is visible immediately rather than appearing to have done nothing.
|
Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
|
||||||
|
the gate — it runs for every replayed date during a rebuild, so it must never
|
||||||
|
grow a bypass flag. `current_observation` is the **live reading** behind
|
||||||
|
`fundamental_context`, and *reports* the effective date instead of blanking the
|
||||||
|
content.
|
||||||
|
|
||||||
|
Until 2026-08-07 the live reading called the gated function, so a just-collected
|
||||||
|
observation stayed hidden until the next weekday — three days over a weekend —
|
||||||
|
and refreshing appeared to do nothing. That was the opposite of what this section
|
||||||
|
already claimed. Showing it early cannot leak into a published number, because
|
||||||
|
nothing in the overlay is scored (see "Fundamentals left the score").
|
||||||
|
|
||||||
|
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
|
||||||
|
every path writing real content stamps). Without it, the default override —
|
||||||
|
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
|
||||||
|
live observation with `available: true`, so the card presented placeholders as a
|
||||||
|
collected reading. Those are the absence of an observation, not an observation of
|
||||||
|
absence. `fundamental_overlay` never had this problem: no observation means no
|
||||||
|
effective date, which means `pending`, which already blanks the content.
|
||||||
|
|
||||||
Each snapshot stores the fixed basket symbols, hash, and freeze date.
|
Each snapshot stores the fixed basket symbols, hash, and freeze date.
|
||||||
Reconstructed history before that freeze date is retrospective/exploratory.
|
Reconstructed history before that freeze date is retrospective/exploratory.
|
||||||
|
|
||||||
|
## Presentation
|
||||||
|
|
||||||
|
The page is deliberately thin: two gauges, one chart card, one pillar table, the
|
||||||
|
overlay, and a provenance strip. Time and Path are two projections of the same
|
||||||
|
snapshot series and share one card and one query key — they were previously two
|
||||||
|
panels, which read as two datasets. Methodology rationale lives in this document,
|
||||||
|
not on the page; page text is limited to what changes how the reader interprets
|
||||||
|
today's number. The quadrant dividers rendered in Path view come from
|
||||||
|
`quadrant_config` and are the same constants the alert path consumes
|
||||||
|
(`alert_service`), so the chart cannot drift from what actually fires.
|
||||||
|
|
||||||
## Warning study
|
## Warning study
|
||||||
|
|
||||||
The study calls the outcome a **10% correction**, not a regime break. The first
|
The study calls the outcome a **10% correction**, not a regime break. The first
|
||||||
@@ -194,6 +223,93 @@ coverage bias for a regime-selection bias. The honest position is that the
|
|||||||
threshold is hypersensitive to window choice at this sample size; the report
|
threshold is hypersensitive to window choice at this sample size; the report
|
||||||
states its limits rather than pretending to a precision it does not have.
|
states its limits rather than pretending to a precision it does not have.
|
||||||
|
|
||||||
|
## Open calibration questions
|
||||||
|
|
||||||
|
Raised 2026-08-07 during the page refactor. **None are implemented.** Each one
|
||||||
|
changes a published score, so acting on any of them means cutting `METHODOLOGY`
|
||||||
|
to v4 — which reseeds 400 sessions and discards the cached event study. They are
|
||||||
|
recorded here rather than hand-patched into v3.
|
||||||
|
|
||||||
|
**1. State's top band is a credit-event band.** `f2_credit_spreads` returns
|
||||||
|
`0.0` — not `None` — for any OAS below the 3.5 mild anchor, so credit stays
|
||||||
|
*available* at weight 20 and is not renormalized out. It is simply pinned at
|
||||||
|
zero. Verified: with price, breadth and volatility all pegged at 100 and OAS at
|
||||||
|
the cutover's 2.77, State computes to exactly **80.0** at 100% coverage — the
|
||||||
|
"breaking" threshold to the decimal. So the top State band requires either a
|
||||||
|
credit event or all three remaining pillars simultaneously at maximum. A pure
|
||||||
|
AI/Tech drawdown with calm credit — the scenario this monitor exists to
|
||||||
|
measure — cannot print it with anything to spare. Anchors-only credit was
|
||||||
|
nonzero on 27 of 408 calibration sessions, so that 20-point weight sits at zero
|
||||||
|
roughly 93% of the time. This is structurally the same defect v3 corrected on
|
||||||
|
the Warning axis ("the upper half of the Warning axis was unreachable"), and it
|
||||||
|
means the State bands were fit against a v2 credit distribution that v3 no
|
||||||
|
longer produces.
|
||||||
|
|
||||||
|
**2. V1 saturates at VIX 30.** `(vix - 15) / 15 * 100` reaches 100 at VIX 30 and
|
||||||
|
has no resolution above it: VIX 30, 50 and 82 all score identically. That is the
|
||||||
|
same failure mode, at a similar percentile, as the `dd_pct * 5` formula this
|
||||||
|
version replaced for pegging at a 20% drawdown. If addressed, it should get an
|
||||||
|
anchor table in the P3 style rather than a rescaled slope.
|
||||||
|
|
||||||
|
**3. `max(P1, P2, P3)` defeats P3's anchoring.** The `max` is deliberate ("one
|
||||||
|
capped vote for correlated reads"), but `_under_200` is binary, so P1 prints 100
|
||||||
|
whenever SMH and QQQ are both below their 200-DMA. P3's anchor ladder therefore
|
||||||
|
only resolves anything while price is *above* the 200-DMA — that is, before the
|
||||||
|
drawdown it measures is underway. Note also that "P3's realized share of State
|
||||||
|
falls from 65% to 40%" is argmax-share accounting, which is a slippery statistic
|
||||||
|
under `max()`.
|
||||||
|
|
||||||
|
## Fixed 2026-08-07: the OAS fetch window did not cover a rebuild
|
||||||
|
|
||||||
|
`HY_OAS_WINDOW_DAYS` was 400 **calendar** days, but a rebuild replays
|
||||||
|
`leader_series[-REBUILD_SESSIONS:]` — 400 **trading** sessions, about 579
|
||||||
|
calendar days. The oldest ~180 calendar days of any rebuild therefore got no OAS
|
||||||
|
data at all, so `f2_credit_spreads` and `w3_credit_impulse` both returned `None`.
|
||||||
|
Verified: State then lands at 80% coverage and Warning at exactly 75.0% —
|
||||||
|
`MIN_COVERAGE` — so **both still publish bands**. The rebuilt series would look
|
||||||
|
homogeneous while its oldest rows had been scored without credit, the tell being
|
||||||
|
a null `data_quality.credit_history_days` on exactly those rows.
|
||||||
|
|
||||||
|
The window is now 700 days: it must cover the oldest replayed date (~579) plus
|
||||||
|
W3's lookback and slack, while staying under ICE's ~3-year cap so FRED still
|
||||||
|
honours the request. This required **no methodology bump** — C1 reads
|
||||||
|
`oas_values[-1]` and W3 reads `oas_values[-21]`, both indexed from the end, so
|
||||||
|
widening only prepends older observations and every live score is bit-identical.
|
||||||
|
Confirmed by evaluating both windows against a varying synthetic series: today's
|
||||||
|
C1/W3 match exactly, while the oldest rebuild row goes from `None`/`None` to real
|
||||||
|
values.
|
||||||
|
|
||||||
|
Expect `credit_history_days` on new snapshots to rise from ~400 to ~700. That is
|
||||||
|
the widened request, not new upstream history — and it makes the chip a better
|
||||||
|
truncation canary, since a 700-day request returning ~1095 days' worth is now
|
||||||
|
the visible ceiling.
|
||||||
|
|
||||||
|
**Widening the window alone does not repair stored history.** Routine runs
|
||||||
|
recompute only the latest trading date, and `rebuilding` was keyed on "no v3
|
||||||
|
snapshot exists at all" — which is false once the cutover has run — so every row
|
||||||
|
already written would have kept its credit gap indefinitely. `SENSOR_REVISION`
|
||||||
|
fixes that: it is stamped into each snapshot, snapshots predating it read as 1,
|
||||||
|
and a stored revision below the current one triggers exactly one reseed.
|
||||||
|
|
||||||
|
It is deliberately not `METHODOLOGY`. That constant partitions the history API
|
||||||
|
and discards the cached event study; neither is warranted here, because the study
|
||||||
|
recomputes its Warning series from source (`_warning_series` calls
|
||||||
|
`warning_sensor_scores` against freshly fetched prices and OAS) rather than
|
||||||
|
reading snapshots, so a reseed cannot stale it.
|
||||||
|
|
||||||
|
The reseed is bounded by `REBUILD_LOOKBACK_DAYS` in calendar days rather than a
|
||||||
|
session count, because the binding constraint is the OAS fetch: each replayed row
|
||||||
|
needs W3's 20-business-day lookback inside `HY_OAS_WINDOW_DAYS`. At 672 days the
|
||||||
|
replay reaches ~464 sessions, W3's oldest requirement lands exactly on the first
|
||||||
|
fetched OAS day, and the ~400-session series the v3 cutover wrote is fully
|
||||||
|
covered. A test asserts that relationship so the two constants cannot drift into
|
||||||
|
recreating the gap.
|
||||||
|
|
||||||
|
The fix was sequenced deliberately: acting on items 1–3 above bumps
|
||||||
|
`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
|
||||||
|
rows into the fresh series. Fixing the window afterwards would mean reseeding
|
||||||
|
twice.
|
||||||
|
|
||||||
## Operator rule
|
## Operator rule
|
||||||
|
|
||||||
Quadrant alerts default off for new/reset configurations. When enabled they
|
Quadrant alerts default off for new/reset configurations. When enabled they
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceArea,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Scatter,
|
||||||
|
ScatterChart,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
ZAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
|
||||||
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { SkeletonCard } from '../ui/Skeleton';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
|
||||||
|
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
||||||
|
// Time and Path are two projections of one series, so they share a card and a
|
||||||
|
// query rather than sitting in two panels that look like different data.
|
||||||
|
|
||||||
|
const VIEWS = ['Time', 'Path'] as const;
|
||||||
|
type View = (typeof VIEWS)[number];
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ key: '1M', days: 30 },
|
||||||
|
{ key: '3M', days: 90 },
|
||||||
|
{ key: '6M', days: 182 },
|
||||||
|
{ key: 'All', days: Number.POSITIVE_INFINITY },
|
||||||
|
] as const;
|
||||||
|
type RangeKey = (typeof RANGES)[number]['key'];
|
||||||
|
|
||||||
|
/** Sessions drawn in Path view. The full series is unreadable as a path. */
|
||||||
|
const PATH_TRAIL = 60;
|
||||||
|
|
||||||
|
const STATE_COLOR = '#60a5fa';
|
||||||
|
const WARNING_COLOR = '#fb923c';
|
||||||
|
|
||||||
|
// Fall back to the v3 constants, not v2's shared 60/60, so a missing
|
||||||
|
// quadrant_config cannot draw dividers that disagree with the alert path.
|
||||||
|
const DEFAULT_STATE_DIVIDER = 50;
|
||||||
|
const DEFAULT_WARNING_DIVIDER = 40;
|
||||||
|
|
||||||
|
interface PathPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
||||||
|
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
||||||
|
const n = points.length;
|
||||||
|
return points.map((p, i) => {
|
||||||
|
if (i === n - 1) return { ...p };
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let c = 0;
|
||||||
|
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
|
||||||
|
sx += points[j].x;
|
||||||
|
sy += points[j].y;
|
||||||
|
c += 1;
|
||||||
|
}
|
||||||
|
return { x: sx / c, y: sy / c, date: p.date };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
|
||||||
|
function recencyColor(t: number): string {
|
||||||
|
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
|
||||||
|
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentedControl<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
options: readonly T[];
|
||||||
|
value: T;
|
||||||
|
onChange: (next: T) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1" role="group" aria-label={label}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === option}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
||||||
|
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const p = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="glass px-2.5 py-1.5 text-[11px]">
|
||||||
|
<div className="text-gray-300">{formatDate(p.date)}</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
|
||||||
|
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegimeChart() {
|
||||||
|
const [view, setView] = useState<View>('Time');
|
||||||
|
const [range, setRange] = useState<RangeKey>('3M');
|
||||||
|
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
|
||||||
|
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
||||||
|
|
||||||
|
const xDiv = monitor.data?.quadrant_config?.state_divider ?? DEFAULT_STATE_DIVIDER;
|
||||||
|
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? DEFAULT_WARNING_DIVIDER;
|
||||||
|
const basketAsOf = monitor.data?.basket?.basket_asof;
|
||||||
|
|
||||||
|
const series = useMemo(() => {
|
||||||
|
const data = history.data ?? [];
|
||||||
|
if (view === 'Path') {
|
||||||
|
return data
|
||||||
|
.filter((p) => p.state != null && p.warning != null)
|
||||||
|
.slice(-PATH_TRAIL);
|
||||||
|
}
|
||||||
|
const days = RANGES.find((r) => r.key === range)!.days;
|
||||||
|
if (!Number.isFinite(days)) return data;
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
return data.filter((p) => new Date(p.date) >= cutoff);
|
||||||
|
}, [history.data, view, range]);
|
||||||
|
|
||||||
|
const pathPoints = useMemo<PathPoint[]>(
|
||||||
|
() => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })),
|
||||||
|
[series],
|
||||||
|
);
|
||||||
|
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
|
||||||
|
const latest = view === 'Path' && pathPoints.length ? pathPoints[pathPoints.length - 1] : null;
|
||||||
|
|
||||||
|
// Only warn about pre-freeze history when the drawn window actually reaches
|
||||||
|
// back past the freeze date.
|
||||||
|
const crossesFreeze = Boolean(basketAsOf && series.length && series[0].date < basketAsOf);
|
||||||
|
const enoughData = view === 'Path' ? pathPoints.length > 0 : series.length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
|
||||||
|
</span>
|
||||||
|
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
|
||||||
|
</div>
|
||||||
|
{view === 'Time' ? (
|
||||||
|
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
|
||||||
|
) : (
|
||||||
|
latest && (
|
||||||
|
<span className="text-[11px] text-gray-500">
|
||||||
|
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
|
||||||
|
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{history.isLoading ? (
|
||||||
|
<SkeletonCard className="mt-3 h-72" />
|
||||||
|
) : !enoughData ? (
|
||||||
|
<Callout variant="empty">Not enough coverage-qualified history yet — it accumulates as the daily job runs.</Callout>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{view === 'Time' ? (
|
||||||
|
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||||
|
tickFormatter={(d) => formatDate(String(d))}
|
||||||
|
minTickGap={28}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||||
|
/>
|
||||||
|
{/* width must clear a 3-digit label: the old chart paired
|
||||||
|
width 28 with margin.left -18 and clipped every tick. */}
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
ticks={[0, 25, 50, 75, 100]}
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||||
|
width={34}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{/* The two axes have different thresholds, so each divider is
|
||||||
|
drawn in its series' colour rather than as shared gridlines. */}
|
||||||
|
<ReferenceLine y={xDiv} stroke={STATE_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
|
||||||
|
<ReferenceLine y={yDiv} stroke={WARNING_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'rgba(17,24,39,0.95)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#9ca3af' }}
|
||||||
|
labelFormatter={(l) => formatDate(String(l))}
|
||||||
|
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="state" name="State" stroke={STATE_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
|
||||||
|
<Line type="monotone" dataKey="warning" name="Warning" stroke={WARNING_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
) : (
|
||||||
|
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
||||||
|
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
|
||||||
|
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
|
||||||
|
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
||||||
|
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
|
||||||
|
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||||||
|
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
||||||
|
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="x"
|
||||||
|
domain={[0, 100]}
|
||||||
|
ticks={[0, 20, 40, 60, 80, 100]}
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||||
|
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="y"
|
||||||
|
domain={[0, 100]}
|
||||||
|
ticks={[0, 20, 40, 60, 80, 100]}
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||||
|
width={30}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
<ZAxis range={[13, 13]} />
|
||||||
|
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
|
||||||
|
<Scatter data={trail} line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }} isAnimationActive={false}>
|
||||||
|
{trail.map((_, i) => (
|
||||||
|
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
||||||
|
))}
|
||||||
|
</Scatter>
|
||||||
|
{latest && (
|
||||||
|
<Scatter
|
||||||
|
data={[latest]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
shape={(props: { cx?: number; cy?: number }) => (
|
||||||
|
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScatterChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'Time' ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
|
||||||
|
State
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
|
||||||
|
Warning
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
||||||
|
<span><span className="text-amber-400">Early warning</span> — calm, fragility rising</span>
|
||||||
|
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
|
||||||
|
<span><span className="text-emerald-400">Healthy</span> — calm, broadly supported</span>
|
||||||
|
<span><span className="text-red-400">Stabilizing</span> — damage remains, warning lower</span>
|
||||||
|
<span className="text-gray-600 sm:col-span-2">White dot = today; trail brightens toward the present, smoothed.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crossesFreeze && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-600">
|
||||||
|
History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
ScatterChart,
|
|
||||||
Scatter,
|
|
||||||
Cell,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
ZAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
ReferenceLine,
|
|
||||||
ReferenceArea,
|
|
||||||
} from 'recharts';
|
|
||||||
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
|
|
||||||
import { Callout } from '../ui/Callout';
|
|
||||||
import { SkeletonCard } from '../ui/Skeleton';
|
|
||||||
|
|
||||||
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
|
||||||
|
|
||||||
// Quadrant boundaries come from the backend v2 methodology response.
|
|
||||||
const TRAIL = 60; // sessions shown
|
|
||||||
|
|
||||||
interface QPoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
|
||||||
function smoothTrail(points: QPoint[], half = 2): QPoint[] {
|
|
||||||
const n = points.length;
|
|
||||||
return points.map((p, i) => {
|
|
||||||
if (i === n - 1) return { ...p };
|
|
||||||
let sx = 0;
|
|
||||||
let sy = 0;
|
|
||||||
let c = 0;
|
|
||||||
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
|
|
||||||
sx += points[j].x;
|
|
||||||
sy += points[j].y;
|
|
||||||
c += 1;
|
|
||||||
}
|
|
||||||
return { x: sx / c, y: sy / c, date: p.date };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
|
|
||||||
function recencyColor(t: number): string {
|
|
||||||
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
|
|
||||||
const r = lerp(71, 96);
|
|
||||||
const g = lerp(85, 165);
|
|
||||||
const b = lerp(105, 250);
|
|
||||||
const alpha = (0.3 + 0.7 * t).toFixed(2);
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) {
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
const p = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="glass px-2.5 py-1.5 text-[11px]">
|
|
||||||
<div className="text-gray-300">{p.date}</div>
|
|
||||||
<div className="text-gray-400">
|
|
||||||
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
|
|
||||||
<span className="text-orange-300">{Math.round(p.y)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RegimeQuadrant() {
|
|
||||||
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
|
|
||||||
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
|
||||||
const xDiv = monitor.data?.quadrant_config?.state_divider ?? 60;
|
|
||||||
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? 60;
|
|
||||||
|
|
||||||
const points = useMemo<QPoint[]>(() => {
|
|
||||||
const data = history.data ?? [];
|
|
||||||
return data
|
|
||||||
.filter((p) => p.state != null && p.warning != null)
|
|
||||||
.slice(-TRAIL)
|
|
||||||
.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date }));
|
|
||||||
}, [history.data]);
|
|
||||||
|
|
||||||
const trail = useMemo(() => smoothTrail(points), [points]);
|
|
||||||
const latest = points.length ? points[points.length - 1] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
|
||||||
State × Warning quadrant — last {TRAIL} sessions
|
|
||||||
</div>
|
|
||||||
{latest && (
|
|
||||||
<div className="text-[11px] text-gray-500">
|
|
||||||
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
|
|
||||||
<span className="text-orange-300">{Math.round(latest.y)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.isLoading ? (
|
|
||||||
<SkeletonCard className="mt-3 h-72" />
|
|
||||||
) : !points.length ? (
|
|
||||||
<Callout variant="empty">
|
|
||||||
Not enough coverage-qualified v2 history yet.
|
|
||||||
</Callout>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
|
||||||
{/* Quadrant shading (drawn first, behind everything) */}
|
|
||||||
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
|
|
||||||
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
|
|
||||||
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
|
||||||
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
|
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
|
||||||
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
|
||||||
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
dataKey="x"
|
|
||||||
domain={[0, 100]}
|
|
||||||
ticks={[0, 20, 40, 60, 80, 100]}
|
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
|
||||||
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="number"
|
|
||||||
dataKey="y"
|
|
||||||
domain={[0, 100]}
|
|
||||||
ticks={[0, 20, 40, 60, 80, 100]}
|
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
|
||||||
width={30}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
<ZAxis range={[13, 13]} />
|
|
||||||
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
|
|
||||||
{/* Smoothed trail with a recency gradient (old → new) */}
|
|
||||||
<Scatter
|
|
||||||
data={trail}
|
|
||||||
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
>
|
|
||||||
{trail.map((_, i) => (
|
|
||||||
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
|
||||||
))}
|
|
||||||
</Scatter>
|
|
||||||
{/* Today */}
|
|
||||||
{latest && (
|
|
||||||
<Scatter
|
|
||||||
data={[latest]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
shape={(props: { cx?: number; cy?: number }) => (
|
|
||||||
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ScatterChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
|
||||||
<span><span className="text-amber-400">Early warning</span> — state calm, fragility rising</span>
|
|
||||||
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
|
|
||||||
<span><span className="text-emerald-400">Healthy</span> — calm and broadly supported</span>
|
|
||||||
<span><span className="text-red-400">Stressed / stabilizing</span> — damage remains, warning lower</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
|
|
||||||
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
|
|
||||||
sessions, smoothed. The path matters more than a single point. Risk thermometer — not an entry, exit,
|
|
||||||
or sizing signal.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
ReferenceLine,
|
|
||||||
} from 'recharts';
|
|
||||||
import { getRegimeHistory } from '../../api/regime';
|
|
||||||
import { Callout } from '../ui/Callout';
|
|
||||||
import { SkeletonCard } from '../ui/Skeleton';
|
|
||||||
import { formatDate } from '../../lib/format';
|
|
||||||
|
|
||||||
// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk.
|
|
||||||
|
|
||||||
const HISTORY_RANGES = [
|
|
||||||
{ key: '1M', days: 30 },
|
|
||||||
{ key: '3M', days: 90 },
|
|
||||||
{ key: '6M', days: 182 },
|
|
||||||
{ key: 'All', days: 100000 },
|
|
||||||
] as const;
|
|
||||||
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
|
|
||||||
|
|
||||||
const HISTORY_SERIES = [
|
|
||||||
{ key: 'state', label: 'State', color: '#60a5fa' },
|
|
||||||
{ key: 'warning', label: 'Warning', color: '#fb923c' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function ScoreHistoryChart() {
|
|
||||||
const [range, setRange] = useState<HistoryRange>('3M');
|
|
||||||
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const data = history.data ?? [];
|
|
||||||
const days = HISTORY_RANGES.find((r) => r.key === range)!.days;
|
|
||||||
if (range === 'All') return data;
|
|
||||||
const cutoff = new Date();
|
|
||||||
cutoff.setDate(cutoff.getDate() - days);
|
|
||||||
return data.filter((p) => new Date(p.date) >= cutoff);
|
|
||||||
}, [history.data, range]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">Score history</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{HISTORY_RANGES.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRange(r.key)}
|
|
||||||
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
|
||||||
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{r.key}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.isLoading ? (
|
|
||||||
<SkeletonCard className="mt-3 h-56" />
|
|
||||||
) : filtered.length < 2 ? (
|
|
||||||
<Callout variant="empty">Not enough history yet — it accumulates as the daily job runs.</Callout>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 h-60">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
|
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
|
||||||
tickFormatter={(d) => formatDate(String(d))}
|
|
||||||
minTickGap={28}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
domain={[0, 100]}
|
|
||||||
ticks={[0, 30, 60, 80, 100]}
|
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
|
||||||
width={28}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
|
|
||||||
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
|
|
||||||
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: 'rgba(17,24,39,0.95)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#9ca3af' }}
|
|
||||||
labelFormatter={(l) => formatDate(String(l))}
|
|
||||||
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
|
|
||||||
/>
|
|
||||||
{HISTORY_SERIES.map((s) => (
|
|
||||||
<Line
|
|
||||||
key={s.key}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={s.key}
|
|
||||||
name={s.label}
|
|
||||||
stroke={s.color}
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-4">
|
|
||||||
{HISTORY_SERIES.map((s) => (
|
|
||||||
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
|
|
||||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -500,6 +500,9 @@ export interface RegimeFundamentalOverlay {
|
|||||||
reasoning: string | null;
|
reasoning: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
fetched_at: string | null;
|
fetched_at: string | null;
|
||||||
|
/** Whether anything was actually collected. Live reading only; the snapshot's
|
||||||
|
* point-in-time overlay omits it. */
|
||||||
|
observed?: boolean;
|
||||||
observed_in_snapshot?: boolean;
|
observed_in_snapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +552,9 @@ export interface RegimeMonitor {
|
|||||||
inputs_fresh: boolean;
|
inputs_fresh: boolean;
|
||||||
snapshot_age_days?: number;
|
snapshot_age_days?: number;
|
||||||
is_fresh?: boolean;
|
is_fresh?: boolean;
|
||||||
|
/** Upstream history spans, so a silently truncated series is visible. */
|
||||||
|
credit_history_days?: number | null;
|
||||||
|
vix_history_days?: number | null;
|
||||||
};
|
};
|
||||||
quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
|
quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-110
@@ -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,73 +125,87 @@ 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>
|
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
|
||||||
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
|
|
||||||
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{Object.entries(capex).map(([symbol, state]) => (
|
|
||||||
<div key={symbol} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="font-mono text-gray-400">{symbol}</span>
|
|
||||||
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
|
||||||
<span className="font-medium text-gray-300">Good news, stock down</span>
|
|
||||||
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
|
|
||||||
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{overlay.reasoning && (
|
|
||||||
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
|
<div>
|
||||||
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
|
<div className="mb-2 flex items-baseline justify-between text-xs">
|
||||||
Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even
|
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
|
||||||
when both were pegged — so they are reported here rather than diluted into a daily score.
|
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
|
||||||
</p>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(capex).map(([symbol, state]) => (
|
||||||
|
<div key={symbol} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-gray-400">{symbol}</span>
|
||||||
|
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-baseline justify-between text-xs">
|
||||||
|
<span className="font-medium text-gray-300">Good news, stock down</span>
|
||||||
|
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
|
||||||
|
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
||||||
</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,32 +216,78 @@ 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 }) => (
|
||||||
{reading.pillars.map((pillar) => (
|
<tbody key={title}>
|
||||||
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
|
||||||
<td className="px-4 py-3">
|
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
|
||||||
<div className="font-medium text-gray-200">{pillar.label}</div>
|
{title}
|
||||||
<div className="mt-1 space-y-0.5">
|
<span className="ml-2 normal-case tracking-normal text-gray-600">
|
||||||
{pillar.sensors.map((sensor) => (
|
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
||||||
<div key={sensor.id} className="text-xs text-gray-500">
|
</span>
|
||||||
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
|
|
||||||
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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-400">{pillar.weight}</td>
|
|
||||||
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : '—'}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{reading.pillars.map((pillar) => (
|
||||||
</tbody>
|
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-gray-200">{pillar.label}</div>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{pillar.sensors.map((sensor) => (
|
||||||
|
<div key={sensor.id} className="text-xs text-gray-500">
|
||||||
|
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
|
||||||
|
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
|
||||||
|
<td className="px-4 py-3 text-right num text-gray-300">
|
||||||
|
{pillar.available ? pillar.contribution.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</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="Regime 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 “Regime 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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -162,13 +162,13 @@ def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
|
|||||||
if len(rows) < 90:
|
if len(rows) < 90:
|
||||||
return None
|
return None
|
||||||
ords, opens, highs, lows, closes, vols = [], [], [], [], [], []
|
ords, opens, highs, lows, closes, vols = [], [], [], [], [], []
|
||||||
for d, o, h, l, c, v in rows:
|
for d, o, h, lo, c, v in rows:
|
||||||
if isinstance(d, str):
|
if isinstance(d, str):
|
||||||
d = date.fromisoformat(d[:10])
|
d = date.fromisoformat(d[:10])
|
||||||
ords.append(d.toordinal())
|
ords.append(d.toordinal())
|
||||||
opens.append(float(o))
|
opens.append(float(o))
|
||||||
highs.append(float(h))
|
highs.append(float(h))
|
||||||
lows.append(float(l))
|
lows.append(float(lo))
|
||||||
closes.append(float(c))
|
closes.append(float(c))
|
||||||
vols.append(float(v or 0))
|
vols.append(float(v or 0))
|
||||||
return (symbol, ords, opens, highs, lows, closes, vols, spy)
|
return (symbol, ords, opens, highs, lows, closes, vols, spy)
|
||||||
@@ -275,7 +275,8 @@ def main() -> None:
|
|||||||
vol_weeks = collected.get("vol_6m") or {}
|
vol_weeks = collected.get("vol_6m") or {}
|
||||||
momr_weeks = collected.get("mom_12_1_resid") or {}
|
momr_weeks = collected.get("mom_12_1_resid") or {}
|
||||||
|
|
||||||
# Index mom/vol by (week, symbol) for joins
|
# Index mom by (week, symbol) for joins. vol/momr are consumed as week maps
|
||||||
|
# directly further down, so they need no index.
|
||||||
def _index(weeks_map: dict) -> dict[tuple, dict]:
|
def _index(weeks_map: dict) -> dict[tuple, dict]:
|
||||||
out: dict[tuple, dict] = {}
|
out: dict[tuple, dict] = {}
|
||||||
for wk, recs in weeks_map.items():
|
for wk, recs in weeks_map.items():
|
||||||
@@ -290,8 +291,6 @@ def main() -> None:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
mom_ix = _index(mom_weeks)
|
mom_ix = _index(mom_weeks)
|
||||||
vol_ix = _index(vol_weeks)
|
|
||||||
momr_ix = _index(momr_weeks)
|
|
||||||
|
|
||||||
# Per-week membership + extended checks via shared rich filter
|
# Per-week membership + extended checks via shared rich filter
|
||||||
same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
|
same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
|
||||||
@@ -606,8 +605,8 @@ def _update_md(path: Path, results: dict, artifact: Path) -> None:
|
|||||||
"",
|
"",
|
||||||
"### Authoritative unconditional fip (liquid top-N, post-mask)",
|
"### Authoritative unconditional fip (liquid top-N, post-mask)",
|
||||||
"",
|
"",
|
||||||
f"| metric | value |",
|
"| metric | value |",
|
||||||
f"|---|---|",
|
"|---|---|",
|
||||||
f"| mean_ic | {h.get('mean_ic')} |",
|
f"| mean_ic | {h.get('mean_ic')} |",
|
||||||
f"| ic_t_stat | {h.get('ic_t_stat')} |",
|
f"| ic_t_stat | {h.get('ic_t_stat')} |",
|
||||||
f"| weeks | {h.get('weeks')} |",
|
f"| weeks | {h.get('weeks')} |",
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ def _write_md(path: Path, payload: dict) -> None:
|
|||||||
row = br.get("row") or br
|
row = br.get("row") or br
|
||||||
if row:
|
if row:
|
||||||
lines.extend([
|
lines.extend([
|
||||||
f"| metric | value |",
|
"| metric | value |",
|
||||||
f"|---|---|",
|
"|---|---|",
|
||||||
f"| mean_ic | {row.get('mean_ic')} |",
|
f"| mean_ic | {row.get('mean_ic')} |",
|
||||||
f"| ic_t_stat | {row.get('ic_t_stat')} |",
|
f"| ic_t_stat | {row.get('ic_t_stat')} |",
|
||||||
f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
|
f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import os
|
|||||||
import pickle
|
import pickle
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -70,7 +70,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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -252,13 +252,29 @@ async def test_real_clone_smoke(engine):
|
|||||||
# A few tickers spanning near + further-out reporters so the initial-load
|
# A few tickers spanning near + further-out reporters so the initial-load
|
||||||
# forward-horizon gate (>= 21d) is satisfied on the fixed clone.
|
# forward-horizon gate (>= 21d) is satisfied on the fixed clone.
|
||||||
await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"])
|
await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"])
|
||||||
|
|
||||||
|
# "today" is anchored to the clone, NOT the wall clock. The clone is fixed
|
||||||
|
# and do_pull=False, so a wall-clock today makes this test decay: the
|
||||||
|
# forward horizon shrinks a day per real day and eventually trips the
|
||||||
|
# >= 21d gate (it did, at 19d). Anchoring keeps it time-stable. Production
|
||||||
|
# pulls fresh data and is unaffected. Dot-free symbols only, so the query
|
||||||
|
# needs no symbol normalisation.
|
||||||
|
rows = await dolt_client.query_csv(
|
||||||
|
_CLONE_DIR,
|
||||||
|
"SELECT MAX(`date`) AS max_date FROM earnings_calendar "
|
||||||
|
"WHERE act_symbol IN ('AAPL', 'MSFT', 'NVDA', 'JPM')",
|
||||||
|
binary=_DOLT_BIN,
|
||||||
|
)
|
||||||
|
max_date = date.fromisoformat(rows[0]["max_date"])
|
||||||
|
today = max_date - timedelta(days=35) # ~35d horizon, per the importer's note
|
||||||
|
|
||||||
imp = DoltEarningsImporter(
|
imp = DoltEarningsImporter(
|
||||||
repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=date.today(), do_pull=False, dolt=dolt_client
|
repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=today, do_pull=False, dolt=dolt_client
|
||||||
)
|
)
|
||||||
run = await run_import(imp, engine=engine)
|
run = await run_import(imp, engine=engine)
|
||||||
|
|
||||||
assert run.status == STATUS_PROMOTED
|
assert run.status == STATUS_PROMOTED, run.error_details
|
||||||
events = await _events(factory)
|
events = await _events(factory)
|
||||||
assert events, "no earnings parsed from the real clone"
|
assert events, "no earnings parsed from the real clone"
|
||||||
assert any(e.announce_date > date.today() for e in events), "no forward calendar"
|
assert any(e.announce_date > today for e in events), "no forward calendar"
|
||||||
assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing"
|
assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing"
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.services.regime_monitor_service import (
|
|||||||
breadth_level_score,
|
breadth_level_score,
|
||||||
drawdown_pct,
|
drawdown_pct,
|
||||||
f2_credit_spreads,
|
f2_credit_spreads,
|
||||||
|
current_observation,
|
||||||
fundamental_overlay,
|
fundamental_overlay,
|
||||||
p1_trend_break,
|
p1_trend_break,
|
||||||
p2_death_cross,
|
p2_death_cross,
|
||||||
@@ -238,6 +239,77 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
|
|||||||
assert expired["available"] is False
|
assert expired["available"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_observation_is_visible_before_its_effective_date():
|
||||||
|
"""Refreshing must not look like it did nothing.
|
||||||
|
|
||||||
|
The stored snapshot keeps the effective-date gate so a rebuild cannot
|
||||||
|
backdate an observation, but the live card reports that date instead of
|
||||||
|
blanking the content -- otherwise a Friday refresh stays invisible until
|
||||||
|
Monday.
|
||||||
|
"""
|
||||||
|
overrides = {
|
||||||
|
"f1_score": 50.0,
|
||||||
|
"f3_score": 100.0,
|
||||||
|
"capex": {"GOOGL": "holding"},
|
||||||
|
"good_news_stock_down": "yes",
|
||||||
|
"reasoning": "fresh read",
|
||||||
|
"fetched_at": "2026-06-01T10:00:00+00:00",
|
||||||
|
"effective_date": "2026-06-02",
|
||||||
|
}
|
||||||
|
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
|
||||||
|
|
||||||
|
before = date(2026, 6, 1)
|
||||||
|
record = fundamental_overlay(overrides, config, before)
|
||||||
|
now = current_observation(overrides, config, before)
|
||||||
|
|
||||||
|
# Same day, same observation: the record hides it, the live reading shows it.
|
||||||
|
assert record["capex"] is None and record["reasoning"] is None
|
||||||
|
assert now["capex"] == {"GOOGL": "holding"}
|
||||||
|
assert now["reasoning"] == "fresh read"
|
||||||
|
assert now["capex_stress"] == 50.0
|
||||||
|
assert now["earnings_stress"] == 100.0
|
||||||
|
|
||||||
|
# ...while still reporting when the stored record picks it up.
|
||||||
|
assert now["pending"] is True
|
||||||
|
assert now["effective_date"] == "2026-06-02"
|
||||||
|
assert now["available"] is True
|
||||||
|
|
||||||
|
# Staleness still expires the live reading.
|
||||||
|
assert current_observation(overrides, config, date(2026, 8, 22))["stale"] is True
|
||||||
|
assert current_observation(overrides, config, date(2026, 8, 22))["available"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_uncollected_observation_is_not_reported_as_collected():
|
||||||
|
"""The default override is placeholders, not a reading.
|
||||||
|
|
||||||
|
``capex`` defaults to "unknown" for every hyperscaler and the reaction to
|
||||||
|
"mixed". Surfacing those as an observation made the card claim a read that
|
||||||
|
never happened.
|
||||||
|
"""
|
||||||
|
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||||
|
nothing_collected = {
|
||||||
|
"f1_score": None,
|
||||||
|
"f3_score": None,
|
||||||
|
"capex": {name: "unknown" for name in names},
|
||||||
|
"good_news_stock_down": "mixed",
|
||||||
|
"reasoning": None,
|
||||||
|
"fetched_at": None,
|
||||||
|
"effective_date": None,
|
||||||
|
"source": "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
blank = current_observation(nothing_collected, DEFAULT_CONFIG, date(2026, 8, 7))
|
||||||
|
assert blank["observed"] is False
|
||||||
|
assert blank["available"] is False
|
||||||
|
assert blank["capex"] is None
|
||||||
|
assert blank["good_news_stock_down"] is None
|
||||||
|
assert blank["reasoning"] is None
|
||||||
|
|
||||||
|
# One real observation flips it, placeholders and all.
|
||||||
|
collected = {**nothing_collected, "fetched_at": "2026-08-07T10:00:00+00:00", "source": "gemini"}
|
||||||
|
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_fundamentals_do_not_move_the_warning_score():
|
def test_fundamentals_do_not_move_the_warning_score():
|
||||||
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
|
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
|
||||||
|
|
||||||
@@ -421,11 +493,11 @@ async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
|
|||||||
changed["state"] = {"score": 90.0, "band": "breaking"}
|
changed["state"] = {"score": 90.0, "band": "breaking"}
|
||||||
|
|
||||||
written, _ = await rms._upsert_snapshot(
|
written, _ = await rms._upsert_snapshot(
|
||||||
db_session, first, rewrite_existing_v2=True
|
db_session, first, rewrite_existing=True
|
||||||
)
|
)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
rewritten, persisted = await rms._upsert_snapshot(
|
rewritten, persisted = await rms._upsert_snapshot(
|
||||||
db_session, changed, rewrite_existing_v2=False
|
db_session, changed, rewrite_existing=False
|
||||||
)
|
)
|
||||||
row = (
|
row = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
@@ -468,10 +540,10 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
|||||||
return {}, {}
|
return {}, {}
|
||||||
|
|
||||||
async def fake_latest(_db):
|
async def fake_latest(_db):
|
||||||
return object(), {"methodology": "v3"}
|
return object(), {"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}
|
||||||
|
|
||||||
async def fake_upsert(_db, result, *, rewrite_existing_v2):
|
async def fake_upsert(_db, result, *, rewrite_existing):
|
||||||
rewrites.append(rewrite_existing_v2)
|
rewrites.append(rewrite_existing)
|
||||||
return True, result
|
return True, result
|
||||||
|
|
||||||
class FakeDB:
|
class FakeDB:
|
||||||
@@ -492,6 +564,91 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
|||||||
assert rewrites == [True]
|
assert rewrites == [True]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("stored", "expect_reseed"),
|
||||||
|
[
|
||||||
|
({"methodology": "v3"}, True), # written before the marker existed
|
||||||
|
({"methodology": "v3", "sensor_revision": 1}, True),
|
||||||
|
({"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_a_stale_sensor_revision_reseeds_stored_history(
|
||||||
|
monkeypatch, stored, expect_reseed
|
||||||
|
):
|
||||||
|
"""Widening the OAS window has to reach rows that are already stored.
|
||||||
|
|
||||||
|
Routine runs recompute only the latest date, so without this trigger every
|
||||||
|
older row would keep the credit gap the wider window exists to close.
|
||||||
|
"""
|
||||||
|
sessions = [date.today() - timedelta(days=offset) for offset in reversed(range(10))]
|
||||||
|
prices = {symbol: [(day, 100.0) for day in sessions] for symbol in ("SMH", "QQQ", "SPY")}
|
||||||
|
written: list[date] = []
|
||||||
|
revisions: list[int] = []
|
||||||
|
|
||||||
|
async def fake_config(_db):
|
||||||
|
return copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
async def fake_overrides(_db):
|
||||||
|
return {"locked": True, "fetched_at": None, "effective_date": None}
|
||||||
|
|
||||||
|
async def fake_prices(_config, _start, _end):
|
||||||
|
return prices
|
||||||
|
|
||||||
|
async def fake_fred(_series_id, _start, _end):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_breadth(_db, _symbols, window, min_tickers):
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
async def fake_latest(_db):
|
||||||
|
return object(), stored
|
||||||
|
|
||||||
|
async def fake_upsert(_db, result, *, rewrite_existing):
|
||||||
|
written.append(date.fromisoformat(result["date"]))
|
||||||
|
revisions.append(result["sensor_revision"])
|
||||||
|
# Every replayed row must be rewritable, or a reseed writes one row.
|
||||||
|
assert rewrite_existing is True
|
||||||
|
return True, result
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for name, value in (
|
||||||
|
("get_regime_config", fake_config),
|
||||||
|
("get_fundamental_overrides", fake_overrides),
|
||||||
|
("_fetch_prices", fake_prices),
|
||||||
|
("_fetch_fred_series", fake_fred),
|
||||||
|
("_latest_snapshot_row", fake_latest),
|
||||||
|
("_upsert_snapshot", fake_upsert),
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(rms, name, value)
|
||||||
|
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
|
||||||
|
|
||||||
|
await rms.update_regime_monitor(FakeDB())
|
||||||
|
|
||||||
|
if expect_reseed:
|
||||||
|
assert written == sessions, "a reseed must replay the whole stored span"
|
||||||
|
else:
|
||||||
|
assert written == [sessions[-1]], "a current revision must not reseed"
|
||||||
|
assert set(revisions) == {rms.SENSOR_REVISION}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rebuild_span_stays_inside_the_oas_window():
|
||||||
|
"""The reseed must not replay rows it cannot compute credit for.
|
||||||
|
|
||||||
|
Each replayed row needs W3's lookback inside the fetched OAS window; if the
|
||||||
|
replay reached further back than the fetch, the reseed would recreate the
|
||||||
|
very gap it exists to close.
|
||||||
|
"""
|
||||||
|
replay_calendar_days = rms.REBUILD_LOOKBACK_DAYS
|
||||||
|
w3_lookback_calendar = rms.W3_OAS_LOOKBACK * 7 / 5 # business days -> calendar
|
||||||
|
assert replay_calendar_days + w3_lookback_calendar <= rms.HY_OAS_WINDOW_DAYS
|
||||||
|
# ...and still covers the 400-session series the v3 cutover wrote.
|
||||||
|
assert replay_calendar_days >= 400 * 365 / 252
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch):
|
async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch):
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ httpx transport (no network)."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|||||||
Reference in New Issue
Block a user