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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ breaking change are governed by [docs/api-stability.md](docs/api-stability.md).
## [Unreleased]

### Added
- **`crucible_stack.orchestrate.decay` and `EdgeDecayTrigger`**: the parameter-space
counterpart to `drift`. `drift` watches the equity **path** (cumulative R and drawdown
against the frozen envelope); this watches the per-trade **parameter** (expectancy, and
how often the signal fires). They are complements and come apart in both directions: a
halved expectancy leaves the path flat and usually inside a p5 band provisioned over a
multi-year horizon, and a run of correlated losers breaches the drawdown floor with
every per-trade statistic intact. Running only one leaves a real failure invisible.

The judging is crucible's (`crucible.validation.monitor`). What this contributes is the
three things crucible refuses to own: freezing an `EdgeBaseline` at the moment of
promotion, persisting it in the `DeploymentLedger` beside the `DriftEnvelope`, and
turning a verdict into a re-optimization trigger. `DeploymentEntry` gains a `baseline`
field that round-trips through JSON, and a refusal carries none, matching the envelope
rule so there is never a reference a later cycle could re-baseline onto.

**Only DEGRADED fires.** crucible reserves that label for the CUSUM, the one channel
with a stated false-alarm rate. SLIPPING is reported in `reasons` and does not trigger.
Promoting an uncalibrated tripwire to a re-optimization trigger would undo the reason
crucible separates them, and would tax the honest N besides: every re-optimization is
variants added to the `SearchSpaceLog`.

`TriggerContext` gains `trade_r` (per-TRADE R since promotion) as a field separate from
`realized_r` (PERIODIC R, the grid the envelope was built on). Two different series with
two different lengths and two different clocks (`trades_live` vs `elapsed`); conflating
them is the units bug crucible fixed in v0.4.0. `run_cycle` gains an optional `trade_r`
argument, defaulting to empty, so existing callers are unaffected.

`trigger` depends on `decay.check_decay` rather than on crucible directly, exactly as it
depends on `drift.check_drift`, which keeps the trigger seam's import surface to
crucible_stack + numpy as `test_triggers_know_nothing_about_the_substrate` pins.

- **`tests/test_no_findings_in_prose.py`**, a guard on the writing rather than the code.
The boundary guard reads the syntax tree, so it cannot see a docstring or a markdown
file, and every leak found while extracting this framework came through prose. This
Expand All @@ -29,6 +60,14 @@ breaking change are governed by [docs/api-stability.md](docs/api-stability.md).
`_max_drawdown` remains as a deprecated alias.

### Changed
- **The `crucible` floor is raised to `>=0.5.0`** for the monitor API above. Unlike the
0.3.0 constraint, this one is **not yet true**: the monitor is in crucible's
`[Unreleased]` and the newest published crucible is 0.4.0. This must not be released
before crucible 0.5.0 is on PyPI, or `import crucible_stack.orchestrate` raises for
anyone installing from PyPI. `tests/test_crucible_compat.py` gains two checks that name
the cause, including one asserting `edge_monitor` still has no parameter from which a
baseline could be rebuilt, since a future crucible relaxing that would silently make
`EdgeDecayTrigger` incapable of firing while every other test kept passing.
- `crucible>=0.3.0` (was `>=0.2.0`). The honest-N API this package depends on landed after
crucible's v0.2.0 tag, so the old constraint was satisfiable by a version that could not
actually satisfy it. The CI workaround that installed crucible from git is gone.
9 changes: 8 additions & 1 deletion crucible_stack/orchestrate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
monthly_r,
provision_envelope,
)
from crucible_stack.orchestrate.decay import (
baseline_from_dict,
baseline_to_dict,
check_decay,
)
from crucible_stack.orchestrate.drift import (
DriftEnvelope,
DriftVerdict,
Expand All @@ -37,6 +42,7 @@
)
from crucible_stack.orchestrate.trigger import (
DriftTrigger,
EdgeDecayTrigger,
ScheduleTrigger,
Trigger,
TriggerContext,
Expand All @@ -49,6 +55,7 @@
"envelope_from_r",
"provision_envelope", "check_account_drift", "monthly_r", "in_currency",
"Trigger", "TriggerContext", "TriggerDecision", "ScheduleTrigger",
"DriftTrigger", "any_of",
"DriftTrigger", "EdgeDecayTrigger", "any_of",
"check_decay", "baseline_to_dict", "baseline_from_dict",
"DeploymentEntry", "DeploymentLedger", "ACTIONS",
"run_cycle", "CycleResult", "Reoptimization", "missed_windows"]
90 changes: 90 additions & 0 deletions crucible_stack/orchestrate/decay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""decay - has the promoted edge itself weakened, rather than the path wandered?

The second half of the monitoring story. `drift` watches the equity **path**: realized
cumulative R against the block-bootstrap envelope frozen at promotion. This watches the
per-trade **parameter**: expectancy, and how often the signal fires at all.

They are complements, not duplicates, and they come apart in both directions. A book
whose expectancy halves while its trade count holds produces a path that goes flat, and
flat usually sits inside a p5 band provisioned over a multi-year horizon. Conversely a
run of correlated losers breaches the drawdown floor with every per-trade statistic
exactly where it should be. Running only one of the two leaves a real failure invisible.

The judging is crucible's (`crucible.validation.monitor`); this module contributes state,
time and control flow, the same division of labour as `drift`. Specifically it contributes
the three things crucible refuses to own:

* **freezing** an `EdgeBaseline` at the moment of promotion, and persisting it in the
ledger beside the `DriftEnvelope`
* **reading** it back on a later cycle without any means of rebuilding it
* turning a verdict into a **re-optimization trigger**

**Only DEGRADED fires.** crucible's monitor returns HOLDING / SLIPPING / DEGRADED, and
only the CUSUM (which carries a stated false-alarm rate) can produce DEGRADED. The soft
channels cap at SLIPPING and are reported here without firing. Letting SLIPPING trigger a
re-optimization would import an uncalibrated tripwire into the loop and undo the whole
reason crucible separates them: a trailing window on a book whose edge never decayed
still crosses the soft line often enough to matter.

**Per-trade R, not periodic R.** `TriggerContext.realized_r` is periodic (the grid the
drift envelope was built on). The monitor consumes per-trade R, which is a different
series with a different length, so it rides on its own field. Denominating one in the
other is the units bug crucible fixed in v0.4.0 and is not worth repeating.
"""
from __future__ import annotations

from dataclasses import asdict
from typing import Any, Mapping, Optional, Sequence

from crucible.edge import TradeLog
from crucible.validation import EdgeBaseline, MonitorVerdict, Thresholds, edge_monitor

# Re-exported so `trigger` depends on this adapter rather than on crucible directly,
# exactly as it depends on `drift.check_drift` rather than reimplementing the band. That
# keeps the trigger seam's import surface to crucible_stack + numpy, which
# `test_triggers_know_nothing_about_the_substrate` pins.
__all__ = ["check_decay", "baseline_to_dict", "baseline_from_dict",
"EdgeBaseline", "MonitorVerdict", "Thresholds"]


def check_decay(baseline: EdgeBaseline, trade_r: Sequence[float], *,
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`.
"""
return edge_monitor(TradeLog.from_arrays(trade_r), baseline,
thresholds=thresholds or Thresholds())


def baseline_to_dict(baseline: EdgeBaseline) -> dict:
"""JSON-safe, lossless rendering of a frozen baseline. Pure conversion, no I/O.

`EdgeBaseline` is a plain frozen dataclass of scalars in the layer below, and crucible
deliberately persists nothing, so the round-trip lives here alongside the ledger that
needs it (same arrangement as `DriftEnvelope.to_dict`).
"""
d = asdict(baseline)
return {k: (None if v is None else v) for k, v in d.items()}


def baseline_from_dict(d: Optional[Mapping[str, Any]]) -> Optional[EdgeBaseline]:
"""Rebuild from `baseline_to_dict`. Round-trips exactly; see the ledger's test.

Reconstruction runs `EdgeBaseline.__post_init__`, so a corrupted or hand-edited entry
(a non-positive expectancy, say) raises here rather than silently producing a monitor
that can never fire.
"""
if not d:
return None
return EdgeBaseline(
expectancy=float(d["expectancy"]),
sigma=float(d["sigma"]),
n_trades=int(d["n_trades"]),
trades_per_year=None if d.get("trades_per_year") is None
else float(d["trades_per_year"]),
n_variants=None if d.get("n_variants") is None else int(d["n_variants"]),
deflated=bool(d.get("deflated", False)),
)
8 changes: 8 additions & 0 deletions crucible_stack/orchestrate/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
import numpy as np
import pandas as pd

from crucible_stack.orchestrate.decay import (
EdgeBaseline,
baseline_from_dict,
baseline_to_dict,
)
from crucible_stack.orchestrate.drift import DriftEnvelope

try:
Expand Down Expand Up @@ -90,6 +95,7 @@ class DeploymentEntry:
honest_n: int = 0
fit_window: Optional[Tuple[pd.Timestamp, pd.Timestamp]] = None
envelope: Optional[DriftEnvelope] = None
baseline: Optional[EdgeBaseline] = None # frozen at promotion, for EdgeDecayTrigger
equity_ref: Optional[str] = None # points at the EquityResult; never a copy of it

def __post_init__(self) -> None:
Expand Down Expand Up @@ -121,6 +127,7 @@ def to_json(self) -> str:
"fit_window": [pd.Timestamp(fw[0]).isoformat(),
pd.Timestamp(fw[1]).isoformat()] if fw else None,
"envelope": self.envelope.to_dict() if self.envelope is not None else None,
"baseline": baseline_to_dict(self.baseline) if self.baseline is not None else None,
"equity_ref": self.equity_ref,
}, sort_keys=True)

Expand All @@ -136,6 +143,7 @@ def from_json(cls, line: str) -> "DeploymentEntry":
reasons=tuple(d.get("reasons") or ()), honest_n=int(d.get("honest_n", 0)),
fit_window=(pd.Timestamp(fw[0]), pd.Timestamp(fw[1])) if fw else None,
envelope=DriftEnvelope.from_dict(env) if env else None,
baseline=baseline_from_dict(d.get("baseline")),
equity_ref=d.get("equity_ref"),
)

Expand Down
12 changes: 10 additions & 2 deletions crucible_stack/orchestrate/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import pandas as pd

from crucible_stack.optimize import Selection
from crucible_stack.orchestrate.decay import EdgeBaseline
from crucible_stack.orchestrate.drift import DriftEnvelope
from crucible_stack.orchestrate.gate import GateDecision, evaluate
from crucible_stack.orchestrate.ledger import DeploymentEntry, DeploymentLedger
Expand All @@ -45,12 +46,15 @@
class Reoptimization:
"""What a re-optimization hands back: the verdict plus the provenance to record.

`envelope` is what the book WOULD be provisioned with if this candidate is promoted.
It is only ever written to the ledger on a promotion.
`envelope` and `baseline` are what the book WOULD be provisioned with if this
candidate is promoted: the path-space band and the parameter-space reference. Both are
only ever written to the ledger on a promotion, and both are frozen at that instant,
which is the property the monitors depend on.
"""
selection: Selection
fit_window: Optional[Tuple[pd.Timestamp, pd.Timestamp]] = None
envelope: Optional[DriftEnvelope] = None
baseline: Optional[EdgeBaseline] = None
equity_ref: Optional[str] = None


Expand Down Expand Up @@ -103,6 +107,7 @@ def run_cycle(
realized_r: Sequence[float],
now: datetime,
cadence: Optional[int] = None,
trade_r: Optional[Sequence[float]] = None,
) -> CycleResult:
"""Run one turn of the loop for one book.

Expand All @@ -118,6 +123,8 @@ def run_cycle(
realized_r=r,
envelope=incumbent.envelope if incumbent is not None else None,
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,
)
tdec = trigger(ctx)
missed = missed_windows(ctx.elapsed, cadence)
Expand Down Expand Up @@ -147,6 +154,7 @@ def run_cycle(
honest_n=int(getattr(sel, "honest_n", 0) or 0),
fit_window=reopt.fit_window,
envelope=reopt.envelope if promoting else None, # refusals provision nothing
baseline=reopt.baseline if promoting else None,
equity_ref=reopt.equity_ref if promoting else None,
)
ledger.record(entry)
Expand Down
78 changes: 72 additions & 6 deletions crucible_stack/orchestrate/trigger.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""trigger — when should the loop re-optimize?

ADR-0003 commitment 2. The cadence is a *policy*, not a hardcoded rule, and this module is
the seam that keeps it one. Two honest triggers compose:
the seam that keeps it one. Three honest triggers compose:

* **schedule** — Pardo's baseline: re-optimize every N periods as the out-of-sample
window rolls forward.
* **drift** — the early interrupt: re-optimize as soon as the live book leaves the
envelope it was provisioned with (`crucible_stack.orchestrate.drift`).
* **drift** — the early interrupt on the equity PATH: re-optimize as soon as the live
book leaves the envelope it was provisioned with (`crucible_stack.orchestrate.drift`).
* **edge_decay** — the early interrupt on the per-trade PARAMETER: re-optimize when the
edge itself weakens against the baseline frozen at promotion
(`crucible_stack.orchestrate.decay`). Complements drift rather than duplicating it; a
halved expectancy can leave the path flat and inside a wide band, and a cluster of
correlated losers can breach the band with every per-trade statistic intact.

`any_of(schedule, drift)` is the recommended hybrid: a scheduled floor with drift able to
pull the re-optimization forward.
`any_of(schedule, drift, edge_decay)` is the recommended hybrid: a scheduled floor with
either monitor able to pull the re-optimization forward.

**This is also the substrate seam.** Nothing here knows what invokes it — cron today, a
workflow engine later (ADR-0003, Option A vs B). A trigger reads a `TriggerContext` and
Expand All @@ -32,10 +37,11 @@

import numpy as np

from crucible_stack.orchestrate.decay import EdgeBaseline, Thresholds, check_decay
from crucible_stack.orchestrate.drift import DriftEnvelope, check_drift

__all__ = ["TriggerContext", "TriggerDecision", "Trigger", "ScheduleTrigger",
"DriftTrigger", "any_of"]
"DriftTrigger", "EdgeDecayTrigger", "any_of"]


@dataclass(frozen=True)
Expand All @@ -50,18 +56,33 @@ class TriggerContext:
realized_r: np.ndarray = field(default_factory=lambda: np.zeros(0))
envelope: Optional[DriftEnvelope] = None
has_incumbent: bool = True
# Per-TRADE R since the incumbent went live, and the baseline frozen at promotion.
# Separate from `realized_r` because that one is PERIODIC (the grid the envelope was
# built on). Two different series with two different lengths; denominating one in the
# 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

def __post_init__(self) -> None:
r = np.asarray(self.realized_r, dtype=float)
if r.ndim != 1:
raise ValueError(f"realized_r must be 1-D, got shape {r.shape}")
object.__setattr__(self, "realized_r", r)
tr = np.asarray(self.trade_r, dtype=float)
if tr.ndim != 1:
raise ValueError(f"trade_r must be 1-D, got shape {tr.shape}")
object.__setattr__(self, "trade_r", tr)

@property
def elapsed(self) -> int:
"""Periods the incumbent has been live."""
return int(self.realized_r.size)

@property
def trades_live(self) -> int:
"""Trades closed since the incumbent went live. Not the same clock as `elapsed`."""
return int(self.trade_r.size)


@dataclass(frozen=True)
class TriggerDecision:
Expand Down Expand Up @@ -154,6 +175,51 @@ def __call__(self, ctx: TriggerContext) -> TriggerDecision:
reasons=tuple(f"{self.name}: {r}" for r in v.reasons))


@dataclass(frozen=True)
class EdgeDecayTrigger:
"""Fire when the promoted edge itself has weakened, not merely when the path wandered.

The parameter-space counterpart to `DriftTrigger`. Reads the frozen `EdgeBaseline` off
the context and never builds one, for the same reason `DriftTrigger` never builds an
envelope: a reference recomputed from current data re-fits onto the drifted reality and
the monitor can never fire. See `crucible_stack.orchestrate.decay`.

**Only DEGRADED fires.** crucible's monitor reserves that label for the CUSUM, the one
channel carrying a stated false-alarm rate. SLIPPING (a trailing-window dip, or a
firing-rate collapse) is reported in `reasons` and does not trigger, because promoting
an uncalibrated tripwire to a re-optimization trigger is exactly the mistake the
monitor's design is shaped to prevent.

With no baseline attached this fires with an explicit reason rather than staying quiet,
matching `DriftTrigger`'s fails-open posture: unmonitored is not the same as healthy.
"""
thresholds: Optional[Thresholds] = None
name: str = "edge_decay"

def __call__(self, ctx: TriggerContext) -> TriggerDecision:
cold = _cold_start(ctx, self.name)
if cold is not None:
return cold
if ctx.baseline is None:
return TriggerDecision(
fired=True, sources=(self.name,),
reasons=(f"{self.name}: no baseline attached, so edge decay cannot be "
"assessed; re-checking rather than trading unmonitored",))
if ctx.trades_live == 0:
return TriggerDecision(
fired=False, sources=(),
reasons=(f"{self.name}: no closed trades yet",))

v = check_decay(ctx.baseline, ctx.trade_r, thresholds=self.thresholds)
fired = v.label == "DEGRADED"
reasons = tuple(f"{self.name}: {r}" for r in v.reasons)
if v.label == "SLIPPING":
reasons += (f"{self.name}: SLIPPING, which does NOT trigger; only the "
"calibrated detector may force a re-optimization",)
return TriggerDecision(fired=fired, sources=(self.name,) if fired else (),
reasons=reasons)


@dataclass(frozen=True)
class any_of:
"""Hybrid: fire if any policy fires, and record every one that did.
Expand Down
Loading
Loading