Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`),
Expand Down
48 changes: 47 additions & 1 deletion crucible_stack/orchestrate/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 (
Expand All @@ -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:
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading