From a0d7f2b40e376f55f9737f2487e6a6c52ba078aa Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Sun, 2 Aug 2026 21:26:06 -0400 Subject: [PATCH] fix(orchestrate): the firing-rate channel was dead through the seam check_decay built its TradeLog from bare floats, TradeLog.from_arrays(trade_r), while crucible derives the live firing rate from the log's entry_date. So live_trades_per_year and frequency_ratio came back None on every cycle, for every book, however carefully the baseline's own rate had been frozen at promotion. Two of the monitor's three channels ran. The third reported itself off, in a reason line that read as a fact about the book ("live log carries no entry_date") when it was a fact about the seam. npf had just gone to some trouble to compute a trades_per_year explicitly and freeze it into every baseline, and nothing could ever consume it. It is the channel that matters most on its own, because neither of the others can see the failure it covers: a signal that stops firing while the trades it still takes keep their per-trade edge. Expectancy is unchanged, so the CUSUM stays quiet and the rolling window reads full size, and annual R falls anyway because the opportunity set shrank. check_decay now takes trade_dates, TriggerContext carries them, run_cycle forwards them, EdgeDecayTrigger passes them through, and the CLI sources them from an optional trade_dates_since(since, params) on the book. Three choices worth recording: * TriggerContext refuses a trade_dates/trade_r length mismatch. Misaligned dates do not fail downstream, they date the wrong trades and yield a rate that is wrong in a direction nothing else in the verdict would reveal. * The dates are optional where trade_r is not, and their absence warns rather than refusing. A book that cannot date its trades is still worth monitoring on expectancy. What is not acceptable is the previous state, where supplying R alone turned the channel off with no way to tell that apart from a book that genuinely has no dates. * No crucible change was needed. TradeLog.from_arrays has accepted entry_date all along, so this was the seam throwing the dates away rather than a gap in the judge. One test premise of mine was wrong and is recorded rather than quietly fixed: I assumed a live book at exactly baseline expectancy reads HOLDING, so the frequency test could isolate its channel. At n=300, sigma=1.0, the 200-trade rolling window has SE 0.071 against a 0.10 soft line, so it dips below on roughly 1 seed in 7 from noise alone (2 of the first 12). The test now uses a seed sitting at 192% of baseline, and the isolation is a property of the fixture rather than an assumption. 399 tests pass, ruff clean. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 21 +++++++ crucible_stack/orchestrate/__main__.py | 28 +++++++-- crucible_stack/orchestrate/decay.py | 11 +++- crucible_stack/orchestrate/runner.py | 2 + crucible_stack/orchestrate/trigger.py | 20 ++++++- tests/test_decay.py | 81 +++++++++++++++++++++++++- 6 files changed, 155 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7dc7a..b75064a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ breaking change are governed by [docs/api-stability.md](docs/api-stability.md). ## [Unreleased] +### 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)`), + while crucible derives the live firing rate from the log's `entry_date`. So + `live_trades_per_year` and `frequency_ratio` came back `None` on every cycle, for every + book, however carefully the baseline's own rate had been frozen at promotion. Two of the + monitor's three channels ran; the third reported itself off and nothing said why. + + It is the channel that matters most on its own, because neither of the others can see + the failure it covers: a signal that stops firing while the trades it still takes keep + their per-trade edge. Expectancy is unchanged, so the CUSUM stays quiet and the rolling + window reads full size, and annual R falls anyway because the opportunity set shrank. + + `check_decay` now takes `trade_dates`, `TriggerContext` carries them (refusing a length + mismatch against `trade_r`, which would mis-date the rate rather than fail), `run_cycle` + forwards them, and `EdgeDecayTrigger` passes them through. The CLI sources them from an + optional `trade_dates_since(since, params)` on the book. Absence stays legal and is now + *reported* rather than silent, since a book that cannot date its trades is still worth + monitoring on expectancy. No crucible change was needed: `TradeLog.from_arrays` has + accepted `entry_date` all along. + ### Added - **`crucible_stack.orchestrate.decay` and `EdgeDecayTrigger`**: the parameter-space counterpart to `drift`. `drift` watches the equity **path** (cumulative R and drawdown diff --git a/crucible_stack/orchestrate/__main__.py b/crucible_stack/orchestrate/__main__.py index 1c0d972..9d31374 100644 --- a/crucible_stack/orchestrate/__main__.py +++ b/crucible_stack/orchestrate/__main__.py @@ -12,6 +12,7 @@ .reoptimize() -> Reoptimization .realized_r_since(since, params) -> periodic R since the incumbent went live .trade_r_since(since, params) -> per-TRADE R since then [only with --edge-decay] + .trade_dates_since(since, params)-> entry dates for those trades [optional] `since` and `params` come off the ledger's current entry, not from the book. The book cannot know when its parameters were promoted, and a realized series measured over the wrong window @@ -23,6 +24,13 @@ is not aggregated at all. A book typically produces both from the same trades, resampling for the first and not for the second. +`trade_dates_since` is optional where the other two are not, and its absence is reported +rather than fatal: it feeds only the firing-rate channel, which compares how often the +signal fires now against how often it fired in the baseline. A book that cannot date its +trades is still worth monitoring on expectancy. Supplying R without dates, though, leaves +that channel silently off, and it is the one that catches a signal that has stopped firing +while the trades it still takes look as good as ever. + `--edge-decay` is **opt-in and off by default**, deliberately. `EdgeDecayTrigger` fails open, so switching it on for a book that has no frozen baseline yet makes every cycle fire and re-optimize, which taxes the honest N for no information. A baseline is only written on a @@ -174,6 +182,7 @@ def main(argv=None) -> int: triggers = [ScheduleTrigger(cadence=args.cadence), DriftTrigger(breach_level=args.breach_level)] trade_r = () + trade_dates = None if args.edge_decay: source = getattr(book, "trade_r_since", None) if source is None: @@ -182,10 +191,20 @@ def main(argv=None) -> int: "Refusing rather than monitoring nothing.", file=sys.stderr) return EXIT_ERROR # same window as the periodic series, off the ledger for the same reason - trade_r = source( - incumbent.timestamp if incumbent is not None else None, - incumbent.params if incumbent is not None else None, - ) + since = incumbent.timestamp if incumbent is not None else None + params = incumbent.params if incumbent is not None else None + trade_r = source(since, params) + # Optional, unlike trade_r: without dates the firing-rate channel is off and + # the verdict says so. A book that cannot date its trades is still worth + # monitoring on expectancy, so this warns rather than refusing. + dated = getattr(book, "trade_dates_since", None) + if dated is None: + print("[orchestrate] note: " + f"{type(book).__name__} exposes no trade_dates_since(since, params), " + "so the firing-rate channel is off; edge decay is judged on " + "per-trade expectancy alone.", file=sys.stderr) + else: + trade_dates = dated(since, params) triggers.append(EdgeDecayTrigger()) result = run_cycle( @@ -195,6 +214,7 @@ def main(argv=None) -> int: reoptimize=book.reoptimize, realized_r=realized, trade_r=trade_r, + trade_dates=trade_dates, now=datetime.now(), # the ONLY clock read in the system cadence=args.cadence, ) diff --git a/crucible_stack/orchestrate/decay.py b/crucible_stack/orchestrate/decay.py index 2127bfb..bf21d9f 100644 --- a/crucible_stack/orchestrate/decay.py +++ b/crucible_stack/orchestrate/decay.py @@ -48,14 +48,23 @@ def check_decay(baseline: EdgeBaseline, trade_r: Sequence[float], *, + trade_dates: Optional[Sequence[Any]] = None, thresholds: Optional[Thresholds] = None) -> MonitorVerdict: """Judge a frozen baseline against per-TRADE R since promotion. The judging is crucible's; what this adds is the seam. `trade_r` is per-trade, not the periodic series `check_drift` consumes. Deliberately has no parameter from which a baseline could be rebuilt, matching `check_drift`. + + `trade_dates` are the live trades' entry dates, parallel to `trade_r`. They feed one + channel only: crucible derives the live firing rate from them and compares it against + `baseline.trades_per_year`. Passing R alone leaves that channel permanently off, which + is not a neutral default, because it is the channel that catches a signal quietly + ceasing to fire while per-trade expectancy still reads full size. Omitting them stays + legal (a book that cannot date its trades is honestly reported as having the channel + off) but that should be a fact about the book rather than an accident of the seam. """ - return edge_monitor(TradeLog.from_arrays(trade_r), baseline, + return edge_monitor(TradeLog.from_arrays(trade_r, entry_date=trade_dates), baseline, thresholds=thresholds or Thresholds()) diff --git a/crucible_stack/orchestrate/runner.py b/crucible_stack/orchestrate/runner.py index d79d223..ea0c40e 100644 --- a/crucible_stack/orchestrate/runner.py +++ b/crucible_stack/orchestrate/runner.py @@ -108,6 +108,7 @@ def run_cycle( now: datetime, cadence: Optional[int] = None, trade_r: Optional[Sequence[float]] = None, + trade_dates: Optional[Sequence[object]] = None, ) -> CycleResult: """Run one turn of the loop for one book. @@ -125,6 +126,7 @@ def run_cycle( has_incumbent=incumbent is not None, trade_r=np.asarray(trade_r if trade_r is not None else (), dtype=float), baseline=incumbent.baseline if incumbent is not None else None, + trade_dates=None if trade_dates is None else np.asarray(trade_dates), ) tdec = trigger(ctx) missed = missed_windows(ctx.elapsed, cadence) diff --git a/crucible_stack/orchestrate/trigger.py b/crucible_stack/orchestrate/trigger.py index c9ede41..95c96d4 100644 --- a/crucible_stack/orchestrate/trigger.py +++ b/crucible_stack/orchestrate/trigger.py @@ -62,6 +62,11 @@ class TriggerContext: # other is the units bug crucible fixed in v0.4.0. trade_r: np.ndarray = field(default_factory=lambda: np.zeros(0)) baseline: Optional[EdgeBaseline] = None + # Entry dates for those same trades, parallel to `trade_r`. Optional, and consumed by + # exactly one channel: the live firing rate, compared against the baseline's. Without + # them that channel is off, so a book that has quietly stopped firing reads healthy + # for as long as the few trades it still takes keep their per-trade edge. + trade_dates: Optional[np.ndarray] = None def __post_init__(self) -> None: r = np.asarray(self.realized_r, dtype=float) @@ -72,6 +77,18 @@ def __post_init__(self) -> None: if tr.ndim != 1: raise ValueError(f"trade_r must be 1-D, got shape {tr.shape}") object.__setattr__(self, "trade_r", tr) + if self.trade_dates is not None: + td = np.asarray(self.trade_dates) + if td.ndim != 1: + raise ValueError(f"trade_dates must be 1-D, got shape {td.shape}") + # A length mismatch would not raise downstream, it would date the wrong + # trades and yield a firing rate that is wrong in a direction nothing else + # in the verdict would reveal. Cheaper to refuse here. + if td.size != tr.size: + raise ValueError( + f"trade_dates has {td.size} entries but trade_r has {tr.size}; " + "they describe the same trades and must be parallel") + object.__setattr__(self, "trade_dates", td) @property def elapsed(self) -> int: @@ -210,7 +227,8 @@ def __call__(self, ctx: TriggerContext) -> TriggerDecision: fired=False, sources=(), reasons=(f"{self.name}: no closed trades yet",)) - v = check_decay(ctx.baseline, ctx.trade_r, thresholds=self.thresholds) + v = check_decay(ctx.baseline, ctx.trade_r, trade_dates=ctx.trade_dates, + thresholds=self.thresholds) fired = v.label == "DEGRADED" reasons = tuple(f"{self.name}: {r}" for r in v.reasons) if v.label == "SLIPPING": diff --git a/tests/test_decay.py b/tests/test_decay.py index 8b8a618..a8ccfb0 100644 --- a/tests/test_decay.py +++ b/tests/test_decay.py @@ -140,10 +140,87 @@ def test_trade_r_must_be_one_dimensional(): TriggerContext(trade_r=np.zeros((3, 4))) +# ── the firing-rate channel ───────────────────────────────────────────────────────── + +def _dates(n, *, per_year, start="2024-01-01"): + """n entry dates spaced to fire at `per_year`.""" + step = 365.25 / per_year + return np.array([np.datetime64(start) + np.timedelta64(int(round(i * step)), "D") + for i in range(n)]) + + +def test_without_dates_the_firing_rate_channel_is_off(): + """The regression this block exists for. `check_decay` built its log from bare floats, + so crucible could never derive a live rate: the channel was dead for every book through + the orchestrator, however carefully the baseline's own rate had been frozen.""" + v = check_decay(_baseline(), _r(0.2, n=300, seed=4)) + assert v.live_trades_per_year is None and v.frequency_ratio is None + assert any("no entry_date" in r for r in v.reasons) + + +def test_with_dates_the_channel_reports_a_ratio(): + n = 300 + v = check_decay(_baseline(trades_per_year=150.0), _r(0.2, n=n, seed=4), + trade_dates=_dates(n, per_year=150.0)) + assert v.live_trades_per_year == pytest.approx(150.0, rel=0.02) + assert v.frequency_ratio == pytest.approx(1.0, rel=0.02) + + +def test_a_signal_that_stopped_firing_is_caught_only_by_the_dated_channel(): + """The failure mode the channel exists for: per-trade expectancy is INTACT, so the + CUSUM and the rolling window both read healthy and only the rate has collapsed.""" + n = 300 + r = _r(0.2, n=n, seed=4) # edge exactly at baseline + baseline = _baseline(trades_per_year=150.0) + assert check_decay(baseline, r).label == "HOLDING" # undated: blind + + v = check_decay(baseline, r, trade_dates=_dates(n, per_year=30.0)) + assert v.frequency_ratio == pytest.approx(0.2, rel=0.02) + assert v.label == "SLIPPING" + assert any("fires at" in x for x in v.reasons) + + +def test_a_collapsed_firing_rate_reports_but_does_not_trigger(): + """Uncalibrated, so it caps at SLIPPING. Same rule as the rolling window.""" + n = 300 + ctx = TriggerContext(trade_r=_r(0.2, n=n, seed=4), + baseline=_baseline(trades_per_year=150.0), + trade_dates=_dates(n, per_year=30.0)) + d = EdgeDecayTrigger()(ctx) + assert not d.fired + assert any("does NOT trigger" in x for x in d.reasons) + + +def test_misaligned_dates_are_refused_rather_than_mis_dating_the_rate(): + with pytest.raises(ValueError, match="must be parallel"): + TriggerContext(trade_r=np.zeros(10), trade_dates=_dates(4, per_year=150.0)) + + +def test_trade_dates_must_be_one_dimensional(): + with pytest.raises(ValueError, match="trade_dates must be 1-D"): + TriggerContext(trade_r=np.zeros(4), trade_dates=np.zeros((2, 2))) + + +def test_the_trigger_forwards_the_dates_it_was_given(): + """Guards the wire itself. A trigger that dropped `trade_dates` would leave every + verdict looking exactly like the undated case above, with nothing else to notice.""" + n = 300 + ctx = TriggerContext(trade_r=_r(0.2, n=n, seed=4), + baseline=_baseline(trades_per_year=150.0), + trade_dates=_dates(n, per_year=30.0)) + undated = TriggerContext(trade_r=ctx.trade_r, baseline=ctx.baseline) + assert "ctx.trade_dates" in inspect.getsource(EdgeDecayTrigger) + assert EdgeDecayTrigger()(ctx).reasons != EdgeDecayTrigger()(undated).reasons + + def test_check_decay_cannot_rebuild_a_baseline(): - """Same guard crucible puts on edge_monitor, re-asserted at the seam that persists it.""" + """Same guard crucible puts on edge_monitor, re-asserted at the seam that persists it. + + `trade_dates` describes the LIVE trades, so it cannot reconstruct a reference: it has + no expectancy in it, only when the trades happened. + """ assert set(inspect.signature(check_decay).parameters) == { - "baseline", "trade_r", "thresholds"} + "baseline", "trade_r", "trade_dates", "thresholds"} def test_the_trigger_reads_the_baseline_and_never_builds_one():