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