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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions crucible_stack/orchestrate/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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,
)
Expand Down
11 changes: 10 additions & 1 deletion crucible_stack/orchestrate/decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())


Expand Down
2 changes: 2 additions & 0 deletions crucible_stack/orchestrate/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion crucible_stack/orchestrate/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
81 changes: 79 additions & 2 deletions tests/test_decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading