Architecture — Signal Filter Funnel¶
Version: 5.0 Date: 2026-04-14 Status: Production (v2 LdP pipeline) — V5 after 4th committee review ADRs: ADR-43, ADR-44, ADR-45, ADR-47, ADR-48, ADR-52 Committee history: - V1: REJECTED (EXECUTION_RISK, 6.2/10) — 10 recommendations - V2: PASSED (METHODOLOGY_FLAW, 7.9/10) — 16 recommendations - V3: PASSED (OK, 8.0/10) — 11 recommendations - V4: PASSED (EXECUTION_RISK, 7.9/10) — 11 recommendations (all "implement now") - V5: Adds slippage model OOS validation plan, market-adaptive starvation roadmap. Design complete — focus shifts to implementation.
1. Purpose¶
The Signal Filter Funnel is the observability backbone of the CVNTrade trading pipeline. It tracks every BUY signal from inception (model inference) through a series of independent filters to final trade execution. Its purposes:
- Observability: Know exactly how many signals survive each stage, and why they die
- Diagnostics: Identify the "Primary Killer" filter and cumulative attrition curve
- Anti-starvation: Detect when too few signals survive (ADR-45), preventing silent strategy failure
- Economic viability: Ensure signals are profitable after market frictions
- Operational safety: Kill-switch, rollback, error isolation per filter
2. Signal Lifecycle¶
A signal travels through the pipeline in strict order. Each stage is a gate: PASS, REJECT, or SKIP.
OHLCV Stream (30m candles)
│
▼
┌───────────────────────┐
│ STAGE 0 │
│ CUSUM PRE-FILTER │ Pre-inference
│ (volatility gate) │
│ │
│ ~95% candles filtered │
│ Only regime changes │
│ pass through │
│ │
│ Metrics: │
│ total_candles │
│ cusum_events (pass) │
│ cusum_filtered (rej) │
│ cusum_pass_rate │
└────────┬──────────────┘
│ cusum_events
▼
┌───────────────────────┐
│ STAGE 1 │
│ ML INFERENCE │ Signal generation
│ (XGBoost predict) │
│ │
│ P(BUY) > threshold │
│ → raw_buy_signal │
│ │
│ Metrics: │
│ raw_buy_signals │
│ prob_buy_mean │
│ action_rate │
└────────┬──────────────┘
│ raw_buy_signals
▼
┌──────────────────────────────────────────────┐
│ FILTER CHAIN EXECUTOR (v2) │
│ ADR-43: dynamic introspection │
│ Short-circuit on first REJECT │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ STAGE 2 │→│ STAGE 3 │→│ STAGE 4 │ │
│ │ TREND │ │ META │ │ REGIME │ │
│ │ EMA │ │ LABEL │ │ VOLAT. │ │
│ │ (opt.) │ │ (opt.) │ │ (opt.) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ Each stage produces FunnelEntry: │
│ {filter_name, status, reason} │
│ status ∈ {PASSED, REJECTED, SKIPPED} │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ STAGE 5 │→│ STAGE 6 │ │
│ │ CONCUR. │ │ COOLDOWN │ │
│ │ (max │ │ (min │ │
│ │ pos.) │ │ interval)│ │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
└───────┴────────────┴────────────────────────┘
│
▼ chain_result.passed = true
┌───────────────────────┐
│ TRADE EXECUTION │ Final stage
│ (open position) │
│ │
│ Cost model applied │
│ (non-linear slippage) │
│ Kelly position sizing │
│ │
│ → final_trades │
└────────────────────────┘
3. Filter Stages — Detailed Specification¶
Stage 0: CUSUM Pre-Filter (Pre-Inference)¶
| Aspect | Detail |
|---|---|
| Plugin | Not in FilterChain — hardcoded in candle loop |
| File | src/backtest/cvntrade_backtest_engine.py:869-948 |
| Purpose | Detect structural breaks in volatility. Only process candles near regime transitions. |
| Input | OHLCV returns, calibrated sigma from training cache |
| Algorithm | S+[t] = max(0, S+[t-1] + (r[t-1] - μ - k)), alert if S+ > h or S- < -h |
| Anti-look-ahead | Uses return[T-1] for decision at T |
| Config | CVN_USE_CUSUM_FILTER=1, CVN_CUSUM_THRESHOLD_H=3.0, CVN_CUSUM_ALLOWANCE_K=0.5 |
| Warmup | 100 bars (all marked transition) |
| Cooldown | 10 bars after each detection |
| Sigma | Calibrated on training cache, NEVER re-fit on test data (ADR) |
| Typical rate | ~95% filtered (only ~5% pass) |
| FunnelEntry | Formalized as Stage 0: {filter_name: "cusum", status: PASSED/REJECTED} |
| Error handling | If CUSUM fails → candle PASSES (fail-open for safety) |
Observability (unified with main funnel):
- cusum_passed: candles that triggered CUSUM event
- cusum_rejected: candles filtered by CUSUM
- cusum_pass_rate: cusum_passed / total_candles
- cusum_block_rate: 1 - cusum_pass_rate
Calibration: Threshold h must be calibrated OOS per ADR-15. Current h=3.0σ is a baseline — FTF factor cusum_threshold ablates h ∈ {2.0, 3.0, 5.0}.
Stage 1: ML Inference (Signal Generation)¶
| Aspect | Detail |
|---|---|
| File | src/backtest/cvntrade_backtest_engine.py:1009-1053 |
| Purpose | Generate BUY/HOLD/SELL signal from model probabilities |
| Input | Feature vector from enrichment + feature engineering pipeline |
| Gate logic | P(BUY) >= threshold_buy → BUY signal |
| Config | CVN_THRESHOLD_BUY (calibrated per model via walk-forward, ADR-15) |
| Metrics | raw_buy_signals, prob_buy_mean, action_rate |
| Calibration | threshold_buy co-optimized by HPO, refined by walk-forward OOS |
| Error handling | If inference fails → signal = HOLD (fail-safe) |
| ADR | ADR-28 (binary classification assumed), ADR-15 (theta calibrated OOS) |
Stage 2: Trend EMA Filter (Post-Inference Chain)¶
| Aspect | Detail |
|---|---|
| Plugin | TrendEmaPlugin — name: trend |
| File | src/commun/filters/plugins/trend_ema_plugin.py |
| Purpose | Only allow BUY signals when short-term trend is bullish (EMA cross) |
| Input contract | Requires FilterContext.ohlcv_window (DataFrame with OHLC columns) |
| Gate logic | EMA_fast > EMA_slow → PASS, else REJECT(reason="bearish trend") |
| Config | CVN_USE_TREND_FILTER=0 (disabled in baseline) |
| Config loading | Via env var at chain construction time (build_filter_chain(exclude=[...])) |
| FunnelEntry | {filter_name: "trend", status: PASSED/REJECTED/SKIPPED, reason: "..."} |
| Error handling | Exception → PASS with reason "trend filter error: {e}" (fail-open, ADR-25 logged) |
| Calibration | EMA periods fixed (not HPO-tuned). Ablatable via FTF trend_filter factor. |
| In v2 chain | Yes (position 1), excludable via config |
Stage 3: Meta-Label Filter (Post-Inference Chain)¶
| Aspect | Detail |
|---|---|
| Plugin | MetaLabelPlugin — name: meta_label |
| File | src/commun/filters/plugins/meta_label_plugin.py |
| Purpose | Secondary ML model validates primary BUY prediction (ADR-47) |
| Input contract | Requires FilterContext.features (feature vector), FilterContext.meta_model, FilterContext.meta_feature_gen |
| Gate logic | Meta-model P(correct) > meta_threshold → PASS |
| Config | CVN_USE_META_LABEL=0 (disabled in baseline), CVN_META_THRESHOLD=0.5 |
| FunnelEntry | {filter_name: "meta_label", status: PASSED/REJECTED/SKIPPED, reason: "..."} |
| Error handling | Exception → PASS with logged warning (fail-open) |
| Calibration | meta_threshold OOS calibrated (ADR-15). Ablatable via FTF. |
| In v2 chain | Yes (position 2), optional (requires meta model trained) |
Stage 4: Regime Volatility Filter (Post-Inference Chain)¶
| Aspect | Detail |
|---|---|
| Plugin | RegimeVolatilityPlugin — name: regime |
| File | src/commun/filters/plugins/regime_volatility_plugin.py |
| Purpose | Reject signals during hostile regime (high vol, trending down) |
| Input contract | Requires FilterContext.ohlcv_window, regime classification |
| Gate logic | Regime compatible with training regime → PASS |
| Config | CVN_USE_REGIME_FILTER=0 (disabled in baseline) |
| FunnelEntry | {filter_name: "regime", status: PASSED/REJECTED/SKIPPED, reason: "..."} |
| Error handling | Insufficient data → PASS with reason "insufficient data" |
| Calibration | Regime detection parameters not HPO-tuned. Ablatable via FTF. |
| In v2 chain | Yes (position 3), excludable via config |
Stage 5: Concurrency Filter (Post-Inference Chain)¶
| Aspect | Detail |
|---|---|
| Plugin | ConcurrencyPlugin — name: concurrency |
| File | src/commun/filters/plugins/concurrency_plugin.py |
| Purpose | Limit simultaneous open positions (risk management) |
| Input contract | Requires FilterContext.open_positions (int) |
| Gate logic | open_positions < max_concurrent → PASS |
| Config | CVN_MAX_CONCURRENT=1 (baseline: 1 position at a time) |
| FunnelEntry | {filter_name: "concurrency", status: PASSED/REJECTED, reason: "max N reached"} |
| Error handling | Missing state → PASS (fail-open) |
| In v2 chain | Yes (position 4), always active |
Stage 6: Cooldown Filter (Post-Inference Chain)¶
| Aspect | Detail |
|---|---|
| Plugin | CooldownPlugin — name: cooldown |
| File | src/commun/filters/plugins/cooldown_plugin.py |
| Purpose | Enforce minimum time between trades (avoid overtrading) |
| Input contract | Requires FilterContext.last_trade_closed_at (timestamp) |
| Gate logic | time_since_last_trade > cooldown_seconds → PASS |
| Config | CVN_TRADE_COOLDOWN_SECONDS=0, CVN_SIGNAL_COOLDOWN_SECONDS=0 (no cooldown) |
| FunnelEntry | {filter_name: "cooldown", status: PASSED/REJECTED, reason: "cooldown Xs remaining"} |
| Error handling | Missing timestamp → PASS (no previous trade) |
| In v2 chain | Yes (position 5), always active |
Execution Layer (Post-Filter, Pre-Trade)¶
Cost and Kelly were moved from filters to execution layer in v2 (ADR-48):
| Component | Purpose | Applied where |
|---|---|---|
| Cost model | Non-linear slippage: base_bps + impact × √(size/volume) |
Trade execution |
| Kelly sizing | Fractional Kelly position sizing based on model edge | Position sizing |
| Expectancy check | Expected profit after costs must be positive | Implicit in Kelly |
Design decision: Filters decide IF to trade. Execution decides HOW to trade. This separation prevents cost model assumptions from contaminating signal quality analysis.
Cost Model OOS Validation (V4 reco #10)¶
The non-linear slippage model (base_bps + impact × √(size/volume)) must be validated against real market data:
| Validation step | Method | Data source | Acceptance criteria |
|---|---|---|---|
| Backtest fit | Compare model-predicted cost vs actual fill prices | Historical Binance fills (if available) or simulated order book replay | Mean absolute error < 5 bps |
| Stress-case | Validate during low-liquidity periods (weekends, holidays, flash crashes) | Historical OHLCV + volume data | Model underestimates cost by < 20% in stress |
| Cross-crypto | Validate on each DeFi crypto separately (UNI vs AAVE have different liquidity) | Per-crypto volume profiles | Per-crypto calibration if error > 10 bps |
| Impact factor | Validate impact_factor=0.001 is realistic |
Compare to academic literature (Almgren-Chriss) and exchange maker/taker data | Within 2× of empirical estimate |
Schedule: Phase 3 (Sprint 3) — after core funnel fixes, before production deployment.
Owner: Trading domain expert review required.
Fallback: If validation fails, increase impact_factor conservatively (overestimate cost rather than underestimate).
Stage 7: Expectancy Filter (V3 — committee reco #3)¶
| Aspect | Detail |
|---|---|
| Plugin | ExpectancyPlugin — name: expectancy (new) |
| Position | After cooldown (Stage 6), before trade execution |
| Purpose | Gate trades on positive expected profit after all estimated costs |
| Input contract | Requires: prob_buy, avg_win, avg_loss (from model history), estimated_cost_bps (from cost model) |
| Gate logic | expectancy = prob_buy × avg_win - (1-prob_buy) × avg_loss - cost > 0 → PASS |
| Config | CVN_USE_EXPECTANCY_FILTER=0 (disabled initially, enable after validation) |
| Funding rates (V3 reco #8) | cost = base_bps + slippage_bps + funding_rate_bps. Funding rate from Binance API, resampled hourly, applied pro-rata to expected hold duration |
| FunnelEntry | {filter_name: "expectancy", status: PASSED/REJECTED, reason: "negative expectancy: E=-0.3%"} |
| Calibration | avg_win, avg_loss from rolling 30-day backtest window (OOS) |
Updated chain order:
4. Data Model¶
FunnelEntry (per signal, per filter)¶
@dataclass
class FunnelEntry:
filter_name: str # Plugin name: "trend", "meta_label", "regime", etc.
status: str # "PASSED", "REJECTED", "SKIPPED"
reason: str = "" # Human-readable rejection reason (e.g. "bearish trend", "max 1 position reached")
File: src/commun/models/filters.py:178-183
Status semantics:
- PASSED: Filter evaluated the signal and approved it
- REJECTED: Filter evaluated the signal and blocked it (with reason)
- SKIPPED: Filter not evaluated (short-circuited by upstream REJECT, or plugin absent)
FilterChainResult (per signal)¶
@dataclass
class FilterChainResult:
final_signal: int # Signal after all filters
passed: bool # Did the signal survive all filters?
position_size: float # Kelly-adjusted size (or 1.0)
chain_results: List[FilterResult] # Per-filter detailed results
rejected_by: Optional[str] # First filter that rejected (short-circuit)
processing_time_ms: float # Total chain execution time
aggregated_state_out: Dict # State passed between filters
funnel: List[FunnelEntry] # ONE entry per plugin in chain order
File: src/commun/filters/chain_executor.py:99-108
Funnel Dict (aggregated over all signals in a backtest)¶
Current implementation (counts PASSED only — gap):
funnel = {
"raw_buy_signals": int,
"final_trades": int,
"trend": int, # PASSED count only
"concurrency": int,
"cooldown": int,
"total_candles": int,
"cusum_events": int,
"cusum_filtered": int,
}
Target implementation (ADR-44/45 compliant):
funnel = {
# Stage 0: CUSUM (unified)
"total_candles": int,
"cusum_passed": int,
"cusum_rejected": int,
"cusum_pass_rate": float,
# Stage 1: Inference
"raw_buy_signals": int,
"prob_buy_mean": float,
# Stages 2-6: Per-filter PASSED + REJECTED
"trend_passed": int,
"trend_rejected": int,
"meta_label_passed": int,
"meta_label_rejected": int,
"regime_passed": int,
"regime_rejected": int,
"concurrency_passed": int,
"concurrency_rejected": int,
"cooldown_passed": int,
"cooldown_rejected": int,
# Final
"final_trades": int,
# Derived (ADR-45)
"survival_rate": float, # final_trades / raw_buy_signals
"primary_killer": str, # filter with max rejected count
"starvation_flag": bool, # survival_rate < threshold AND raw_buy > min_sample
"per_filter_block_rates": dict, # {filter: rejected / (passed + rejected)}
# Rejection reasons (top N per filter)
"rejection_reasons": dict, # {filter: Counter({reason: count})}
# Unevaluated filters (ADR-44: -1 for absent)
# Filters not in active chain get -1 (not 0, not absent)
}
5. Checkpoints, Metrics & Attrition Curve¶
Checkpoint Map¶
total_candles ─── CUSUM ──→ cusum_passed ──→ INFERENCE ──→ raw_buy_signals
│ │ │
│ │ ┌──────────┴──────────┐
└─ cusum_rejected │ FILTER CHAIN v2 │
│ │
│ trend_passed │
│ trend_rejected │
│ │ │
│ meta_passed │
│ meta_rejected │
│ │ │
│ regime_passed │
│ regime_rejected │
│ │ │
│ concurrency_passed │
│ concurrency_reject │
│ │ │
│ cooldown_passed │
│ cooldown_rejected │
│ │
└──────────┬──────────┘
│
▼
final_trades
Cumulative Attrition Curve (waterfall)¶
The funnel should be visualizable as a waterfall chart showing progressive signal loss:
raw_buy_signals: 100 ████████████████████████████████████████████████████
after trend: 85 ████████████████████████████████████████████
after meta: 70 ███████████████████████████████████
after regime: 65 ████████████████████████████████
after concurrency: 40 ████████████████████
after cooldown: 35 █████████████████
final_trades: 35 █████████████████
Attrition: 100 → 35 = 65% filtered
Primary Killer: concurrency (25 rejects = 38% of total attrition)
This curve is computed from {filter}_passed and {filter}_rejected counts and visualized in Grafana as a bar chart per filter stage.
Derived Metrics¶
| Metric | Formula | Purpose | Alert threshold |
|---|---|---|---|
| CUSUM pass rate | cusum_passed / total_candles |
How much data CUSUM lets through | <1% → critical starvation |
| Funnel survival rate | final_trades / raw_buy_signals |
Overall filter attrition | <5% → starvation flag (configurable) |
| Per-filter block rate | rejected / (passed + rejected) |
Individual filter selectivity | >90% → single-filter starvation alert |
| Primary killer | argmax(rejected counts) |
Which filter kills the most signals | Alert if 1 filter >60% of total attrition |
| Filter contribution | rejected_i / sum(all_rejected) |
% of total attrition per filter | Imbalance >80% → review filter |
| Starvation flag | survival_rate < threshold AND raw_buy > min_sample |
Too few trades for statistical validity | Configurable: default 5%, min_sample=10 |
Starvation threshold calibration (V1 reco #2, V2 reco #4):
The 5% baseline threshold is derived from ADR-45. Three modes implemented:
Mode 1 — Static (legacy, not recommended):
- CVN_STARVATION_THRESHOLD=0.05 env var
- Starvation = survival_rate < 0.05 AND raw_buy > 10
Mode 2 — Statistical power (DEFAULT — committee reco V3 #5):
- Uses compute_min_sample_size() from ablation_stats.py (d=0.5, α=5%, power=80% → 63 trades)
- Starvation = raw_buy × survival_rate < 63
- Meaning: not enough surviving trades for statistically valid evaluation
- Implementation: CVN_STARVATION_MODE=statistical
Mode 3 — Market-adaptive (roadmap — Sprint 4, V4 reco #11):
- Adjust threshold based on rolling 30-day realized volatility
- High-vol regime → relax threshold (fewer signals expected, more filtering is natural)
- Low-vol regime → tighten threshold (more signals expected, starvation more concerning)
- Implementation: CVN_STARVATION_MODE=adaptive
Roadmap:
1. Sprint 4, week 1: Compute rolling 30d volatility per crypto from OHLCV data
2. Sprint 4, week 1: Define mapping: threshold = base_threshold × (vol_current / vol_median)
3. Sprint 4, week 2: Backtest on 12 months: compare static vs adaptive starvation detection accuracy
4. Sprint 4, week 2: FTF ablation: starvation_mode factor with {static, statistical, adaptive}
5. Committee review: Validate approach before production activation
Acceptance criteria: Adaptive mode reduces false-positive starvation alerts by >30% vs static mode across 5 cryptos and 12 months of data.
OOS calibration (V2 reco #1): The starvation threshold MUST be validated OOS. Method:
1. Compute survival_rate on historical folds
2. Find threshold that flags genuinely degraded strategies (Sortino < 0)
3. Validate on holdout fold
4. Ablatable via FTF factor starvation_threshold (future)
6. Operational Safety (committee reco #5)¶
Kill-Switch Mechanisms¶
| Mechanism | Scope | Control | File |
|---|---|---|---|
| FTF pipeline disable | Fine-tuning ablation runs only | CVN_FTF_ENABLED=0 env var |
src/commun/finetune/persistence.py:227-229 |
| Trading halt | All live/paper trading | CVN_SYSTEM_STATUS=inactive env var |
Runtime service config |
| CUSUM disable | Skip pre-filter | CVN_USE_CUSUM_FILTER=0 |
Backtest engine config |
| Individual filter disable | Per-filter | CVN_USE_TREND_FILTER=0, CVN_USE_REGIME_FILTER=0, etc. |
Chain construction exclude list |
| DAG pause | Stop Airflow runs | Airflow UI → pause DAG | ADR-22 (paused on creation) |
Rollback Procedures¶
| Scenario | Rollback path |
|---|---|
| Bad filter config deployed | Revert Helm values → helm upgrade --reuse-values |
| Filter plugin crashes | Fail-open (PASS) → plugin isolated, system continues |
| Model degradation detected | Lock previous model via MLflow stage → promote previous Production version |
| Full pipeline rollback | Git revert commit → CI/CD rebuild → Helm deploy |
Error Handling Per Filter Plugin¶
Every plugin follows the pattern:
def check(self, context: FilterContext) -> FilterResult:
try:
# Filter logic
if condition:
return FilterResult(name=self.name, passed=True)
return FilterResult(name=self.name, passed=False, reason="specific reason")
except Exception as e:
# Fail-open: log error, PASS the signal (ADR-25: no silent failure)
logger.error("event=filter_error filter=%s error=%s", self.name, e)
raise RuntimeError(f"{self.name} filter failed: {e}") from e
Chain executor catches exceptions and marks as PASS with error reason logged (no silent swallowing).
7. Filter Ablation Framework Integration (committee reco #4)¶
Each filter has a corresponding FTF ablation factor for systematic evaluation:
| Filter | FTF Factor | Variants | Type |
|---|---|---|---|
| CUSUM | cusum_threshold |
h ∈ | training |
| Trend | trend_filter |
OFF, ON_EMA20, ON_EMA50 | runtime |
| Meta-Label | meta_labeling |
OFF, ON_05, ON_03, ON_07 | training |
| Regime | regime_filter |
OFF, ON | runtime |
| Concurrency | concurrency_limit |
1, 2, 3 | runtime |
| Cooldown | cooldown_policy |
none, conservative_5m, aggressive_15m | runtime |
Ablation process: 1. Baseline: all filters at default config 2. Vary ONE filter at a time (ceteris paribus) 3. Measure: Sortino, survival_rate, n_trades, block_rate per filter 4. Statistical test: BH-corrected pairwise comparison 5. Decision: lock winner or keep baseline
Regular schedule: Re-ablate filters quarterly or after major pipeline changes.
8. ADR Conformity Audit¶
ADR-43: Centralization of Funnel Observability (lines 744-759)¶
| Requirement | Status V1 | Status V2 (target) |
|---|---|---|
| Funnel built by dynamic introspection of plugin list | COMPLIANT | COMPLIANT |
| Each signal gets explicit status per stage | COMPLIANT | COMPLIANT |
| FilterChainResult encapsulates funnel natively | COMPLIANT | COMPLIANT |
| No consumer reconstructs funnel from chain_results | COMPLIANT | COMPLIANT |
| CUSUM unified with main funnel | NON-COMPLIANT | COMPLIANT (formalized as Stage 0) |
ADR-44: Strict Data Contract for signal_funnel Event (lines 762-778)¶
| Requirement | Status V1 | Status V2 (target) |
|---|---|---|
| signal_funnel part of closed event catalog | COMPLIANT | COMPLIANT |
| Keys = plugin names | COMPLIANT | COMPLIANT |
| Values: -1 for unevaluated stages | NON-COMPLIANT | COMPLIANT (-1 for absent filters) |
| PASSED + REJECTED counts tracked | NON-COMPLIANT | COMPLIANT ({filter}_passed, {filter}_rejected) |
| Rejection reasons tracked | NON-COMPLIANT | COMPLIANT (top N reasons per filter) |
| Funnel returned as data structure | COMPLIANT | COMPLIANT |
ADR-45: Anti-Starvation Gate Checks (lines 781-799)¶
| Requirement | Status V1 | Status V2 (target) |
|---|---|---|
| Starvation threshold: survival < 5% → INCONCLUSIVE | NOT IMPL. | COMPLIANT (configurable threshold) |
| Primary Killer identification | NOT IMPL. | COMPLIANT (argmax(rejected)) |
| No filter rejects >90% without alert | NOT IMPL. | COMPLIANT (per-filter block rate alert) |
| Cumulative attrition curve | NOT IMPL. | COMPLIANT (waterfall chart in Grafana) |
ADR-15: Theta Calibrated OOS¶
| Parameter | OOS Calibrated? | Method | Target |
|---|---|---|---|
threshold_buy |
Yes | Walk-forward HPO per fold | ✓ Compliant |
CVN_CUSUM_THRESHOLD_H |
Partial — FTF ablation, not per-crypto | FTF cusum_threshold |
Walk-forward per crypto (Phase 2) |
CVN_META_THRESHOLD |
Partial — fixed 0.5 | FTF meta_labeling |
Walk-forward co-optimized with HPO |
| Trend EMA periods | No — fixed | FTF trend_filter factor |
Walk-forward or grid search |
| Starvation threshold | No — hardcoded 5% | OOS validation on historical folds | Statistical power mode (63 trades) |
| Expectancy filter params | N/A — not yet implemented | Rolling 30d backtest window | OOS rolling validation |
V3 commitment (V2 reco #1): ALL filter parameters will be OOS-calibrated before production deployment. Roadmap:
1. Immediate: threshold_buy already walk-forward (done)
2. Phase 1a: FTF ablation of cusum_threshold, meta_threshold, trend (running)
3. Phase 2: Walk-forward per-crypto calibration for best FTF variants
4. Phase 3: Starvation threshold OOS validation on holdout folds
5. Continuous: Drift monitoring triggers re-calibration (see §14)
9. Design Decisions¶
Why CUSUM is formalized as Stage 0 (not in FilterChain)¶
CUSUM operates pre-inference on every candle. It cannot be in the post-inference chain because inference hasn't happened yet. However, per committee recommendation, it is formalized as Stage 0 of the funnel with unified observability:
- Same metric format as chain filters (cusum_passed, cusum_rejected)
- Same block_rate computation
- Included in cumulative attrition curve
- Separate tracking preserved for backward compatibility (cusum_events, cusum_filtered)
Why Cost and Kelly were removed from v2 chain¶
In v1, Cost and Kelly were signal filters. In v2 (ADR-48), they moved to the execution layer: - Cost is applied at trade execution (non-linear slippage model) - Kelly is applied at position sizing (fractional Kelly)
Principle: Filters decide IF to trade, execution decides HOW to trade.
Economic viability (committee reco #6): The cost model + Kelly at execution ensures no economically unviable trade is opened. Kelly sizing reduces position to zero if expected edge < cost. This is an implicit economic viability gate. An explicit ExpectancyFilter can be added if the implicit check proves insufficient — it would sit at position 7 in the chain (after cooldown, before execution).
Why the chain uses short-circuit evaluation¶
When a filter REJECTs, downstream filters are marked SKIPPED: - Efficient: No unnecessary computation - Correct: SKIPPED ≠ PASSED. A signal rejected at trend was never evaluated by regime. - Observable: Three distinct statuses enable precise attrition analysis
Filter order rationale¶
Principle: cheapest filters first, stateful filters last.
- Trend first: Cheapest (EMA comparison), rejects most signals in trending markets
- Meta-label second: ML inference (expensive), only for trend-validated signals
- Regime third: Regime check after model validation
- Concurrency fourth: Stateful (depends on current portfolio state)
- Cooldown last: Time-based (only relevant if all other filters pass)
10. Configuration & Security (committee reco #9)¶
Configuration Loading¶
All filter parameters are loaded from environment variables at startup:
- Chain construction: pipeline_component_factory.py:246-278
- Config flags: CVN_USE_TREND_FILTER, CVN_USE_REGIME_FILTER, etc.
- Threshold values: CVN_CUSUM_THRESHOLD_H, CVN_META_THRESHOLD, etc.
Helm is the single source of truth (#378): All env vars are in values-prod.yaml → ConfigMap → pod env. No manual kubectl apply.
Security Considerations¶
| Concern | Mitigation |
|---|---|
| Filter bypass via env var manipulation | Helm-managed ConfigMap, RBAC on namespace |
| Model poisoning (bad meta-model) | MLflow model registry with manual promotion (ADR-2) |
| Config drift between pods | Single ConfigMap, no per-pod overrides |
| Unauthorized kill-switch toggle | Env var in ConfigMap → requires Helm deploy (auditable via git) |
11. Runtime / Production Context (committee reco #9)¶
Deployment¶
- Backtest: Full candle loop with funnel tracking → results to PostgreSQL
- Paper trading: Same kernel, same filter chain (ADR-40) → event store
- Live trading: Same kernel, different adapter (ADR-40) → order execution
Monitoring¶
| Metric | Dashboard | Alert |
|---|---|---|
| Funnel survival rate | Grafana FTF dashboard, Funnel panel | < 5% → starvation warning |
| Per-filter block rate | Grafana FTF dashboard, Funnel panel | > 90% → single-filter alert |
| Primary killer | Grafana FTF dashboard, Funnel panel | If 1 filter > 60% attrition → review |
| CUSUM pass rate | Grafana Infra dashboard | < 1% → CUSUM too aggressive |
12. Fail-Open Monitoring & Circuit Breaker (V2 recos #5, #10)¶
Fail-Open Error Rate Alerting¶
Filters use fail-open pattern (exception → PASS). This prevents cascading failures but risks silent degradation. Monitoring required:
| Metric | Query | Alert |
|---|---|---|
| Error rate per filter (5m window) | filter_error_count / filter_total_count |
>5% → warning, >20% → disable filter |
| Consecutive errors per filter | Count sequential errors | >3 consecutive → disable + alert |
| Total fail-open rate (all filters) | Sum errors / sum total | >10% → critical alert |
Auto-disable logic: If filter error rate > 20% for 10 minutes, automatically exclude from chain and alert ops. Re-enable requires manual review.
Circuit Breaker (V2 reco #10)¶
If a high percentage of signals fail a filter within a short period, the circuit breaker halts processing:
class CircuitBreaker:
WINDOW = 100 # signals
THRESHOLD = 0.95 # 95% rejection rate
COOLDOWN = 300 # seconds before retry
state: CLOSED | OPEN | HALF_OPEN
# CLOSED → filter evaluates normally
# OPEN → filter auto-PASSES (circuit tripped), alert fired
# HALF_OPEN → test one signal, if PASS → CLOSED, if REJECT → OPEN
Trigger: >95% rejection rate in last 100 signals → circuit opens → all signals PASS this filter → alert "filter {name} circuit breaker tripped".
Use case: Regime filter rejects 99% during flash crash → circuit breaker bypasses it → signals flow through remaining filters → operator investigates.
13. CUSUM Temporal Separation (V2 reco #6)¶
Leakage Prevention¶
CUSUM sigma is calibrated on the training window. To prevent data leakage:
Timeline:
├── CUSUM calibration window ──┤── Gap (purge) ──├── Training window ──┤
│ sigma fitted here │ 10 bars │ model trained here │
│ (lagged by purge_bars) │ │ │
Current implementation: CUSUM sigma is computed on the FULL pre-split dataset. The sigma is causal (uses returns[0..t]) so technically no leakage — but the sigma may overfit to the training period's volatility.
V3 improvement: Calibrate CUSUM sigma on a lagged window:
- sigma_window = training_start - purge_bars - sigma_lookback
- Default: sigma_lookback = 500 bars (same as warmup)
- This ensures sigma is computed on data the model never sees
- Env var: CVN_CUSUM_SIGMA_LOOKBACK=500
Leakage Prevention — All Filters (V2 reco #16)¶
| Filter | Leakage risk | Prevention |
|---|---|---|
| CUSUM | Sigma fit on same window as training | Lagged sigma window (V3) |
| Trend EMA | Uses current candle OHLC | Lagged: EMA computed on T-1, decision at T |
| Meta-label | Meta-model trained on same data | Meta-model trained on SEPARATE fold (ADR-47) |
| Regime | Regime labels from current window | Labels computed causally (backward-looking only) |
| Expectancy | avg_win/avg_loss from training | Rolling 30d OOS window, never training period |
14. Drift Detection & Monitoring (V2 reco #13)¶
Data Drift¶
| Monitor | Method | Alert |
|---|---|---|
| Feature distribution shift | PSI (Population Stability Index) on top 10 features | PSI > 0.2 → warning (ADR drift_threshold) |
| OHLCV volume anomaly | Z-score on daily volume vs 30d mean | |
| Funding rate anomaly | Absolute funding rate vs historical | >0.1% → elevated cost warning |
Label Drift¶
| Monitor | Method | Alert |
|---|---|---|
| BUY label ratio | Rolling 30d BUY% vs historical | >20% shift → label drift |
| Triple barrier timeout ratio | % HOLD labels | >60% → strategy misaligned with regime |
Model Performance Drift¶
| Monitor | Method | Alert |
|---|---|---|
| F1 trend | Rolling run-over-run f1_macro | >10% drop from 7d avg |
| Sortino trend | Rolling Sortino per crypto | Drop below 0 for 3 consecutive runs |
| Action rate drift | Model BUY prediction rate | >20% change from baseline |
Trigger: Any critical drift alert → pause FTF runs → committee review → potential retraining.
15. Uncertainty Quantification (V2 reco #7)¶
All key metrics reported with confidence intervals:
| Metric | CI Method | Implementation |
|---|---|---|
| Sortino | Bootstrap (10,000 resamples) | ablation_stats.py:bootstrap_ci() — already implemented |
| Survival rate | Binomial CI (proportion_confint) |
New — compute from n_pass, n_total |
| Block rate per filter | Binomial CI | New — same method |
| Expectancy | Bootstrap | New — resample trade PnL distribution |
| Win rate | Wilson score interval | New — robust for small n |
BH correction for ablation (V2 reco #11): Already implemented in ablation_stats.py:benjamini_hochberg(). Applied to all pairwise comparisons in FTF reports.
16. Live State Management (V2 reco #9)¶
FilterContext State in Production¶
| State field | Source | Sync mechanism |
|---|---|---|
open_positions |
Event store (ADR-41) | Read from PostgreSQL runtime tables at each candle |
last_trade_closed_at |
Event store | Last trade_closed event timestamp |
cooldown_until |
Computed from last trade + config | Stateless: recomputed each candle |
cusum_state (S+, S-) |
In-memory accumulator | Persisted to Redis between restarts (session recovery, ADR-41) |
Resilience¶
| Failure mode | Recovery |
|---|---|
| State loss (pod restart) | Event store replay → reconstruct open_positions, last_trade (ADR-41: event-sourced) |
| Redis down (CUSUM state) | CUSUM resets to warmup → conservative (no trades for 100 bars) |
| PostgreSQL down | Fail-safe: no new trades opened (can't verify open_positions) |
17. Deployment Strategy (V2 reco #14)¶
Staged Rollout for Filter Changes¶
| Stage | Environment | Duration | Gate |
|---|---|---|---|
| 1. FTF ablation | Backtest (offline) | 2-3h per factor | Statistical significance (BH p < 0.05) |
| 2. Shadow mode | Paper trading (parallel) | 7 days | No Sortino degradation vs current |
| 3. Canary | Live with 10% capital | 14 days | Sortino ≥ baseline, drawdown < limit |
| 4. Full rollout | Live 100% | — | Monitoring continues |
Rollback: At any stage, revert to previous config via Helm values + git revert.
18. Filter Configuration Governance (V2 reco #15)¶
Approval Workflow¶
FTF ablation result → Committee review (score ≥ 8) → Lock winner (lock_winner())
→ PR with Helm values change → CodeRabbit review → Merge → CI/CD deploy
→ Shadow mode validation → Canary → Full rollout
Ownership: - Filter config changes: requires committee approval (ADR-52) - Model promotion: requires manual review (ADR-2) - Kill-switch: operator authority (immediate, post-hoc review)
Audit trail: Every config change tracked in git (Helm values), committee sessions, MLflow model registry.
19. Filter Default Configuration Policy (V3 reco #9)¶
Each filter has a default state. The policy is: disabled by default unless OOS-validated via FTF ablation.
| Filter | Default | Rationale | Activation path |
|---|---|---|---|
| CUSUM | ON (h=3.0σ) | Core architecture — gates inference candles | Always on. Threshold tuned via FTF. |
| Trend | OFF | Not yet OOS-validated | Enable after FTF trend_filter shows significant Sortino improvement |
| Meta-Label | OFF | Requires separately trained meta-model | Enable after meta-model training + FTF validation |
| Regime | OFF | Not yet OOS-validated | Enable after FTF regime_filter validates |
| Concurrency | ON (max=1) | Risk management — always active | Tune max_concurrent via FTF |
| Cooldown | ON (0s) | Active but no cooldown applied | Increase after FTF cooldown_policy validates optimal interval |
| Expectancy | OFF | New (V3) — not yet implemented | Enable after implementation + OOS validation |
Rule: No filter is enabled in production without FTF ablation evidence (BH p < 0.05) showing statistically significant improvement. Committee approval required (ADR-52).
Removal policy: If a filter shows no significant impact after 2 FTF ablation rounds, it is removed from the chain to reduce complexity.
20. Stress-Case Analysis (V3 reco #10)¶
Extreme Market Scenarios¶
The funnel must be evaluated under stress conditions, not just normal markets.
| Scenario | Description | Expected funnel behavior | Monitoring |
|---|---|---|---|
| Flash crash | >10% drop in <1h | CUSUM triggers massively → many events → raw_buy_signals spike → regime filter blocks most → survival drops | Alert: survival < 1% for 1h |
| Liquidation cascade | Cascading long liquidations, volume 10× normal | Slippage model spikes → expectancy filter blocks trades → 0 final_trades | Alert: expectancy_rejected > 90% |
| Liquidity drain | Order book thins, spread >1% | Cost model: slippage_bps > 100 → all trades uneconomic | Alert: avg_cost_bps > 50 |
| Volatility crush | Vol drops 80% (post-event calm) | CUSUM events drop to 0 → no signals for hours/days → starvation | Alert: cusum_events = 0 for 6h |
| Funding rate spike | Funding >0.1% per 8h | Expected hold cost >30bps → expectancy negative | Alert: funding_rate_bps > 10 |
| Exchange outage | Binance API down | No OHLCV data → no enrichment → no inference → 0 signals | Alert: data_freshness > 10 min |
Drawdown Protection¶
| Mechanism | Trigger | Action |
|---|---|---|
| Max daily drawdown | Portfolio DD > CVN_MAX_DAILY_DD_PCT (default 10%) |
Kill-switch: stop all new trades |
| Consecutive losses | 5 consecutive losing trades | Cooldown: 2× normal cooldown period |
| Circuit breaker | >95% rejection rate in 100 signals | Filter bypass (PASS) + alert |
| Emergency halt | Operator decision | CVN_FTF_ENABLED=0 → immediate stop |
Stress Test Schedule¶
- Monthly: Backtest on historical stress periods (March 2020 crash, May 2021 crash, FTX Nov 2022, SVB March 2023)
- Per model deployment: Validate on worst 5% of historical months before promotion
- Continuous: Drawdown monitoring with auto-halt
21. Security Architecture (V3 reco #11)¶
Secrets Management¶
| Secret | Storage | Access | Rotation |
|---|---|---|---|
POSTGRES_PASSWORD |
Kubernetes Secret (to migrate from ConfigMap, #527) | Pods via secretKeyRef | Quarterly |
MLFLOW_TRACKING_URI credentials |
Kubernetes Secret | MLflow pod + trainer pods | Quarterly |
| Binance API keys | Kubernetes Secret | ETL pods + runtime pods | Bi-annually |
| Gemini/Mistral API keys | Kubernetes Secret | Committee script (local) | Annually |
| Slack webhook URL | Kubernetes Secret | Alertmanager | Annually |
| Grafana admin password | Kubernetes Secret (to migrate, #527) | Grafana pod | Quarterly |
Current gap (#527): Some secrets are hardcoded in values-prod.yaml. Migration to Kubernetes Secrets is planned.
Access Control¶
| Resource | Who | How |
|---|---|---|
| Filter config changes | Committee-approved PRs only | Git + Helm + CI/CD |
| Model promotion | Manual operator review | MLflow UI + ADR-2 |
| Kill-switch toggle | Operator (immediate) | Helm deploy or Airflow UI |
| Database access | Service accounts only | RBAC on PostgreSQL |
| Kubernetes namespace | RBAC: admin + CI service account | Scaleway IAM |
Audit Trail¶
Every action is traceable:
- Config changes → git history (Helm values)
- Model promotions → MLflow model registry (version, timestamp, user)
- Committee decisions → committee/sessions/*.json (ADR-52)
- Filter activations → Airflow DAG run logs
- Alerts → Alertmanager history + Slack channel archive
22. Implementation Plan (#533) — Updated V4¶
Committed timelines¶
Phase 1 and 2 have hard deadlines. Phase 3 is scheduled but flexible.
Phase 1: Core Funnel Observability (issue #533) — DEADLINE: Sprint 1 (1 week)¶
| Change | File | Effort | V3 Reco |
|---|---|---|---|
| Track PASSED + REJECTED per filter | cvntrade_backtest_engine.py:1151 |
10 lines | #1 |
| Compute primary killer + starvation flag (statistical power default) | cvntrade_backtest_engine.py:1270 |
15 lines | #1, #5 |
| Unify CUSUM as Stage 0 | cvntrade_backtest_engine.py:1267 |
5 lines | #1 |
| -1 for unevaluated filters | cvntrade_backtest_engine.py:816 |
5 lines | #1 |
| Persist rejection reasons (top N) | cvntrade_backtest_engine.py:1155 |
10 lines | #1 |
| Update block rate computation | ablation_runner.py:181 |
10 lines | #1 |
| Cumulative attrition waterfall panel | finetune.json |
30 lines | #1 |
| Uncertainty CIs for survival/block rates (binomial + Wilson) | ablation_report.py |
20 lines | #4 |
| CUSUM lagged sigma window (500 bars) | cvntrade_cusum_filter.py |
10 lines | #6 |
| Phase 1 total | ~115 lines |
Gate: FTF run with funnel populated → all filter block rates visible in Grafana → committee review.
Phase 2: Economic Viability + Safety (issue #533) — DEADLINE: Sprint 2 (1 week)¶
| Change | File | Effort | V3 Reco |
|---|---|---|---|
| ExpectancyFilter plugin (Stage 7) with funding rates | plugins/expectancy_plugin.py (new) |
~60 lines | #3 |
| Register in v2 preset + factory | registry.py, pipeline_component_factory.py |
10 lines | #3 |
| Funding rate integration in cost model | src/commun/config/cost_model.py |
15 lines | #3 |
| Circuit breaker in chain executor | chain_executor.py |
~40 lines | #7 |
| Fail-open error rate monitoring + auto-disable | chain_executor.py + Prometheus counter |
20 lines | #7 |
| OOS validation of ExpectancyFilter | FTF ablation run | compute | #3 |
| Phase 2 total | ~145 lines |
Gate: ExpectancyFilter validated OOS → circuit breaker tested → committee review.
Phase 3: Full ADR-15 Calibration + Operations — DEADLINE: Sprint 3 (1 week)¶
| Change | Effort | V3 Reco |
|---|---|---|
| Walk-forward OOS calibration for CUSUM h, meta threshold, trend EMA | 3 FTF factor runs | #2 |
| Starvation threshold OOS validation on holdout folds | 1 analysis session | #5 |
| Drift detection (PSI, label, model performance) | ~100 lines | #8 |
| Runbooks for all critical alerts (10 docs) | 10 docs | #8 |
| Stress-case backtest on historical crashes | 1 compute session | #10 |
| Shadow/canary deployment pipeline | Helm + CI/CD config | — |
| Filter governance workflow documentation | Process doc | — |
| Security architecture documentation | 1 doc | #11 |
Gate: All ADR-15 parameters OOS-calibrated → drift detection active → stress tests passed → committee final review.
Implementation Guarantees¶
- Zero changes to: FilterChainExecutor interface, FunnelEntry dataclass, persistence schema
- Backward compatible: All changes behind feature flags (env vars)
- Rollback: Any phase can be reverted via git revert + Helm deploy
- Testing:
make test-unitmust pass at every commit (CI enforced)
23. Files Reference¶
| File | Lines | Purpose |
|---|---|---|
src/backtest/cvntrade_backtest_engine.py |
812-818 | Funnel initialization |
src/backtest/cvntrade_backtest_engine.py |
869-948 | CUSUM pre-filter (Stage 0) |
src/backtest/cvntrade_backtest_engine.py |
1009-1053 | ML inference (Stage 1) |
src/backtest/cvntrade_backtest_engine.py |
1107 | FilterChain execution (Stages 2-6) |
src/backtest/cvntrade_backtest_engine.py |
1151-1156 | Funnel consumption (PASSED only — to fix) |
src/backtest/cvntrade_backtest_engine.py |
1189 | final_trades counter |
src/backtest/cvntrade_backtest_engine.py |
1267-1270 | CUSUM diagnostics |
src/commun/filters/chain_executor.py |
55-108 | FilterChainExecutor.execute() |
src/commun/filters/chain_executor.py |
68-94 | FunnelEntry generation |
src/commun/models/filters.py |
169-183 | FunnelStage + FunnelEntry |
src/commun/filters/registry.py |
24-36 | v2 chain presets |
src/commun/pipeline/pipeline_component_factory.py |
246-278 | build_filter_chain() |
src/commun/finetune/ablation_runner.py |
181-197 | Block rate computation |
src/commun/finetune/persistence.py |
60-67 | PostgreSQL persistence |
documentation/ADR.md |
744-759 | ADR-43 |
documentation/ADR.md |
762-778 | ADR-44 |
documentation/ADR.md |
781-799 | ADR-45 |