edgekit.monitor
Live monitoring — the rules you set beforeyou go live, so that the decision to stop trading is made by yesterday’s calm self instead of tonight’s drawn-down one. CUSUM change detection, an expanding probabilistic Sharpe, backtest-vs-live reconciliation, and a KillSwitch that composes them into one yes/no.
What's inside. cusum watches the live return stream and alarms when its mean has credibly dropped; psr_monitor tracks the expanding probability that the live Sharpe is really above your benchmark; reconcile tests whether live returns are statistically the same animal as the backtest; and KillSwitch bundles drawdown, PSR and CUSUM limits into a single .check() you run after every session.
Every threshold below — h, psr_floor, max_dd— is a decision about when you stop, and the only time you can make it honestly is before you have a P&L to rationalise. Mid-drawdown, every trader discovers a reason the rule does not apply this time. Freeze a KillSwitchconfig alongside the strategy, and treat “kill” as an action, not the opening bid of a negotiation.
Change detection#
cusum#
A one-sided lower CUSUM on standardised returns — the sequential test for “has the mean dropped?”. Returns are z-scored, and the statistic accumulates evidence of underperformance: S_t = max(0, S_(t-1) + (-z_t - k)). The drift k is the dead-band (deviations smaller than k sigmas are ignored), and an alarm fires when S_t > h. Because the statistic resets at zero while the edge is intact, it reacts to a genuine break in a way a rolling Sharpe — which drags a window of stale history — cannot.
cusum(r, k: float = 0.5, h: float = 4.0) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
r | Series or array | — | Live per-period returns (or trade R's). |
k | float | 0.5 | Drift allowance in sigmas — the slack before evidence accrues. |
h | float | 4.0 | Alarm threshold; higher = fewer, later, surer alarms. |
Returns: a dict with "stat" (the CUSUM statistic as a pd.Series) and "alarms" (the list of index labels where it crossed h).
import edgekit as ek
out = ek.monitor.cusum(live_r, k=0.5, h=4.0)
if out["alarms"]:
print("mean shift detected at", out["alarms"][0])
Performance tracking#
psr_monitor#
The expanding probabilistic Sharpe ratio: after each new observation, the probability that the true Sharpe exceeds sr_benchmark, with the skew/kurtosis correction of Bailey & López de Prado. Early values are noisy by nature (hence min_obs); what matters is the trend. A healthy strategy’s PSR grinds upward as evidence accumulates; a dead one’s decays toward zero — smoothly, and usually earlier than the equity curve makes it obvious.
psr_monitor(r, sr_benchmark: float = 0.0, min_obs: int = 20) -> pd.Series| Param | Type | Default | Meaning |
|---|---|---|---|
r | Series or array | — | Live per-period returns. |
sr_benchmark | float | 0.0 | The Sharpe you must beat (0 = “any edge at all”). |
min_obs | int | 20 | Observations before the first value (earlier is NaN). |
Returns: a pd.Series in [0, 1]— the expanding P(true Sharpe > benchmark), indexed like the input.
psr = ek.monitor.psr_monitor(live_r, sr_benchmark=0.0)
psr.iloc[-1] # today's confidence the edge is real
psr.diff().tail(21) # a month of steady decay is a warning before any threshold
reconcile#
The backtest-vs-live gap, tested properly: a Welch two-sample t-test (unequal variances) on the mean return of the backtest stream against the live stream. Live trading always underperforms the backtest a little — slippage, latency, the odd missed fill. This tells you whether the gap is that, or something structural: a p-value below 0.05 with a negative mean_gap means live is not the strategy you simulated, and the difference is bigger than costs explain.
reconcile(backtest_r, live_r) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
backtest_r | Series or array | — | Backtest per-period returns (or R's). |
live_r | Series or array | — | Live per-period returns, same units. |
Returns: a dict with "mean_gap" (live mean minus backtest mean), "t_stat" and "p_value".
rec = ek.monitor.reconcile(bt_daily_r, live_daily_r)
print(f"gap {rec['mean_gap']:+.4f}/day t {rec['t_stat']:+.2f} p {rec['p_value']:.3f}")
# p < 0.05 with a negative gap: stop and find the leak (costs? fills? regime?)The kill switch#
KillSwitch#
The three monitors composed into one pre-committed decision rule. Configure it once, run .check(live_r) after every session, and act on kill. It trips on any of: drawdown beyond max_dd, expanding PSR below psr_floor, or a CUSUM alarm at (cusum_k, cusum_h). The reasons list tells you which tripwire fired — a drawdown breach with healthy PSR reads very differently from all three at once.
@dataclass
class KillSwitch:
max_dd: float = 0.10 # maximum tolerated drawdown (fraction)
psr_floor: float = 0.05 # minimum expanding PSR
cusum_h: float = 5.0 # CUSUM alarm threshold
cusum_k: float = 0.5 # CUSUM drift allowance
def check(self, live_r) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
max_dd | float | 0.10 | Kill if the live equity drawdown exceeds this fraction. |
psr_floor | float | 0.05 | Kill if expanding PSR drops below this. |
cusum_h | float | 5.0 | Kill on a CUSUM alarm at this threshold. |
cusum_k | float | 0.5 | Drift allowance passed to the CUSUM. |
Returns (from .check): a dict with "kill" (bool), "reasons" (which limits tripped), and the current "dd" and "psr" readings.
ks = ek.monitor.KillSwitch(max_dd=0.10, psr_floor=0.05) # frozen BEFORE go-live
# end of every session:
verdict = ks.check(live_r)
if verdict["kill"]:
flatten_all_positions()
print("KILLED:", verdict["reasons"], f"dd={verdict['dd']:.1%} psr={verdict['psr']:.2f}")Tripping the switch means the strategy stops trading — it does not mean the idea is disproven. Take the stream back offline, reconcile live against backtest, re-run the gauntlet on data that now includes the bad stretch, and only relaunch if it still passes. Restarting because the last week looked better is how kill switches become decoration.
See also#
- Live monitoring — the chapter behind this module.
- Backtest to live — the transition these tools police.
- edgekit.metrics —
probabilistic_sharpe, the single-shot version of the PSR. - edgekit.validation — the pre-live counterpart: prove it before you monitor it.

