Skip to content

ADR-48 — Cost and Kelly are execution-layer, not signal filters (proposed)

Status: proposed — not yet enforced ; implementation tracked in #738. Promoted to active once #738 ships and Invariant 1 holds. Date: 2026-04-24 (backfill) · originally implied by v2 LdP pipeline redesign Introduced by: backfill under #637 (Phase 2 of #593) Supersedes: nothing yet — the v1 filter-chain pipeline (where Cost + Kelly are filters) is still the active runtime. ADR-48 supersedes that v1 design only after #738 merges.


Context

In v1 of the Filter Funnel, CostFilter and KellyFilter were plugins in the post-inference chain: a BUY signal passed the funnel only if its cost-net expectancy was positive AND its Kelly size was > 0. Two problems:

  1. Rejection / sizing coupling: a signal rejected for expectancy < 0 did not advance to the next stage, but its Kelly size could have been reduced to a non-zero fraction without forbidding execution. We lost alpha by binarizing a decision that is continuous.
  2. Degraded observability: CostFilter.rejected_count and KellyFilter.rejected_count were not commensurable with the other filters (which binarize on ML signal quality), which muddied funnel_density (ADR-45).

V2 (LdP pipeline) separates the two layers:

  • Signal quality (filters): trend, meta-label, regime, concurrency, cooldown. Binary PASS / REJECT.
  • Execution (non-filter): cost model + Kelly sizing — continuous, applied AFTER the signal passes the filters.

Decision

Cost and Kelly are not funnel filters. They belong to the execution layer:

  • Cost: applied at execute_trade() via src/commun/config/cost_model.py. The cost model (fees + non-linear slippage) adjusts expected PnL and final size, but does not reject the signal. If net expectancy turns negative after costs, size → 0 (natural skip).
  • Kelly: applied at compute_position_size() via src/commun/kelly/. The Kelly fraction (typically 0.25 to 0.50 × optimal Kelly) determines size. A signal can have Kelly = 0 (no position taken) without being rejected from the funnel — it is logged, counted, but does not execute this cycle.

The legacy cost_pnl_plugin.py and kelly_sizing_plugin.py (the actual plugin file names in src/commun/filters/plugins/ today) are removed (or their registration is dropped from FilterChainExecutor) when #738 ships.

Invariants (target — enforced once #738 ships)

These invariants describe the target state. They are NOT yet enforced in the codebase as of 2026-04-28 ; #738 implements the migration.

  • Invariant 1 (target): src/commun/filters/plugins/ contains no file named cost_*.py or kelly_*.py. A file appearing there re-violates the separation. Current status: VIOLATED — cost_pnl_plugin.py and kelly_sizing_plugin.py both exist (v1 legacy).
  • Invariant 2 (target): the filter chain ships ONLY with: trend_filter, meta_label, regime_filter, concurrency, cooldown_policy (+ optional configurable quality / confirmation). Any chain that inserts Cost or Kelly as a filter violates ADR-48. Current status: VIOLATED — CLAUDE.md still lists "6. Cost → 7. Kelly" in the v1 chain order.
  • Invariant 3 (target): the event=signal_accepted ... log (ADR-32) does NOT account for cost / Kelly — it reflects that the signal passed quality. The execution log (event=trade_executed or event=trade_skipped_size_zero) is a separate event capturing the Cost + Kelly outcome. Current status: VIOLATED — Cost / Kelly are still in the rejection counts.

When #738 merges : 1. The 3 invariants above flip to enforced 2. ADR-48 status flips from proposed to active 3. CLAUDE.md filter chain order is updated to remove Cost + Kelly 4. The grep find src/commun/filters/plugins -name 'cost*' -o -name 'kelly*' returns empty 5. This block is renamed back to ## Invariants (drop the "(target)" parentheticals)

Alternatives rejected

  • Keep Cost as a filter (but not Kelly): asymmetric, and the binarization problem persists. Rejected.
  • Move EVERYTHING to the execution layer (including trend, meta, regime): loses funnel visibility → impossible to analyze why a signal was filtered. ADR-43 / ADR-44 / ADR-45 assume the filter / execution split.
  • Cost as filter + Kelly in execution: same asymmetry. Rejected.
  • Combined "economic gate" filter merging Cost + Kelly: simplification, but keeps the binarization flaw. Rejected.

Consequences

  • Positive: alpha captured on "marginally positive expectancy" signals — Kelly size reduced instead of binary rejection. Cleaner funnel observability (filters = signal quality, execution = sizing).
  • Negative: code reorganization (plugins removed, logic moved to the execution layer). Regression test audit required at each release to prevent an engineer from reintroducing a cost_* or kelly_* plugin file by habit (Invariant 1 once #738 ships).
  • Neutral: backtest reporting must disaggregate "signals accepted by the funnel" vs "trades executed" (a delta > 0 = signals with Kelly = 0). This was already the case in v2.

Rollback

  • Impossible without re-introducing v1 of the pipeline. If Cost / Kelly need to become filters again, a new ADR supersedes ADR-48 by justifying the trade-off.
  • Revert the commit that removed the v1 plugins — they are reconstructible from git history (e.g. git log --all -- 'src/commun/filters/plugins/*cost*' 'src/commun/filters/plugins/*kelly*', which catches both the actual current files cost_pnl_plugin.py / kelly_sizing_plugin.py and any future renames).

References

  • Parent need: CVN-N003 (POC Runtime, pipeline v2)
  • Related ADRs: ADR-43 (filter observability), ADR-44 (filter contract), ADR-45 (funnel density), ADR-60 / ADR-61 (pipeline architecture)
  • Code: src/commun/config/cost_model.py, src/commun/kelly/, src/commun/filters/registry.py (v2 plugins list)
  • Docs: documentation/architecture/FILTER_FUNNEL.md §3.3 (Cost moved out), §5.7 (Kelly in execution)