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():