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:
- Rejection / sizing coupling: a signal rejected for
expectancy < 0did 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. - Degraded observability:
CostFilter.rejected_countandKellyFilter.rejected_countwere 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()viasrc/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()viasrc/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 namedcost_*.pyorkelly_*.py. A file appearing there re-violates the separation. Current status: VIOLATED —cost_pnl_plugin.pyandkelly_sizing_plugin.pyboth exist (v1 legacy). - Invariant 2 (target): the filter chain ships ONLY with:
trend_filter,meta_label,regime_filter,concurrency,cooldown_policy(+ optional configurablequality/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_executedorevent=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_*orkelly_*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 filescost_pnl_plugin.py/kelly_sizing_plugin.pyand 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)