fix(research): make the calibration artifact live up to its refusal guarantees
Review of the v4 evidence path. The shipped sensors, bands, methodology bump and
categorical allowlist were found sound; these are gaps in the harness that
produced the evidence for them.
The recommendation gates were optional, so they were not gates. The calendar
anchor lived behind --expected-first-session, which defaulted to None -- so the
committed artifact had no first-date check at all, leaving only a session COUNT
that is tautological (the harness slices the tail of the price series to whatever
was asked for). And the state_v4 <= state_v3 invariant was appended only when
both variants were present, so `--methodology v3` alone could still emit a v4
recommendation having never evaluated v4. The anchor is now a published constant
asserted unconditionally, required explicitly whenever --end/--sessions are
overridden, and v3+v4 are mandatory. Both refusals exit 2.
The P1_SCORE_CAP decision was taken on the wrong population. The agreed rule was
"sole price argmax on >80% of sessions with State >= 40"; the harness reported
only all-session counts and the doc concluded from 17/408 = 4.2%. Measured on the
actual population: 47 qualifying sessions, P1 sole argmax on 17 = **36.2%** (P2
16, P3 14). Still well under 80, so the conclusion holds -- but it was reached
from a denominator that did not test the rule, and 36.2% is a materially
different number to have on the page.
Provenance did not identify the code that produced the artifact. It recorded
git_rev c3ae5ad while the live v4 variant depended on app changes that were still
uncommitted, so checking out that revision would not reproduce it. Now records
git_dirty plus sha256 of regime_monitor_service, breadth_service and the script
itself, and this artifact is regenerated from a clean tree.
The 400- vs 700-day OAS question was described as settled but was not
reproducible: the artifact carried only oas_fetch_days 4748, and
v2_reconstruction patches the per-session window to 3653 regardless, so
--oas-window-days 400 could not simulate it. Patching a window cannot stand in
for data that was simply absent, so v2_reconstruction_oas400 truncates the OAS
SOURCE series instead: avg 26.54, p80 42.52, max 100.00 against published
22.6 / 35.1 / 91.2. Full coverage reproduces all three, so the published figures
predate the truncation. Now recorded in the doc.
v4-vix-only and v4-p1-only had become no-ops: after the cutover the shipped
sensors ARE v4, so patching one candidate in left the other shipped and both
variants evaluated full v4. Each now restores the other sensor to its v3 formula,
and they separate properly (v3 18.13, v4-vix-only 16.64, v4-p1-only 16.28,
v4 14.78 -- each fix contributing about half the move).
Docs: the copy-paste invocation was mangled by a backslash-escaping bug and is
now a fenced, forward-slash command; "Unlike P3 and V1 ... P3's do not" corrected
to "Unlike P1 and V1"; the point-in-time section updated from 400 sessions to the
672-calendar-day / ~464-session window production actually replays; the exercised
52.33 VIX print recorded so the top anchors are not merely asserted.
Tests: band_for now pinned at 64.9/65 from both sides so a silent revert to 80
cannot pass, and the categorical carry-forward test stores locked=True and
asserts it survives -- losing it is half the failure mode, since
update_regime_monitor only auto-refreshes when locked is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,14 @@ gate entries, exits, position size, ranking, or alerts about individual setups.
|
||||
**v4 supersedes v3** (2026-08-08). Unlike v3, whose calibration was ad-hoc and
|
||||
never landed, every number below is reproducible:
|
||||
|
||||
.\.venv\Scripts\python.exe scripts
|
||||
un_regime_monitor_calibration.py ^
|
||||
--methodology v2_reconstruction,v3,v4 --cache-dir .calib-cache
|
||||
```
|
||||
.venv/Scripts/python.exe scripts/run_regime_monitor_calibration.py --methodology v2_reconstruction,v2_reconstruction_oas400,v3,v4,v4-vix-only,v4-p1-only --cache-dir .calib-cache
|
||||
```
|
||||
|
||||
`v3` and `v4` are mandatory — the row-wise `state_v4 <= state_v3` invariant is
|
||||
a hard gate and needs both — and the replayed **start** date is asserted
|
||||
against the published window. The session *count* alone proves nothing, since
|
||||
the harness slices the tail of the price series to whatever was asked for.
|
||||
|
||||
The harness replays the 408 sessions ending 2026-07-24 from the live inputs
|
||||
(Alpaca for all 33 symbols, FRED for VIX and HY OAS) with no database, and
|
||||
@@ -244,11 +249,31 @@ Every distribution above inherits that bias. It is the same bias v2 carried, so
|
||||
the v2/v3 comparison is like-for-like, but the absolute band shares are
|
||||
optimistic.
|
||||
|
||||
**Which OAS window the published v2 figures used.** v2 requested 13 years of HY
|
||||
OAS and sliced `HY_OAS_REFERENCE_YEARS = 10.0` per session; ICE serves only ~3
|
||||
years (778 observations from 2023-08-08), so the effective window was that. But
|
||||
production v2 also fetched only 400 *calendar* days at one point — the bug fixed
|
||||
2026-08-07 — and whether the published numbers predate that was not recoverable
|
||||
from the text. Settled by replay rather than assumed: the
|
||||
`v2_reconstruction_oas400` variant truncates the OAS **source series** to 400
|
||||
days (patching the per-session window cannot simulate data that was simply
|
||||
absent) and yields avg 26.54, p80 42.52, max **100.00**, against published
|
||||
22.6 / 35.1 / 91.2. Full coverage reproduces all three. So the published figures
|
||||
correspond to the untruncated fetch.
|
||||
|
||||
**The top VIX anchors are exercised, not just asserted.** The window contains a
|
||||
52.33 close (2025-04-08), so the 40 → 80 → 55 → 100 segment is fed by real data
|
||||
rather than justified from long-run history alone.
|
||||
|
||||
## Point-in-time record
|
||||
|
||||
The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions
|
||||
with sufficient sensor warm-up; routine runs thereafter insert/update only the
|
||||
latest trading date. The history API and main chart show only snapshots matching
|
||||
The first run under a new `METHODOLOGY` rebuilds every session inside
|
||||
`REBUILD_LOOKBACK_DAYS` — 672 calendar days, roughly 464 trading sessions;
|
||||
routine runs thereafter insert/update only the latest trading date. The bound is
|
||||
in calendar days rather than a session count because the binding constraint is
|
||||
the OAS fetch: each replayed row needs W3's lookback inside
|
||||
`HY_OAS_WINDOW_DAYS`, so replaying further back would recreate the credit gap a
|
||||
reseed exists to close. The history API and main chart show only snapshots matching
|
||||
the current methodology, so a bump reseeds the series rather than splicing two
|
||||
formulas into one line.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +0,0 @@
|
||||
# Regime Monitor v4 calibration
|
||||
|
||||
Generated 2026-08-08T20:19:31 at `c3ae5ad`, 2024-12-05 → 2026-07-24.
|
||||
|
||||
## Hard gates
|
||||
|
||||
| gate | expected | measured | |
|
||||
|---|---|---|---|
|
||||
| symbols_fetched | 33 | 33 | ok |
|
||||
| per_symbol_warmup_252_bars | all | 33 | ok |
|
||||
| per_symbol_reaches_last_session | 2026-07-24 | 33 | ok |
|
||||
| breadth_counts_full_basket | 30 | 408/408 sessions | ok |
|
||||
| sessions_scored | 408 | 408 | ok |
|
||||
| last_scored_date | 2026-07-24 | 2026-07-24 | ok |
|
||||
| w1_available_every_session | 408 | 408 | ok |
|
||||
| state_coverage_100_every_row | 0 | 0 | ok |
|
||||
| no_stale_inputs | 0 | 0 | ok |
|
||||
| state_v4_le_v3_every_row | 0 | 0 | ok |
|
||||
|
||||
## Distributions
|
||||
|
||||
| variant | avg | median | p80 | p90 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| v2_reconstruction | 22.68 | 16.15 | 35.1 | 65.63 | 91.2 |
|
||||
| v3 | 18.13 | 9.1 | 31.36 | 65.0 | 87.4 |
|
||||
| v4 | 14.78 | 8.35 | 21.7 | 43.63 | 83.5 |
|
||||
| v4-vix-b | 15.24 | 8.55 | 22.62 | 44.33 | 83.6 |
|
||||
| v4-p1-capped | 14.73 | 8.35 | 21.7 | 43.63 | 80.1 |
|
||||
|
||||
## Saturation census (sessions pegged at 100)
|
||||
|
||||
| variant | P1 | P2 | P3 | V1 |
|
||||
|---|---|---|---|---|
|
||||
| v2_reconstruction | 46 | 0 | 39 | 14 |
|
||||
| v3 | 46 | 0 | 0 | 14 |
|
||||
| v4 | 0 | 0 | 0 | 0 |
|
||||
| v4-vix-b | 0 | 0 | 0 | 0 |
|
||||
| v4-p1-capped | 0 | 0 | 0 | 0 |
|
||||
|
||||
## Reproduction gates — v2_reconstruction
|
||||
|
||||
| figure | published | measured | |
|
||||
|---|---|---|---|
|
||||
| v2_state_avg | 22.6 | 22.68 | ok |
|
||||
| v2_state_p80 | 35.1 | 35.1 | ok |
|
||||
| v2_state_max | 91.2 | 91.2 | ok |
|
||||
| v2_p3_pegged | 39 | 39 | ok |
|
||||
| w1_live_sessions | 108 | 108 | ok |
|
||||
|
||||
## Reproduction gates — v3
|
||||
|
||||
| figure | published | measured | |
|
||||
|---|---|---|---|
|
||||
| v3_state_max | 87.4 | 87.4 | ok |
|
||||
|
||||
## v4 band-share grid (watch 20 / elevated 50)
|
||||
|
||||
| breaking | stable | watch | elevated | breaking |
|
||||
|---|---|---|---|---|
|
||||
| 60 | 78.9 | 13.0 | 2.9 | 5.1 |
|
||||
| 65 | 78.9 | 13.0 | 4.7 | 3.4 |
|
||||
| 70 | 78.9 | 13.0 | 6.9 | 1.2 |
|
||||
|
||||
## Scenarios (pillar arithmetic, explicit sensor scores)
|
||||
|
||||
| scenario | price | breadth | C1 | V1 | State |
|
||||
|---|---|---|---|---|---|
|
||||
| S1 ordinary tape | 7.5 | 0.0 | 0.0 | 4.0 | **3.6** |
|
||||
| S2 10% correction, calm credit | 31.25 | 62.5 | 0.0 | 34.4 | **33.28** |
|
||||
| S3a 2022-style, calm credit, no death cross | 90.83 | 100.0 | 0.0 | 60.0 | **70.33** |
|
||||
| S3b 2022-style, calm credit, death cross | 100.0 | 100.0 | 0.0 | 60.0 | **74.0** |
|
||||
| S4 credit event on top | 100.0 | 100.0 | 75.0 | 86.67 | **93.0** |
|
||||
| S5 March 2020, everything pegged | 100.0 | 100.0 | 100.0 | 100.0 | **100.0** |
|
||||
|
||||
Recommendation: `{'state_bands_candidate': [20.0, 50.0, 65.0], 'provisional': False, 'note': 'confirm against band_grid + scenarios before shipping'}`
|
||||
@@ -55,6 +55,10 @@ from app.services import regime_monitor_service as rms # noqa: E402
|
||||
# on purpose -- anything tighter would be false precision.
|
||||
PUBLISHED = {
|
||||
"window_end": "2026-07-24",
|
||||
# The session count alone is tautological -- the harness slices the tail of
|
||||
# leader_series, so it can only ever equal what was asked for. The start date
|
||||
# is what actually validates the calendar.
|
||||
"window_first": "2024-12-05",
|
||||
"sessions": 408,
|
||||
"w1_live_sessions": 108,
|
||||
"v2_state_avg": 22.6,
|
||||
@@ -223,8 +227,19 @@ VARIANTS: dict[str, dict[str, Callable]] = {
|
||||
"p1_trend_break": _capped(_candidate_p1(), 50.0),
|
||||
"p5_volatility": _candidate_p5(P5_VIX_ANCHORS_A),
|
||||
},
|
||||
"v4-vix-only": {"p5_volatility": _candidate_p5(P5_VIX_ANCHORS_A)},
|
||||
"v4-p1-only": {"p1_trend_break": _candidate_p1()},
|
||||
"v4-vix-only": {"p1_trend_break": _v3_p1_trend_break},
|
||||
"v4-p1-only": {"p5_volatility": _v3_p5_volatility},
|
||||
# (4) v2 as production actually fetched it: a 400-calendar-day OAS source,
|
||||
# which left the oldest rows with no credit at all. Truncating the SERIES is
|
||||
# the only faithful simulation -- patching the per-session window is not,
|
||||
# because the data was simply absent.
|
||||
"v2_reconstruction_oas400": {
|
||||
"p1_trend_break": _v3_p1_trend_break,
|
||||
"p5_volatility": _v3_p5_volatility,
|
||||
"p3_drawdown": _v2_p3_drawdown,
|
||||
"f2_credit_spreads": _v2_f2_credit_spreads,
|
||||
"HY_OAS_WINDOW_DAYS": 3653,
|
||||
},
|
||||
# v2 State sensors + the v2 divergence gate that feeds W1. The v2 *Warning
|
||||
# composition* (F1/F3 fundamentals, 20 of 100 points) is NOT reconstructed,
|
||||
# so only State statistics and the W1 census are comparable to the published
|
||||
@@ -245,7 +260,14 @@ VARIANTS: dict[str, dict[str, Callable]] = {
|
||||
}
|
||||
|
||||
# Variants needing the retired divergence formula rather than the live one.
|
||||
V2_DIVERGENCE_VARIANTS = {"v2_reconstruction"}
|
||||
V2_DIVERGENCE_VARIANTS = {"v2_reconstruction", "v2_reconstruction_oas400"}
|
||||
|
||||
# Variants whose OAS *source series* is truncated before replay, in calendar days.
|
||||
OAS_SOURCE_TRUNCATION = {"v2_reconstruction_oas400": 400}
|
||||
|
||||
# A v4 recommendation is meaningless without both of these: the row-wise
|
||||
# state_v4 <= state_v3 invariant needs them, and it is a hard gate.
|
||||
REQUIRED_VARIANTS = ("v3", "v4")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -332,6 +354,10 @@ def _replay(
|
||||
divergence_series = divergence_by_variant[
|
||||
"v2" if variant in V2_DIVERGENCE_VARIANTS else "live"
|
||||
]
|
||||
truncate_days = OAS_SOURCE_TRUNCATION.get(variant)
|
||||
if truncate_days is not None and oas:
|
||||
cutoff = max(d for d, _ in oas) - timedelta(days=truncate_days)
|
||||
oas = [(d, v) for d, v in oas if d >= cutoff]
|
||||
rows: list[dict] = []
|
||||
with patched(VARIANTS[variant]):
|
||||
for as_of in sessions:
|
||||
@@ -383,6 +409,10 @@ def _stats(rows: list[dict], label: str) -> dict:
|
||||
return sum(1 for r in rows if (_sensor_score(r, pillar, sensor) or 0) >= 100.0)
|
||||
|
||||
argmax_sole, argmax_tied, ties = {"P1": 0, "P2": 0, "P3": 0}, {"P1": 0, "P2": 0, "P3": 0}, 0
|
||||
# The P1_SCORE_CAP rule is "sole argmax on >80% of sessions with State >= 40",
|
||||
# so the all-session count does not evaluate it. Track the conditional
|
||||
# population separately rather than deciding off the wrong denominator.
|
||||
stressed_sole, stressed_total = {"P1": 0, "P2": 0, "P3": 0}, 0
|
||||
for row in rows:
|
||||
legs = {s: _sensor_score(row, "price", s) for s in ("P1", "P2", "P3")}
|
||||
live = {k: v for k, v in legs.items() if v is not None}
|
||||
@@ -396,6 +426,10 @@ def _stats(rows: list[dict], label: str) -> dict:
|
||||
argmax_tied[w] += 1
|
||||
if len(winners) == 1:
|
||||
argmax_sole[winners[0]] += 1
|
||||
if (row["state"]["score"] or 0) >= 40.0:
|
||||
stressed_total += 1
|
||||
if len(winners) == 1:
|
||||
stressed_sole[winners[0]] += 1
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
@@ -418,6 +452,13 @@ def _stats(rows: list[dict], label: str) -> dict:
|
||||
"price_argmax_sole": argmax_sole,
|
||||
"price_argmax_tie_inclusive": argmax_tied,
|
||||
"price_argmax_ties": ties,
|
||||
"price_argmax_when_state_ge_40": {
|
||||
"sessions": stressed_total,
|
||||
"sole": stressed_sole,
|
||||
"p1_sole_share_pct": round(stressed_sole["P1"] / stressed_total * 100.0, 1)
|
||||
if stressed_total else None,
|
||||
"cap_rule": "P1_SCORE_CAP warranted if p1_sole_share_pct > 80",
|
||||
},
|
||||
"w1_nonzero_sessions": sum(
|
||||
1 for r in rows if (_sensor_score(r, "breadth_divergence", "W1") or 0) > 0
|
||||
),
|
||||
@@ -460,7 +501,7 @@ def _completeness_gates(
|
||||
]
|
||||
|
||||
|
||||
def _pipeline_gates(rows: list[dict], sessions: list[date], expected_first: str | None) -> list[dict]:
|
||||
def _pipeline_gates(rows: list[dict], sessions: list[date], expected_first: str) -> list[dict]:
|
||||
coverage_bad = [
|
||||
r["date"] for r in rows if (r["state"]["coverage"] or 0) < 100.0
|
||||
]
|
||||
@@ -482,13 +523,13 @@ def _pipeline_gates(rows: list[dict], sessions: list[date], expected_first: str
|
||||
{"gate": "no_stale_inputs", "expected": 0,
|
||||
"measured": len(stale), "passed": not stale, "detail": stale[:20]},
|
||||
]
|
||||
# sessions_scored alone is tautological when the harness slices the tail of
|
||||
# leader_series -- the calendar is only really checked by the start date.
|
||||
if expected_first:
|
||||
gates.append({
|
||||
"gate": "first_scored_date", "expected": expected_first,
|
||||
"measured": rows[0]["date"], "passed": rows[0]["date"] == expected_first,
|
||||
})
|
||||
# Unconditional: sessions_scored is tautological when the harness slices the
|
||||
# tail of leader_series, so the start date is the only real calendar check.
|
||||
# An optional gate is not a gate.
|
||||
gates.append({
|
||||
"gate": "first_scored_date", "expected": expected_first,
|
||||
"measured": rows[0]["date"], "passed": rows[0]["date"] == expected_first,
|
||||
})
|
||||
return gates
|
||||
|
||||
|
||||
@@ -574,8 +615,13 @@ def _scenarios(vix_anchors, p1_anchors) -> list[dict]:
|
||||
def _markdown(report: dict) -> str:
|
||||
"""Scannable sibling to the JSON. Never written over a curated docs/ file."""
|
||||
lines = ["# Regime Monitor v4 calibration", ""]
|
||||
lines.append(f"Generated {report['generated_at']} at `{report['git_rev']}`, "
|
||||
src = report["source"]
|
||||
dirty = " **(dirty working tree)**" if src.get("git_dirty") else ""
|
||||
lines.append(f"Generated {report['generated_at']} at `{src['git_rev']}`{dirty}, "
|
||||
f"{report['provenance']['scored_range'][0]} → {report['provenance']['scored_range'][1]}.")
|
||||
lines += ["", "Source hashes (sha256, first 16):", ""]
|
||||
for rel, digest in src["source_sha256"].items():
|
||||
lines.append(f"- `{rel}` — `{digest}`")
|
||||
lines += ["", "## Hard gates", "", "| gate | expected | measured | |", "|---|---|---|---|"]
|
||||
for g in report["hard_gates"]:
|
||||
lines.append(f"| {g['gate']} | {g['expected']} | {g['measured']} | {'ok' if g['passed'] else '**FAIL**'} |")
|
||||
@@ -637,7 +683,9 @@ def _parse_args() -> argparse.Namespace:
|
||||
help="fetch range; matches v2's request (ICE truncates to ~3y)")
|
||||
p.add_argument("--methodology", default="v3,v4")
|
||||
p.add_argument("--expected-first-session", default=None,
|
||||
help="assert the first replayed date; sessions count alone is tautological")
|
||||
help="assert the first replayed date. Defaults to the published "
|
||||
"window's start; REQUIRED when --end/--sessions are overridden, "
|
||||
"since the count alone is tautological.")
|
||||
p.add_argument("--cache-dir", default=None)
|
||||
p.add_argument("--out", default=None)
|
||||
p.add_argument("--quiet", action="store_true")
|
||||
@@ -652,6 +700,33 @@ def _git_rev() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _source_state() -> dict:
|
||||
"""Identify the code that produced this run, not just the commit HEAD names.
|
||||
|
||||
A run from a dirty tree is not reproducible by checking out git_rev -- which
|
||||
is exactly how the first v4 artifact was generated, with HEAD still on the
|
||||
harness commit while the v4 sensors lived only in the working tree. The
|
||||
hashes make that visible instead of implied.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
tracked = [
|
||||
"app/services/regime_monitor_service.py",
|
||||
"app/services/breadth_service.py",
|
||||
"scripts/run_regime_monitor_calibration.py",
|
||||
]
|
||||
digests = {}
|
||||
for rel in tracked:
|
||||
path = ROOT / rel
|
||||
digests[rel] = hashlib.sha256(path.read_bytes()).hexdigest()[:16] if path.exists() else None
|
||||
try:
|
||||
dirty = bool(subprocess.run(["git", "status", "--porcelain"], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout.strip())
|
||||
except Exception:
|
||||
dirty = None
|
||||
return {"git_rev": _git_rev(), "git_dirty": dirty, "source_sha256": digests}
|
||||
|
||||
|
||||
async def _main() -> int:
|
||||
args = _parse_args()
|
||||
end = date.fromisoformat(args.end)
|
||||
@@ -659,10 +734,23 @@ async def _main() -> int:
|
||||
config = deepcopy(rms.DEFAULT_CONFIG)
|
||||
symbols = _all_symbols(config)
|
||||
variants = [v.strip() for v in args.methodology.split(",") if v.strip()]
|
||||
expected_first = args.expected_first_session
|
||||
if not expected_first:
|
||||
if args.end != PUBLISHED["window_end"] or args.sessions != PUBLISHED["sessions"]:
|
||||
print("--expected-first-session is required when --end or --sessions "
|
||||
"differ from the published window.", file=sys.stderr)
|
||||
return 2
|
||||
expected_first = PUBLISHED["window_first"]
|
||||
unknown = [v for v in variants if v not in VARIANTS]
|
||||
if unknown:
|
||||
print(f"unknown variant(s): {unknown}; known: {sorted(VARIANTS)}", file=sys.stderr)
|
||||
return 2
|
||||
missing = [v for v in REQUIRED_VARIANTS if v not in variants]
|
||||
if missing:
|
||||
print(f"--methodology must include {list(REQUIRED_VARIANTS)}; missing {missing}. "
|
||||
"The state_v4 <= state_v3 invariant is a hard gate and needs both.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not args.quiet:
|
||||
print(f"fetching {len(symbols)} symbols from Alpaca...", flush=True)
|
||||
@@ -707,7 +795,7 @@ async def _main() -> int:
|
||||
|
||||
canonical = rows_by_variant.get("v3") or next(iter(rows_by_variant.values()))
|
||||
hard_gates = _completeness_gates(prices, symbols, sessions, breadth_counts, len(_basket(config)))
|
||||
hard_gates += _pipeline_gates(canonical, sessions, args.expected_first_session)
|
||||
hard_gates += _pipeline_gates(canonical, sessions, expected_first)
|
||||
if "v3" in rows_by_variant and "v4" in rows_by_variant:
|
||||
hard_gates.append(_invariant_gate(rows_by_variant["v3"], rows_by_variant["v4"]))
|
||||
|
||||
@@ -718,7 +806,7 @@ async def _main() -> int:
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"git_rev": _git_rev(),
|
||||
"source": _source_state(),
|
||||
"params": vars(args),
|
||||
"provenance": {
|
||||
"symbols": {s: {"bars": len(prices.get(s, [])),
|
||||
|
||||
@@ -52,6 +52,10 @@ def test_band_for_is_per_axis():
|
||||
assert band_for(20, STATE_BANDS) == "watch"
|
||||
assert band_for(50, STATE_BANDS) == "elevated"
|
||||
assert band_for(80, STATE_BANDS) == "breaking"
|
||||
# v4 moved the top band 80 -> 65; pin the new boundary from both sides so a
|
||||
# silent revert cannot pass. band_for is inclusive at the threshold.
|
||||
assert band_for(64.9, STATE_BANDS) == "elevated"
|
||||
assert band_for(65, STATE_BANDS) == "breaking"
|
||||
# Warning's realized range is far narrower, so it gets its own thresholds.
|
||||
assert band_for(45, STATE_BANDS) == "watch"
|
||||
assert band_for(45, WARNING_BANDS) == "elevated"
|
||||
@@ -401,6 +405,7 @@ async def test_v2_observation_survives_the_methodology_bump(monkeypatch, stored_
|
||||
"source": "gemini",
|
||||
"fetched_at": "2026-07-24T14:25:47+00:00",
|
||||
"effective_date": "2026-07-27",
|
||||
"locked": True,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(rms.settings_store, "get_value", fake_value)
|
||||
@@ -412,6 +417,9 @@ async def test_v2_observation_survives_the_methodology_bump(monkeypatch, stored_
|
||||
assert result["effective_date"] == "2026-07-27"
|
||||
assert result["f1_score"] == 37.5 # recomputed on the current scale, not the stored 0.0
|
||||
assert result["fetched_at"] == "2026-07-24T14:25:47+00:00" # or a refresh loop starts
|
||||
# locked is the operator saying "do not overwrite this". Losing it is half the
|
||||
# failure mode: update_regime_monitor only auto-refreshes when locked is false.
|
||||
assert result["locked"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user