From 62060e9a44e3ba3df10700848a2d1317924c728a Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Sun, 2 Aug 2026 22:55:57 -0400 Subject: [PATCH] feat(orchestrate): --arl0-years, the false-alarm budget on the CLI EdgeDecayTrigger has always taken a Thresholds, but __main__ built it with none, so the budget was unreachable from the only place the monitor actually runs. It is the one knob worth reaching for, because it buys detection latency roughly one for one. Measured on the real 47-market trend book now frozen on the ledger (+0.5565R, 23.3 trades/yr): --arl0-years h(sigma) ARL0(yr) ARL1(yr) (default) 17.17 25.0 9.4 15 13.84 15.0 6.8 10 11.54 10.0 5.1 6 9.06 6.0 3.5 On a low-frequency book the default is slow enough to be decorative, so the number deserves choosing rather than inheriting. Three choices worth recording: * Omitting the flag passes None, not a materialized Thresholds(). Building one eagerly would pin today's numbers here, so a retune in crucible would quietly stop reaching this CLI. The default has to keep coming from the judge. * Two silent-no-op paths are reported rather than shrugged at, because a tuning flag that tunes nothing reads as applied: --arl0-years without --edge-decay, and --arl0-years against a baseline with no firing rate. The second is the subtle one: years are converted using the BASELINE's own rate, so an undated baseline falls back to monitor_arl0_trades and the flag does nothing, which is exactly the ambiguity the years unit exists to remove. * A non-positive budget raises. It is a span of calendar time, and argparse would otherwise hand a negative straight to the bisection solver. 405 tests pass, ruff clean. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 20 ++++++ crucible_stack/orchestrate/__main__.py | 48 ++++++++++++++- tests/test_runner.py | 84 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b75064a..4a14254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ breaking change are governed by [docs/api-stability.md](docs/api-stability.md). ## [Unreleased] +### Added +- **`--arl0-years`**, exposing the edge-decay CUSUM's false-alarm budget on the CLI. + `EdgeDecayTrigger` has always taken a `Thresholds`, but `__main__` built it with none, + so the budget was unreachable from the only place the monitor actually runs. It is the + one knob worth reaching for, because it buys detection latency roughly one for one: + measured on a real 47-market trend book (23.3 trades/yr), 25 years gives a 9.4-year + wait to call a halved edge, 10 years gives 5.1, and 6 gives 3.5. On a low-frequency + book the default can be slow enough to be decorative, so the number deserves choosing + rather than inheriting. + + Omitting it passes `None` rather than a materialized `Thresholds()`, deliberately: the + default then comes from whichever crucible is installed, so a retune there reaches this + CLI without a matching change here. + + Two silent-no-op paths are reported rather than shrugged at, since a tuning flag that + tunes nothing reads as applied: passing it without `--edge-decay`, and passing it when + the frozen baseline carries no firing rate (years are converted using that rate, so + crucible falls back to `monitor_arl0_trades` and the flag does nothing). A non-positive + budget raises, since it is a span of calendar time. + ### Fixed - **The edge monitor's firing-rate channel was dead through the orchestrator.** `check_decay` built its `TradeLog` from bare floats (`TradeLog.from_arrays(trade_r)`), diff --git a/crucible_stack/orchestrate/__main__.py b/crucible_stack/orchestrate/__main__.py index 9d31374..b2d36cd 100644 --- a/crucible_stack/orchestrate/__main__.py +++ b/crucible_stack/orchestrate/__main__.py @@ -37,6 +37,13 @@ promotion, so the sequence is: teach the book `trade_r_since` and a `Reoptimization.baseline`, let one promotion happen, then turn the flag on. +`--arl0-years` sets the CUSUM's false-alarm budget in calendar time, and it is the one +knob worth reaching for. It buys detection latency roughly one for one: halve the budget +and you roughly halve the years spent trading a halved edge before the alarm, at the cost +of more false alarms. On a low-frequency book the default can be slow enough to be +decorative, so the number is worth choosing rather than inheriting. Omit it and crucible's +own default applies, which means a retune there reaches this CLI without a change here. + Exit codes are chosen for cron's benefit: a non-zero status is how an unattended job gets someone's attention. @@ -58,6 +65,7 @@ import sys from datetime import datetime +from crucible_stack.orchestrate.decay import Thresholds from crucible_stack.orchestrate.ledger import DeploymentLedger from crucible_stack.orchestrate.runner import run_cycle from crucible_stack.orchestrate.trigger import ( @@ -70,6 +78,22 @@ EXIT_OK, EXIT_ERROR, EXIT_HALT, EXIT_MISSED = 0, 1, 3, 4 +def _decay_thresholds(args): + """Thresholds for the edge-decay monitor, or None to take crucible's defaults. + + Returning None rather than a fresh `Thresholds()` is deliberate: the default then + comes from whichever crucible is installed, so a retune there reaches this CLI + without a matching change here. Building one eagerly would pin today's numbers. + """ + if args.arl0_years is None: + return None + if not (args.arl0_years > 0): + raise ValueError( + f"--arl0-years must be positive, got {args.arl0_years}. It is a false-alarm " + "budget in calendar time, so zero or negative has no reading.") + return Thresholds(monitor_arl0_years=float(args.arl0_years)) + + def _resolve(spec: str): """Import `pkg.module:attr` without the orchestrator statically depending on it.""" if ":" not in spec: @@ -94,6 +118,12 @@ def build_parser() -> argparse.ArgumentParser: "promotion. Off by default: the trigger fails open, so enabling it " "before a baseline exists re-optimizes on every cycle. Requires the " "book to expose trade_r_since(since, params).") + p.add_argument("--arl0-years", type=float, default=None, + help="false-alarm budget for the edge-decay CUSUM, in YEARS " + "(default: crucible's Thresholds, currently 25). Buys detection " + "latency: halve the budget and you roughly halve the years to " + "notice a halved edge, at the cost of more false alarms. Only " + "meaningful with --edge-decay.") p.add_argument("--breach-level", type=float, default=None, help="quantile counting as a drift breach (default: envelope's lowest)") p.add_argument("--dry-run", action="store_true", @@ -183,6 +213,11 @@ def main(argv=None) -> int: DriftTrigger(breach_level=args.breach_level)] trade_r = () trade_dates = None + if args.arl0_years is not None and not args.edge_decay: + # A tuning flag that tunes nothing is worse than a missing one: it reads as + # applied. Say so rather than accepting it into a run that ignores it. + print("[orchestrate] note: --arl0-years is set but --edge-decay is off, so " + "there is no CUSUM to budget and the value is ignored.", file=sys.stderr) if args.edge_decay: source = getattr(book, "trade_r_since", None) if source is None: @@ -205,7 +240,18 @@ def main(argv=None) -> int: "per-trade expectancy alone.", file=sys.stderr) else: trade_dates = dated(since, params) - triggers.append(EdgeDecayTrigger()) + # A budget in years is converted using the BASELINE's own firing rate, so a + # baseline that never learned one silently falls back to the trade-count + # budget and the flag does nothing. That is exactly the ambiguity the years + # unit exists to remove, so it is worth a line rather than a shrug. + frozen = incumbent.baseline if incumbent is not None else None + if (args.arl0_years is not None and frozen is not None + and frozen.trades_per_year is None): + print("[orchestrate] note: --arl0-years cannot be applied; the frozen " + "baseline carries no firing rate, so the budget falls back to " + "Thresholds.monitor_arl0_trades. Freeze a baseline from a dated " + "log to use a calendar-time budget.", file=sys.stderr) + triggers.append(EdgeDecayTrigger(thresholds=_decay_thresholds(args))) result = run_cycle( book=args.book, diff --git a/tests/test_runner.py b/tests/test_runner.py index e09252f..c776e72 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -443,3 +443,87 @@ def test_the_two_r_series_come_from_different_methods(tmp_path): b = _BookWithTrades() assert b.realized_r_since(None, {"p": 1}).size == 3 # periods assert b.trade_r_since(None, {"p": 1}).size == 400 # trades + + +# --- the false-alarm budget, exposed on the CLI ------------------------------------- + +def test_arl0_years_defaults_to_none_so_cruciblees_own_default_applies(): + """None rather than a materialized Thresholds(). Building one eagerly would pin + today's numbers here, so a retune in crucible would stop reaching this CLI.""" + from crucible_stack.orchestrate.__main__ import _decay_thresholds, build_parser + args = build_parser().parse_args(["--book", "b", "--ledger", "x"]) + assert args.arl0_years is None + assert _decay_thresholds(args) is None + + +def test_arl0_years_builds_a_thresholds_carrying_only_that_change(): + from crucible_stack.orchestrate.__main__ import _decay_thresholds, build_parser + from crucible_stack.orchestrate.decay import Thresholds + args = build_parser().parse_args( + ["--book", "b", "--ledger", "x", "--edge-decay", "--arl0-years", "10"]) + t = _decay_thresholds(args) + assert t.monitor_arl0_years == 10.0 + # every other knob is left where crucible put it + d = Thresholds() + for f in ("monitor_detect_shift", "monitor_window", "monitor_slip_ratio", + "monitor_min_frequency_ratio", "monitor_arl0_trades"): + assert getattr(t, f) == getattr(d, f) + + +def test_a_tighter_budget_buys_detection_latency(): + """The whole reason the knob is worth exposing. Halving the budget roughly halves + the trades spent at a halved edge before the alarm.""" + from crucible.validation import EdgeBaseline, cusum_design + + from crucible_stack.orchestrate.decay import Thresholds + base = EdgeBaseline(expectancy=0.5, sigma=3.5, n_trades=900, trades_per_year=23.0) + slow = cusum_design(base, thresholds=Thresholds(monitor_arl0_years=25)) + fast = cusum_design(base, thresholds=Thresholds(monitor_arl0_years=10)) + assert fast.arl1 < slow.arl1 # detects sooner + assert fast.arl0 < slow.arl0 # and cries wolf more often + assert fast.h_std < slow.h_std + + +def test_a_non_positive_budget_is_refused(): + """It is a span of calendar time. Zero or negative has no reading, and argparse + would otherwise hand it straight to the solver.""" + import pytest + + from crucible_stack.orchestrate.__main__ import _decay_thresholds, build_parser + for bad in ("0", "-5"): + args = build_parser().parse_args( + ["--book", "b", "--ledger", "x", "--edge-decay", "--arl0-years", bad]) + with pytest.raises(ValueError, match="must be positive"): + _decay_thresholds(args) + + +def test_the_budget_without_the_monitor_says_so_rather_than_being_ignored(tmp_path, capsys): + """A tuning flag that tunes nothing is worse than a missing one: it reads as applied.""" + from crucible_stack.orchestrate.__main__ import main + main(["--book", "book_a", "--ledger", str(tmp_path / "l.jsonl"), + "--book-factory", "tests.test_runner:build_with_trades", + "--arl0-years", "10", "--dry-run"]) + err = capsys.readouterr().err + assert "--arl0-years is set but --edge-decay is off" in err + + +def test_a_years_budget_on_an_undated_baseline_warns_that_it_cannot_apply(tmp_path, capsys): + """Years are converted using the BASELINE's own firing rate. Without one crucible + falls back to the trade-count budget, so the flag silently does nothing.""" + from datetime import datetime + + from crucible.validation import EdgeBaseline + + from crucible_stack.orchestrate.__main__ import main + from crucible_stack.orchestrate.ledger import DeploymentEntry, DeploymentLedger + + path = str(tmp_path / "l.jsonl") + DeploymentLedger(path).record(DeploymentEntry( + book="book_a", timestamp=datetime(2026, 1, 1), action="promote", + trigger="schedule", params={"p": 1}, verdict="PASS", trustworthy=True, + baseline=EdgeBaseline(expectancy=0.5, sigma=1.0, n_trades=500))) # no rate + + main(["--book", "book_a", "--ledger", path, + "--book-factory", "tests.test_runner:build_with_trades", + "--edge-decay", "--arl0-years", "10", "--dry-run"]) + assert "--arl0-years cannot be applied" in capsys.readouterr().err