Skip to content

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:

  1. Observability: Know exactly how many signals survive each stage, and why they die
  2. Diagnostics: Identify the "Primary Killer" filter and cumulative attrition curve
  3. Anti-starvation: Detect when too few signals survive (ADR-45), preventing silent strategy failure
  4. Economic viability: Ensure signals are profitable after market frictions
  5. 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:

trend → meta_label → regime → concurrency → cooldown → expectancy


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

trend → meta_label → regime → concurrency → cooldown

Principle: cheapest filters first, stateful filters last.

  1. Trend first: Cheapest (EMA comparison), rejects most signals in trending markets
  2. Meta-label second: ML inference (expensive), only for trend-validated signals
  3. Regime third: Regime check after model validation
  4. Concurrency fourth: Stateful (depends on current portfolio state)
  5. 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-unit must 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