Conviction campaign 2026-09-27 (FAILED, research knob kept) + plain-words alert wording

Owner question: can score and confidence be combined into one number that is
easier to understand and for the system to decide on?

Research (pre-registered, nothing ships to the signal path):
- signals.grading_mode: "pair" (default, the shipped two-floor rule, byte-
  identical: all five reference MD5s reproduce on both scripts), "min"
  (min(|score|, 0.6*conf)), "product" (|score|*conf/100); thresholds fixed from
  the control's corner points, no tuning. Helpers _final_signal_and_grade /
  _transitioning_boost / conviction / CONVICTION_THRESHOLDS in signal_aggregator;
  mirrored in K2 (GRADING_MODE_CODES, PF_conv_* packed from the live module);
  twin refuses other values; M15 OOS MD5 equal on both scripts for both modes.
- Verdict: FAILED on M5 and M15. K-MIN moves 43-54 trades for -1.2R / -2.9R
  (clauses a,b,e / a,b,d); K-PROD -29R on M15 (PF 1.29, W3 DD 28.9R), neutral
  on M5. H1/H4 product +11.9R / +17.0R on W3 from extra low-score trades
  (H1 PF 1.98 -> 1.75, DD doubles): hypothesis only.
- Code review (high) before the runs: 6 findings fixed, 1 recorded
  (engine_config_fingerprint omits the switch under "pair", so live rows keep
  their fingerprint).

Delivery (books untouched): alerts lead with the grade and print the two raw
numbers in words (main.plain_words: "Direction: strong buy · Reliability: good
· timeframes agree", cut-offs = the grade table's own) on Telegram, /status,
the CLI and the dashboard card (plain_words in the snapshot); "MTF" label
replaced by "Other timeframes"; guides updated. Suite 566 passed.

Evidence: backtest/research/conviction_campaign_2026-09-27.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-09-27 14:54:36 -04:00
parent 1454ea1838
commit 6af1237c2d
13 changed files with 808 additions and 59 deletions
+67 -14
View File
@@ -202,6 +202,10 @@ K2_FLOAT_FIELDS = (
"cp_trigger", "cp_height", "pb_retrace_atr", "bc_ratio", "rf_adx",
)
SKIP_NAMES = {1: "WEEKEND_FILTER", 2: "DAY_FILTER", 3: "SESSION_WINDOW"}
# The grading rules K2 implements (fb2_aggregate: 0 = the shipped pair rule,
# 1 = min conviction, 2 = product conviction). Pinned by name, never by the
# position in signal_aggregator.GRADING_MODES; pack_params checks the two agree.
GRADING_MODE_CODES = {"pair": 0, "min": 1, "product": 2}
# ── Config packing ────────────────────────────────────────────────────
PARAMS_F = (
@@ -220,6 +224,10 @@ PARAMS_F = (
"big_candle_atr_multiple", "big_candle_lookback_hours",
"regime_filter_min_adx", "pip_value", "fb2_ema_lo",
"tp_scale_aplus", "tp_scale_a", "max_penalty",
# grading_mode research thresholds (signal_aggregator.CONVICTION_THRESHOLDS;
# all 0.0 under "pair"): aplus, a, b, c, strong, buy, boost_a, boost_b
"conv_aplus", "conv_a", "conv_b", "conv_c",
"conv_strong", "conv_buy", "conv_boost_a", "conv_boost_b",
)
PARAMS_I = (
"ema_trend_period", "ema_mode_2ema", "ema_partial_neutral", "rsi_regime_aware",
@@ -236,6 +244,7 @@ PARAMS_I = (
"cp_min_sep_bars", "cp_flag_pole_bars", "cp_flag_min_bars", "cp_flag_max_bars",
"quality_filter_mask", "strategies_mask", "primary_is_m5", "primary_bar_secs",
"regime_filter", "n_confirm",
"grading_mode", # 0 pair (shipped) / 1 min / 2 product
)
# Fields of the four config dataclasses the kernels do NOT read, with the
@@ -357,6 +366,17 @@ def pack_params(platform_cfg, bt_cfg) -> Tuple[np.ndarray, np.ndarray, np.ndarra
"fb2_ema_lo": fb2_ema_lo_from_env(),
"tp_scale_aplus": 2.0, "tp_scale_a": 1.5, "max_penalty": 30.0,
}
# grading rule: the live module is the single source of the mode list and
# the thresholds (local import: this module must stay a leaf for the live
# code, the reverse direction is the banned one)
from signal_aggregator import CONVICTION_THRESHOLDS, GRADING_MODES, grading_mode_of
assert set(GRADING_MODES) == set(GRADING_MODE_CODES), (
f"signal_aggregator.GRADING_MODES {GRADING_MODES} != the kernel's {sorted(GRADING_MODE_CODES)}")
grading_mode = grading_mode_of(s)
conv_t = (0.0,) * 8 if grading_mode == "pair" else CONVICTION_THRESHOLDS[grading_mode]
for name, val in zip(("conv_aplus", "conv_a", "conv_b", "conv_c",
"conv_strong", "conv_buy", "conv_boost_a", "conv_boost_b"), conv_t):
f[name] = val
from providers.base import BaseDataProvider # CANDLE_SECONDS (the aggregator's clock)
bar_secs = BaseDataProvider.CANDLE_SECONDS[platform_cfg.primary_timeframe]
strategies = list(bt_cfg.strategies_only or [])
@@ -410,6 +430,7 @@ def pack_params(platform_cfg, bt_cfg) -> Tuple[np.ndarray, np.ndarray, np.ndarra
"primary_bar_secs": bar_secs,
"regime_filter": 1 if b.regime_filter else 0,
"n_confirm": len(platform_cfg.confirmation_timeframes),
"grading_mode": GRADING_MODE_CODES[grading_mode],
}
pf = np.array([float(f[name]) for name in PARAMS_F], dtype=np.float64)
pi = np.array([int(i[name]) for name in PARAMS_I], dtype=np.int32)
@@ -1686,20 +1707,42 @@ void fb2_aggregate(const double* __restrict__ open_p, const double* __restrict__
confidence -= total_penalty;
confidence = PYMAX(0.0, PYMIN(95.0, confidence));
if (exhaustion_blocked) { WARN(W_exhaustion_block, (double)exhaustion_count); confidence = 0.0; }
int final_signal;
if (score >= 35.0 && confidence >= 55.0) final_signal = 2;
else if (score >= 25.0 && confidence >= 50.0) final_signal = 1;
else if (score <= -35.0 && confidence >= 55.0) final_signal = -2;
else if (score <= -25.0 && confidence >= 50.0) final_signal = -1;
else final_signal = 0;
// grading_mode 0 = the shipped pair rule (signal_aggregator._signal_pair /
// _quality_pair, literals verbatim); 1 / 2 = the research conviction number
// (signal_aggregator.conviction: PYMIN in the same argument order as
// Python's min(abs, 0.6 * conf); product as (abs * conf) / 100, never
// re-associated; thresholds ride PF from CONVICTION_THRESHOLDS).
const int gmode = PI[PI_grading_mode];
const double abs_score0 = fabs(score);
int final_signal;
int quality;
if (abs_score0 >= 40.0 && confidence >= 65.0 && mtf) quality = Q_APLUS;
else if (abs_score0 >= 33.0 && confidence >= 55.0 && mtf) quality = Q_A;
else if (abs_score0 >= 28.0 && confidence >= 50.0) quality = Q_B;
else if (abs_score0 >= 25.0 && confidence >= 45.0) quality = Q_C;
else if (abs_score0 >= 15.0) quality = Q_D;
else quality = Q_F;
if (gmode == 0) {
if (score >= 35.0 && confidence >= 55.0) final_signal = 2;
else if (score >= 25.0 && confidence >= 50.0) final_signal = 1;
else if (score <= -35.0 && confidence >= 55.0) final_signal = -2;
else if (score <= -25.0 && confidence >= 50.0) final_signal = -1;
else final_signal = 0;
if (abs_score0 >= 40.0 && confidence >= 65.0 && mtf) quality = Q_APLUS;
else if (abs_score0 >= 33.0 && confidence >= 55.0 && mtf) quality = Q_A;
else if (abs_score0 >= 28.0 && confidence >= 50.0) quality = Q_B;
else if (abs_score0 >= 25.0 && confidence >= 45.0) quality = Q_C;
else if (abs_score0 >= 15.0) quality = Q_D;
else quality = Q_F;
} else {
const double conv = (gmode == 1) ? PYMIN(abs_score0, 0.6 * confidence)
: abs_score0 * confidence / 100.0;
if (score > 0.0 && conv >= PF[PF_conv_strong]) final_signal = 2;
else if (score > 0.0 && conv >= PF[PF_conv_buy]) final_signal = 1;
else if (score < 0.0 && conv >= PF[PF_conv_strong]) final_signal = -2;
else if (score < 0.0 && conv >= PF[PF_conv_buy]) final_signal = -1;
else final_signal = 0;
if (conv >= PF[PF_conv_aplus] && mtf) quality = Q_APLUS;
else if (conv >= PF[PF_conv_a] && mtf) quality = Q_A;
else if (conv >= PF[PF_conv_b]) quality = Q_B;
else if (conv >= PF[PF_conv_c]) quality = Q_C;
else if (abs_score0 >= 15.0) quality = Q_D;
else quality = Q_F;
}
K2I(penalty_mask) = pmask; K2F(penalty_total) = total_penalty;
K2I(exhaustion_count) = exhaustion_count; K2I(momentum_fading) = momentum_fading ? 1 : 0;
@@ -1715,8 +1758,18 @@ void fb2_aggregate(const double* __restrict__ open_p, const double* __restrict__
// ── 6c. transitioning boost ──
if (regime == REG_transitioning && (quality == Q_B || quality == Q_C || quality == Q_D)) {
const double abs_score = fabs(score);
if (abs_score >= 30.0 && confidence >= 50.0 && mtf) quality = Q_A;
else if (abs_score >= 25.0 && confidence >= 45.0) quality = Q_B;
bool boost_a, boost_b;
if (gmode == 0) {
boost_a = abs_score >= 30.0 && confidence >= 50.0 && mtf;
boost_b = abs_score >= 25.0 && confidence >= 45.0;
} else {
const double conv6c = (gmode == 1) ? PYMIN(abs_score, 0.6 * confidence)
: abs_score * confidence / 100.0;
boost_a = conv6c >= PF[PF_conv_boost_a] && mtf;
boost_b = conv6c >= PF[PF_conv_boost_b];
}
if (boost_a) quality = Q_A;
else if (boost_b) quality = Q_B;
}
// ── 6d. exhausted trend cap ──
if (trending_any && exhaustion_count >= 2 && (quality == Q_C || quality == Q_D)) {