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
+12 -3
View File
@@ -23,7 +23,7 @@ cp config.json.example config.json # then fill i
venv/bin/pip install cupy-cuda12x "nvidia-cuda-runtime-cu12==12.9.*" # optional, dev box: backtester GPU backend venv/bin/pip install cupy-cuda12x "nvidia-cuda-runtime-cu12==12.9.*" # optional, dev box: backtester GPU backend
# (CuPy from pip needs that header wheel to compile kernels) # (CuPy from pip needs that header wheel to compile kernels)
venv/bin/python test_all.py # QA suite, no network; prints its own count venv/bin/python test_all.py # QA suite, no network; prints its own count
# (558 tests / 37 modules as of 2026-09-26) # (566 tests / 37 modules as of 2026-09-27)
venv/bin/python main.py --pair XAUUSD --timeframe H1 --detail # one signal report in the terminal venv/bin/python main.py --pair XAUUSD --timeframe H1 --detail # one signal report in the terminal
# --json full dump, --panel adds the advisory LLM panel, # --json full dump, --panel adds the advisory LLM panel,
# --timeframe M5 | M15 | H1 | H4 # --timeframe M5 | M15 | H1 | H4
@@ -46,7 +46,9 @@ realistic Oanda figure is 0.65 and must be passed explicitly), `--quality A+,A,B
`--export-csv out.csv`, `--detail`, `--set PATH=JSON` (research override of any config value; an `--export-csv out.csv`, `--detail`, `--set PATH=JSON` (research override of any config value; an
unknown path is a hard exit), `--export-features` (appends the 65 decision-time columns; never use unknown path is a hard exit), `--export-features` (appends the 65 decision-time columns; never use
it on a parity run because it changes the file), `--fixed-rr-tp N` (research only, not shipped), it on a parity run because it changes the file), `--fixed-rr-tp N` (research only, not shipped),
`--record DIR [--record-bars]` (research recorder, see section 9), `--backend auto|gpu|cpu` (`auto` `--set signals.grading_mode=min|product` (research only, 2026-09-27: grade on one conviction number
instead of the score + confidence floors; both FAILED rule 1 on M5 and M15, default `pair` is the
shipped rule and byte-identical), `--record DIR [--record-bars]` (research recorder, see section 9), `--backend auto|gpu|cpu` (`auto`
uses the CuPy GPU backend when CUDA works and falls back to NumPy with the reason in the `Backend:` uses the CuPy GPU backend when CUDA works and falls back to NumPy with the reason in the `Backend:`
line; `gpu` refuses to fall back; `cpu` forces the NumPy twins for a parity run; the env var line; `gpu` refuses to fall back; `cpu` forces the NumPy twins for a parity run; the env var
`FB2_BACKEND` sets the default). Both backends produce byte-identical books (section 9). `FB2_BACKEND` sets the default). Both backends produce byte-identical books (section 9).
@@ -173,6 +175,12 @@ later generator shipped a setup.
timeframe within 5 bars of the last alerted setup's bar (`_ALERT_COOLDOWN_BARS`, the backtester's timeframe within 5 bars of the last alerted setup's bar (`_ALERT_COOLDOWN_BARS`, the backtester's
entry cooldown; consecutive-bar re-entries were −25.1R of the −37.8R live book of 2026-09-04 → entry cooldown; consecutive-bar re-entries were −25.1R of the −37.8R live book of 2026-09-04 →
09-25). After that, a fingerprint (signal + direction) stops repeats; a grade upgrade re-alerts. 09-25). After that, a fingerprint (signal + direction) stops repeats; a grade upgrade re-alerts.
Since 2026-09-27 the header leads with the grade (`Grade A · BUY · XAU/USD · M15`) and the raw
score / confidence line is replaced by words from `main.plain_words` (`Direction: strong buy ·
Reliability: good · timeframes agree`; the cut-offs are the grade table's own, so the words never
disagree with the grade; the numbers stay in `--json`, `--detail`, the dashboard meters and the
signal log). The `/status` summary and the dashboard card carry the same words (`plain_words` in
the snapshot).
The setup block prints the trailing rule first as the tested exit, TP1/TP2 as guides with their R, The setup block prints the trailing rule first as the tested exit, TP1/TP2 as guides with their R,
the "Planned TP1 RR", and one line of validated-book statistics for the setup class the "Planned TP1 RR", and one line of validated-book statistics for the setup class
(`_book_rate_line`, from `agent_panel_base_rates.json`). The fingerprint is committed only after a successful send, (`_book_rate_line`, from `agent_panel_base_rates.json`). The fingerprint is committed only after a successful send,
@@ -381,7 +389,7 @@ day, 30 kept), `logs/dashboard.log`. Read the databases with `mode=ro`, never `i
## 6. Testing ## 6. Testing
- `venv/bin/python test_all.py` runs everything with mocked network. Expect - `venv/bin/python test_all.py` runs everything with mocked network. Expect
`RESULTS: 558 passed, 0 failed, 558 total` (2026-09-26). On a box with a visible CUDA device `RESULTS: 566 passed, 0 failed, 566 total` (2026-09-27). On a box with a visible CUDA device
Module 36 fails if the GPU backend falls back (the reason is in the message); with Module 36 fails if the GPU backend falls back (the reason is in the message); with
`FB2_BACKEND=cpu` or no CUDA the GPU-path tests print a notice and the NumPy twins are tested. `FB2_BACKEND=cpu` or no CUDA the GPU-path tests print a notice and the NumPy twins are tested.
Module 37 (the CUDA decision stage) adds about 85 s on the GPU because its references replay Module 37 (the CUDA decision stage) adds about 85 s on the GPU because its references replay
@@ -624,6 +632,7 @@ One line per validated change or verdict, newest first. Full evidence in the nam
| Date | Decision | Where the evidence is | | Date | Decision | Where the evidence is |
|---|---|---| |---|---|---|
| 2026-09-27 | Single "conviction" number vs the two-number grade (owner question: one number that is easier to understand and to decide on). Pre-registered; switch `signals.grading_mode` (`pair` default = the shipped rule, byte-identical on both scripts, all five pins reproduced; `min` = min(\|score\|, 0.6 × conf); `product` = \|score\| × conf / 100; thresholds from the control's corner points, no tuning; mirrored in K2, twin MD5-equal to the reference on both new paths). FAILED on M5 and M15: K-MIN moves 43–54 trades for −1.2R / −2.9R (a, b, e / a, b, d); K-PROD −29R on M15 with PF 1.29 and W3 DD 28.9R, neutral on M5. H1 / H4 K-PROD +11.9R / +17.0R on W3 by taking 40–100 extra low-score trades (H1 PF 1.98 → 1.75, DD doubles): a hypothesis, not evidence. Nothing ships; the knob stays research-only. Code review (high) before the runs: 6 findings fixed, 1 recorded. SHIPPED the display half of the question (owner request the same day): alerts lead with the grade and print score / confidence as words (`main.plain_words`, same line on Telegram, `/status`, CLI and the dashboard card); books untouched. Suite 566 | `backtest/research/conviction_campaign_2026-09-27.md` |
| 2026-09-26 | Quant review + live log 2026-09-04 → 09-25 (100 setups replayed on the scans' stored candles): stop-hunt claim REFUTED (71% of stop-outs ran ≥ 1R past the stop, same as the books); −25.1R of the −37.8R raw live book were consecutive-bar re-entries the backtester's cooldown never takes; the cooldown view sits at the 3rd–5th percentile of the books' three-week windows. SHIPPED: 5-bar alert cooldown; alert wording (trail first as the tested exit, TP1 a guide with its R, planned RR, validated-book line per class); M15 grade-A SELL block (`sell_min_quality` `A+, B`, PASSED a–f: W2 +154.15R/813 PF 1.53 vs +146.14/931 PF 1.43, OOS +68.5 vs +60.67, W3 DD +8%); 1R printed-target floor in every generator after the gates (`tp_print_floor_rr`, owner rule "TP1 at least 1:1"; label-only re-pin of all four timeframes). FAILED: M15 `min_rr_ratio` 1.0 (trade-identical, −2.0R); a pre-gate target floor (−24.6R). OWNER'S CALL: M5 `tp_floor_rr` 1.0 adds +48.9R on W2 with both halves up but max DD 10.1 → 15.9R (ladder 0.6–1.0 non-monotone); not shipped. Telegram / dashboard AI block rewritten for a new trader (owner rule): one plain headline against the usual odds, the chance "in 100", one reason for and one against, no verdict word or confidence figure; persona prompts ban trading jargon (`PANEL_PROMPT_VERSION` v2.1). Two delivery bugs from the DB checks fixed: the macro-source panel verdict never stored its probability (all v2 rows NULL); the level-breach rescan re-logged the 20:45 M15 bar three times every evening after the settlement pause (guard added to `_handle_breach`). Indicator grid and volume / daily-direction screens (Part C) not run | `backtest/research/rr_and_sell_campaign_2026-09-26.md` | | 2026-09-26 | Quant review + live log 2026-09-04 → 09-25 (100 setups replayed on the scans' stored candles): stop-hunt claim REFUTED (71% of stop-outs ran ≥ 1R past the stop, same as the books); −25.1R of the −37.8R raw live book were consecutive-bar re-entries the backtester's cooldown never takes; the cooldown view sits at the 3rd–5th percentile of the books' three-week windows. SHIPPED: 5-bar alert cooldown; alert wording (trail first as the tested exit, TP1 a guide with its R, planned RR, validated-book line per class); M15 grade-A SELL block (`sell_min_quality` `A+, B`, PASSED a–f: W2 +154.15R/813 PF 1.53 vs +146.14/931 PF 1.43, OOS +68.5 vs +60.67, W3 DD +8%); 1R printed-target floor in every generator after the gates (`tp_print_floor_rr`, owner rule "TP1 at least 1:1"; label-only re-pin of all four timeframes). FAILED: M15 `min_rr_ratio` 1.0 (trade-identical, −2.0R); a pre-gate target floor (−24.6R). OWNER'S CALL: M5 `tp_floor_rr` 1.0 adds +48.9R on W2 with both halves up but max DD 10.1 → 15.9R (ladder 0.6–1.0 non-monotone); not shipped. Telegram / dashboard AI block rewritten for a new trader (owner rule): one plain headline against the usual odds, the chance "in 100", one reason for and one against, no verdict word or confidence figure; persona prompts ban trading jargon (`PANEL_PROMPT_VERSION` v2.1). Two delivery bugs from the DB checks fixed: the macro-source panel verdict never stored its probability (all v2 rows NULL); the level-breach rescan re-logged the 20:45 M15 bar three times every evening after the settlement pause (guard added to `_handle_breach`). Indicator grid and volume / daily-direction screens (Part C) not run | `backtest/research/rr_and_sell_campaign_2026-09-26.md` |
| 2026-09-18 | Full-GPU research twin SHIPPED: `backtest/cudaBasedBacktesting.py` + `backtest/cuda_signal_kernels.py` (owner decisions: research only, `gpu_backtest.py` stays the base reference, every research flag supported, the one exemption from instructions item 6 recorded there). Proof: the five reference MD5s, four 6-month acceptance books (2026-03-18 → 2026-09-18, M5 and M15, $0.65 and $0.30), the M15 OOS `--export-features` CSV and `--record --record-bars` tables, and ten research-flag variants, all byte-identical to the reference script; Module 37 (16 tests) pins every stage against the live code. Books take 0.8–2.4 s instead of 13–115 s. Rule added: any signal change is a parity event for the twin | `backtest/research/cuda_backtester_2026-09-18.md` | | 2026-09-18 | Full-GPU research twin SHIPPED: `backtest/cudaBasedBacktesting.py` + `backtest/cuda_signal_kernels.py` (owner decisions: research only, `gpu_backtest.py` stays the base reference, every research flag supported, the one exemption from instructions item 6 recorded there). Proof: the five reference MD5s, four 6-month acceptance books (2026-03-18 → 2026-09-18, M5 and M15, $0.65 and $0.30), the M15 OOS `--export-features` CSV and `--record --record-bars` tables, and ten research-flag variants, all byte-identical to the reference script; Module 37 (16 tests) pins every stage against the live code. Books take 0.8–2.4 s instead of 13–115 s. Rule added: any signal change is a parity event for the twin | `backtest/research/cuda_backtester_2026-09-18.md` |
| 2026-09-18 | GPU backend made real: CuPy had never compiled a kernel on the dev box (`CUDA_PATH` missing for the pip layout), so every book to date was NumPy. SHIPPED: `backtest/gpu_kernels.py` (lazy, loud backend probe; `nvidia-cuda-runtime-cu12` header wheel; CUDA kernels for the EWM batch, the pandas rolling mean/var replica and the trade resolver, each a bit-for-bit twin of the CPU code), `--backend auto/gpu/cpu` + `FB2_BACKEND`, Module 36. All five reference MD5s reproduced on the GPU and with `--backend cpu`; precompute GPU == CPU on all 42 cached windows (2.34 M bars). Wall time roughly unchanged: the replay is CPU by design | `backtest/research/gpu_backend_2026-09-18.md` | | 2026-09-18 | GPU backend made real: CuPy had never compiled a kernel on the dev box (`CUDA_PATH` missing for the pip layout), so every book to date was NumPy. SHIPPED: `backtest/gpu_kernels.py` (lazy, loud backend probe; `nvidia-cuda-runtime-cu12` header wheel; CUDA kernels for the EWM batch, the pandas rolling mean/var replica and the trade resolver, each a bit-for-bit twin of the CPU code), `--backend auto/gpu/cpu` + `FB2_BACKEND`, Module 36. All five reference MD5s reproduced on the GPU and with `--backend cpu`; precompute GPU == CPU on all 42 cached windows (2.34 M bars). Wall time roughly unchanged: the replay is CPU by design | `backtest/research/gpu_backend_2026-09-18.md` |
+2
View File
@@ -419,6 +419,8 @@ def refuse_unsupported(platform_cfg: PlatformConfig, bt_cfg: BacktestConfig) ->
problems.append(f"big-candle window {n_bc + 3 * t.atr_period} bars (CUDA limit {ck.FEAT_MAX_P})") problems.append(f"big-candle window {n_bc + 3 * t.atr_period} bars (CUDA limit {ck.FEAT_MAX_P})")
if t.ema_mode not in ("4ema", "2ema"): if t.ema_mode not in ("4ema", "2ema"):
problems.append(f"technical.ema_mode={t.ema_mode!r}") problems.append(f"technical.ema_mode={t.ema_mode!r}")
if platform_cfg.signals.grading_mode not in ck.GRADING_MODE_CODES:
problems.append(f"signals.grading_mode={platform_cfg.signals.grading_mode!r}")
if problems: if problems:
raise SystemExit("cudaBasedBacktesting.py cannot run this configuration: " raise SystemExit("cudaBasedBacktesting.py cannot run this configuration: "
+ "; ".join(problems) + ". Use backtest/gpu_backtest.py.") + "; ".join(problems) + ". Use backtest/gpu_backtest.py.")
+67 -14
View File
@@ -202,6 +202,10 @@ K2_FLOAT_FIELDS = (
"cp_trigger", "cp_height", "pb_retrace_atr", "bc_ratio", "rf_adx", "cp_trigger", "cp_height", "pb_retrace_atr", "bc_ratio", "rf_adx",
) )
SKIP_NAMES = {1: "WEEKEND_FILTER", 2: "DAY_FILTER", 3: "SESSION_WINDOW"} 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 ──────────────────────────────────────────────────── # ── Config packing ────────────────────────────────────────────────────
PARAMS_F = ( PARAMS_F = (
@@ -220,6 +224,10 @@ PARAMS_F = (
"big_candle_atr_multiple", "big_candle_lookback_hours", "big_candle_atr_multiple", "big_candle_lookback_hours",
"regime_filter_min_adx", "pip_value", "fb2_ema_lo", "regime_filter_min_adx", "pip_value", "fb2_ema_lo",
"tp_scale_aplus", "tp_scale_a", "max_penalty", "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 = ( PARAMS_I = (
"ema_trend_period", "ema_mode_2ema", "ema_partial_neutral", "rsi_regime_aware", "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", "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", "quality_filter_mask", "strategies_mask", "primary_is_m5", "primary_bar_secs",
"regime_filter", "n_confirm", "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 # 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(), "fb2_ema_lo": fb2_ema_lo_from_env(),
"tp_scale_aplus": 2.0, "tp_scale_a": 1.5, "max_penalty": 30.0, "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) from providers.base import BaseDataProvider # CANDLE_SECONDS (the aggregator's clock)
bar_secs = BaseDataProvider.CANDLE_SECONDS[platform_cfg.primary_timeframe] bar_secs = BaseDataProvider.CANDLE_SECONDS[platform_cfg.primary_timeframe]
strategies = list(bt_cfg.strategies_only or []) 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, "primary_bar_secs": bar_secs,
"regime_filter": 1 if b.regime_filter else 0, "regime_filter": 1 if b.regime_filter else 0,
"n_confirm": len(platform_cfg.confirmation_timeframes), "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) 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) 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 -= total_penalty;
confidence = PYMAX(0.0, PYMIN(95.0, confidence)); confidence = PYMAX(0.0, PYMIN(95.0, confidence));
if (exhaustion_blocked) { WARN(W_exhaustion_block, (double)exhaustion_count); confidence = 0.0; } if (exhaustion_blocked) { WARN(W_exhaustion_block, (double)exhaustion_count); confidence = 0.0; }
int final_signal; // grading_mode 0 = the shipped pair rule (signal_aggregator._signal_pair /
if (score >= 35.0 && confidence >= 55.0) final_signal = 2; // _quality_pair, literals verbatim); 1 / 2 = the research conviction number
else if (score >= 25.0 && confidence >= 50.0) final_signal = 1; // (signal_aggregator.conviction: PYMIN in the same argument order as
else if (score <= -35.0 && confidence >= 55.0) final_signal = -2; // Python's min(abs, 0.6 * conf); product as (abs * conf) / 100, never
else if (score <= -25.0 && confidence >= 50.0) final_signal = -1; // re-associated; thresholds ride PF from CONVICTION_THRESHOLDS).
else final_signal = 0; const int gmode = PI[PI_grading_mode];
const double abs_score0 = fabs(score); const double abs_score0 = fabs(score);
int final_signal;
int quality; int quality;
if (abs_score0 >= 40.0 && confidence >= 65.0 && mtf) quality = Q_APLUS; if (gmode == 0) {
else if (abs_score0 >= 33.0 && confidence >= 55.0 && mtf) quality = Q_A; if (score >= 35.0 && confidence >= 55.0) final_signal = 2;
else if (abs_score0 >= 28.0 && confidence >= 50.0) quality = Q_B; else if (score >= 25.0 && confidence >= 50.0) final_signal = 1;
else if (abs_score0 >= 25.0 && confidence >= 45.0) quality = Q_C; else if (score <= -35.0 && confidence >= 55.0) final_signal = -2;
else if (abs_score0 >= 15.0) quality = Q_D; else if (score <= -25.0 && confidence >= 50.0) final_signal = -1;
else quality = Q_F; 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(penalty_mask) = pmask; K2F(penalty_total) = total_penalty;
K2I(exhaustion_count) = exhaustion_count; K2I(momentum_fading) = momentum_fading ? 1 : 0; 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 ── // ── 6c. transitioning boost ──
if (regime == REG_transitioning && (quality == Q_B || quality == Q_C || quality == Q_D)) { if (regime == REG_transitioning && (quality == Q_B || quality == Q_C || quality == Q_D)) {
const double abs_score = fabs(score); const double abs_score = fabs(score);
if (abs_score >= 30.0 && confidence >= 50.0 && mtf) quality = Q_A; bool boost_a, boost_b;
else if (abs_score >= 25.0 && confidence >= 45.0) quality = Q_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 ── // ── 6d. exhausted trend cap ──
if (trending_any && exhaustion_count >= 2 && (quality == Q_C || quality == Q_D)) { if (trending_any && exhaustion_count >= 2 && (quality == Q_C || quality == Q_D)) {
@@ -0,0 +1,250 @@
# Single "conviction" number vs the two-number grade — pre-registered campaign, 2026-09-27
Owner question (2026-09-27): "Can there be a combination of both [score and confidence] that is
easier to understand and also for the system to make decisions?" Chosen route (owner): research a
single decision number. This file was written BEFORE any candidate run. Rules first, results
appended below them. Nothing ships from this campaign; a pass is a hypothesis for a per-timeframe
override in a separate commit (CLAUDE.md rule 2).
## What is tested
Today (`signal_aggregator._compute_final_signal`, gate 6c) a grade needs BOTH a score floor and a
confidence floor: A+ |score| ≥ 40 & conf ≥ 65 & mtf; A 33 & 55 & mtf; B 28 & 50; C 25 & 45;
D |score| ≥ 15; STRONG 35 & 55; BUY/SELL 25 & 50; 6c boost A 30 & 50 & mtf, B 25 & 45.
Candidates replace the pair with one number. Thresholds are fixed analytically from the control's
corner points — no grid, no tuning (a data-mined threshold would need its own holdout):
- **K-MIN**: `conviction = min(|score|, 0.6 × confidence)`. Grades A+ ≥ 40 (mtf), A ≥ 33 (mtf),
B ≥ 28, C ≥ 25, D |score| ≥ 15 unchanged, else F; STRONG ≥ 35, BUY/SELL ≥ 25; 6c boost A ≥ 30
(mtf), B ≥ 25. (The control's corner ratios score/conf are 0.615, 0.60, 0.56, 0.556, so 0.6 is the
natural single scale.) Against the control: A+ needs conf ≥ 66.7 (was 65), B ≥ 46.7 (50),
C ≥ 41.7 (45), STRONG ≥ 58.3 (55), BUY/SELL ≥ 41.7 (50); score floors unchanged.
- **K-PROD**: `conviction = |score| × confidence / 100`. A+ ≥ 26 (40×65), A ≥ 18.15 (33×55),
B ≥ 14 (28×50), C ≥ 11.25 (25×45), D |score| ≥ 15, else F; STRONG ≥ 19.25 (35×55),
BUY/SELL ≥ 12.5 (25×50); 6c A ≥ 15 (30×50), B ≥ 11.25 (25×45). A genuine trade-off: high
confidence can lift a mid score and vice versa. Interaction on record: a product B/C with
|score| < 25 is demoted to D on M5 by the live gate 1 (`m5_min_abs_score` 25); the gate stays,
the count of demotions is reported (Step E). Property on record (code review, before the runs):
the D rung stays `|score| ≥ 15` in both candidates, and under the product form B/C can sit below
it (B needs |score| ≥ 14.74 at the 95 confidence cap, C ≥ 11.85), so an M15/H1/H4 product book can
hold a trade with |score| < 15. Kept as pre-registered (a score floor would make it a pair rule
again); the count of |score| < 15 and < 25 trades per candidate book is reported.
- Geometric mean √(|score|·conf) is a monotone transform of the product (same partition at
thresholds √T) — not a separate candidate.
- Direction stays sign(score); mtf agreement stays required for A+/A; the exhaustion hard block
(conf = 0) gives conviction 0 in both forms, so NEUTRAL and at most D, as today.
Switch: `signals.grading_mode` (`"pair"` default = today's literal rule, `"min"`, `"product"`),
read in `_compute_final_signal` and gate 6c (`signal_aggregator.py` helpers `_signal_pair`,
`_quality_pair`, `_transitioning_boost`, `conviction`, `CONVICTION_THRESHOLDS`), mirrored in K2
(`backtest/cuda_signal_kernels.py`, `PI_grading_mode`, `PF_conv_*` packed from the live module),
refused by the twin for other values. `pair` is byte-identical to HEAD `1454ea1` (Step A proves it).
Note: `engine_config_fingerprint` hashes `SignalConfig`, so the live fingerprint changes at the
deploy of this field even in `pair` mode (label-only; split signal-log comparisons there).
## Harness
Twin command template (research twin, CLAUDE.md §3/§9; seconds per book):
`venv/bin/python backtest/cudaBasedBacktesting.py --pair XAUUSD --spread <0.65|0.30>
--primary-tf <5min|15min|1h|4h> --start <s> --end <e> [--set signals.grading_mode=<min|product>]
--export-csv <out>`. Reference script (`backtest/gpu_backtest.py`, same flags, `--workers 12`)
for the neutrality proof and every paired MD5. Factory configuration; `--set` is the only override.
Evidence order (all must hold before any candidate number is read):
1. **Neutrality**: with the new code and no `--set`, the five pins reproduce on BOTH scripts
(M5 W2 `e599c8a8652f95b7fcdf5a7ef2b564f5`, M15 W2 `5b65ecf6fad9a8da412749066fd50d36`,
M15 OOS `09b8a8e7f5833436c20aef60ca0a7ff7`, H1 W3 `a44fd19b4067f46e5bd98d50cc3eff92`,
H4 W3 `f3587bae3c5dc3eda468ea3588b17dcb`), plus the twin with an explicit
`--set signals.grading_mode=pair` on M15 OOS.
2. **Twin parity on the new paths**: each candidate's M15 OOS $0.65 book is MD5-equal on both scripts.
3. A passing candidate is re-run once on `gpu_backtest.py` (W2, $0.65) and the two MD5s must be equal.
Windows: W2 2024-09-05 → 2026-09-05 (halves split at 2025-09-05); OOS 2024-06-01 → 2025-03-30
(halves at 2024-10-30); W3 2023-09-05 → 2026-09-05 (halves at 2025-03-05; max-DD clause); 5-year
2021-08-01 → 2026-08-04 (per-year clause; cache `20210531_20260804`). Costs: $0.65 headline, $0.30
comparability. Books are written outside the tree (§9 keeps no CSVs in-tree).
Code review gate (owner instruction): the diff is reviewed with the `code-review` skill before Step A;
outcome recorded here. Outcome (2026-09-27, `code-review` high, before Step A): 7 findings, 6 fixed in the same working
tree — kernel mode codes pinned by name (`GRADING_MODE_CODES`) and checked against the live list;
no zero-threshold fallback for a non-pair mode; the twin's refusal reads the kernel's map; `conviction()`
refuses modes it has no formula for; one `_final_signal_and_grade` dispatch point; `engine_config_fingerprint`
omits the switch under `pair` so live rows keep their fingerprint; CLAUDE.md test counts updated. One
recorded, not changed: the D rung under the product form (above). Suite green after the fixes.
## Candidates
| # | TF | `--set` | Hypothesis |
|---|---|---|---|
| K-MIN | M5, M15 (H1/H4 for the record) | `signals.grading_mode=min` | one scale on the score axis; nearly the control, loosens B/C/BUY confidence, tightens A+ |
| K-PROD | M5, M15 (H1/H4 for the record) | `signals.grading_mode=product` | genuine trade-off between the two axes; confidence can lift a mid score |
## Acceptance rule (CLAUDE.md rule 1, written before the runs)
A candidate PASSES only if, at $0.65, on W2: (a) total R ≥ control + 5R; (b) both halves ≥ the
control's halves − 1R (no half may fund the other); (c) PF ≥ 1.3 and ≥ control − 0.02; (d) OOS
total R ≥ control − 1R; (e) W3 max drawdown ≤ control + 10%; (f) the same sign of ΔR at $0.30.
A candidate that changes fewer than 20 trades on W2 is "no evidence", not a pass.
Judge run-level numbers, never the added/removed bucket alone (the 5-bar cooldown displaces
entries). Trade diff keyed on (entry time, direction).
Campaign clause (g): on the 5-year window at $0.65, no calendar year in which the control is
positive goes below −5R under the candidate (trend-artefact guard, `fixedtp_2026-08-05.md`
precedent); a W2 gain that lives in a single year is read as an artefact and noted.
The rule is judged independently on M5 W2 and M15 W2: the mode is read per config, so a pass ships
as a per-timeframe factory override (rule 2) and a timeframe where it fails keeps `pair`. H1 and H4
are run on W3 for the record (ΔR at $0.65, trades changed); they are not ship candidates here — a
positive H1/H4 result is a hypothesis for its own pre-registration. Nothing ships if no clause set
passes; the field stays a research-only knob (precedent `--fixed-rr-tp`).
## Run list
Step 0 suite green (Module 37 on the GPU) and the review gate. Step A neutrality (11 runs).
Step B twin parity on the new paths (4 runs). Step C books on the twin: {M5 W2, M5 OOS, M5 W3,
M15 W2, M15 OOS, M15 W3, H1 W3, H4 W3} × {$0.65, $0.30} × {control, min, product}. Step D per-year
(5-year, M5 and M15, $0.65, control/min/product). Step E K-PROD M5 W2 $0.65 with
`--record DIR --record-bars` (MD5 equal to the Step C book) to count `m5_score` demotions.
Step F reference-script rerun for any passer.
## Results
<!-- appended after the runs; nothing above this line changes -->
Reference script `Backend: CuPy GPU (NVIDIA GeForce RTX 4070 SUPER, cupy 14.0.1)`, `GPU cache fallbacks: 0`;
twin `Backend: CuPy GPU (NVIDIA GeForce RTX 4070 SUPER, cupy 14.0.1)`, `GPU cache fallbacks: 0 (no per-bar
recompute path in cudaBasedBacktesting)`. Every run from the kept cache windows.
### Step A — neutrality (new code, `grading_mode` at its default, no `--set`)
| Book | Pin | `gpu_backtest.py` | `cudaBasedBacktesting.py` |
|---|---|---|---|
| M5 W2 $0.65 | `e599c8a8652f95b7fcdf5a7ef2b564f5` | equal | equal |
| M15 W2 $0.65 | `5b65ecf6fad9a8da412749066fd50d36` | equal | equal |
| M15 OOS $0.65 | `09b8a8e7f5833436c20aef60ca0a7ff7` | equal | equal; also equal with an explicit `--set signals.grading_mode=pair` |
| H1 W3 $0.65 | `a44fd19b4067f46e5bd98d50cc3eff92` | equal | equal |
| H4 W3 $0.65 | `f3587bae3c5dc3eda468ea3588b17dcb` | equal | equal |
The refactor (helpers + switch) is byte-neutral on both scripts: the five pins stand.
### Step B — twin parity on the new code paths (M15 OOS $0.65)
| Candidate | `gpu_backtest.py` | `cudaBasedBacktesting.py` |
|---|---|---|
| K-MIN | `22726d8500383ce02e0a3cbf0412f6d8` | `22726d8500383ce02e0a3cbf0412f6d8` (equal) |
| K-PROD | `d1ae11651d5d7c8066b7e8addc0c7f36` | `d1ae11651d5d7c8066b7e8addc0c7f36` (equal) |
The twin's books below therefore count as evidence (instructions item 6, CUDA-twin addendum).
### Books (all on the twin; the control rows reproduce the reference script's MD5s)
Halves: W2 at 2025-09-05, OOS at 2024-10-30, W3 at 2025-03-05. Trades / R from the 63-column book.
| TF | Window | Cost | Candidate | Trades | Total R | H1 | H2 | WR | PF | Max DD | MD5 (8) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| M5 | W2 | $0.65 | control | 781 | +146.07 | +26.42 | +119.65 | 52.9% | 1.52 | 10.1 | e599c8a8 |
| M5 | W2 | $0.65 | K-MIN | 812 | +144.86 | +24.73 | +120.13 | 52.8% | 1.50 | 11.5 | 2ab2b573 |
| M5 | W2 | $0.65 | K-PROD | 805 | +147.33 | +29.27 | +118.06 | 52.5% | 1.52 | 11.6 | a37dbb71 |
| M5 | W2 | $0.30 | control / K-MIN / K-PROD | 781 / 812 / 805 | +162.09 / +161.17 / +163.96 | +31.71 / +30.15 / +34.70 | +130.37 / +131.02 / +129.26 | | 1.60 / 1.57 / 1.59 | 8.9 / 10.6 / 10.9 | 7ab48db9 / 64f26c30 / ed14d7ec |
| M5 | OOS | $0.65 | control | 86 | +9.47 | +0.01 | +9.46 | 53.5% | 1.33 | 8.2 | 65c46e62 |
| M5 | OOS | $0.65 | K-MIN | 94 | +14.32 | +2.37 | +11.94 | 56.4% | 1.51 | 6.4 | bec7c87f |
| M5 | OOS | $0.65 | K-PROD | 91 | +13.03 | +0.10 | +12.93 | 54.9% | 1.45 | 4.5 | 217d0134 |
| M5 | OOS | $0.30 | control / K-MIN / K-PROD | 86 / 94 / 91 | +12.42 / +17.34 / +16.05 | | | | 1.46 / 1.66 / 1.58 | 6.7 / 4.8 / 4.1 | |
| M5 | W3 | $0.65 | control | 870 | +153.64 | +16.73 | +136.91 | 53.3% | 1.50 | 10.1 | d20ec8a3 |
| M5 | W3 | $0.65 | K-MIN | 905 | +153.84 | +20.64 | +133.20 | 53.3% | 1.48 | 11.5 | 5ad0c381 |
| M5 | W3 | $0.65 | K-PROD | 896 | +154.60 | +20.12 | +134.48 | 52.9% | 1.49 | 11.6 | 466bda90 |
| M5 | W3 | $0.30 | control / K-MIN / K-PROD | 870 / 905 / 896 | +172.40 / +172.92 / +173.99 | | | | 1.57 / 1.55 / 1.56 | 8.9 / 10.6 / 10.9 | |
| M15 | W2 | $0.65 | control | 813 | +154.15 | +70.10 | +84.04 | 62.2% | 1.53 | 15.6 | 5b65ecf6 |
| M15 | W2 | $0.65 | K-MIN | 827 | +151.25 | +69.05 | +82.21 | 61.9% | 1.51 | 16.0 | 5c7918dc |
| M15 | W2 | $0.65 | K-PROD | 1106 | +125.17 | +62.26 | +62.91 | 58.2% | 1.29 | 28.9 | ecfb62e5 |
| M15 | W2 | $0.30 | control / K-MIN / K-PROD | 813 / 827 / 1106 | +167.53 / +164.82 / +144.91 | +78.61 / +77.62 / +74.30 | +88.91 / +87.20 / +70.61 | | 1.59 / 1.57 / 1.34 | 14.5 / 14.9 / 26.8 | 15486607 / 5ab311cf / 582019a7 |
| M15 | OOS | $0.65 | control | 313 | +68.50 | +18.87 | +49.63 | 63.6% | 1.67 | 12.4 | 09b8a8e7 |
| M15 | OOS | $0.65 | K-MIN | 314 | +65.64 | +19.20 | +46.44 | 63.4% | 1.64 | 12.4 | 22726d85 |
| M15 | OOS | $0.65 | K-PROD | 408 | +53.98 | +26.67 | +27.32 | 59.1% | 1.36 | 17.3 | d1ae1165 |
| M15 | OOS | $0.30 | control / K-MIN / K-PROD | 313 / 314 / 408 | +76.21 / +73.36 / +64.35 | | | | 1.77 / 1.74 / 1.44 | 11.1 / 11.1 / 16.4 | |
| M15 | W3 | $0.65 | control | 1105 | +195.79 | +76.87 | +118.92 | 61.1% | 1.49 | 20.9 | 70195fb0 |
| M15 | W3 | $0.65 | K-MIN | 1126 | +191.25 | +71.90 | +119.35 | 60.7% | 1.47 | 21.3 | 23edd8bc |
| M15 | W3 | $0.65 | K-PROD | 1478 | +186.24 | +84.70 | +101.54 | 58.3% | 1.33 | 28.9 | 0246cbb4 |
| M15 | W3 | $0.30 | control / K-MIN / K-PROD | 1105 / 1126 / 1478 | +216.53 / +212.32 / +215.54 | | | | 1.55 / 1.53 / 1.39 | 19.6 / 20.0 / 26.8 | |
| H1 | W3 | $0.65 | control | 253 | +70.02 | +26.26 | +43.76 | 64.4% | 1.98 | 4.4 | a44fd19b |
| H1 | W3 | $0.65 | K-MIN | 268 | +74.81 | +28.75 | +46.06 | 64.9% | 1.99 | 5.7 | 24674555 |
| H1 | W3 | $0.65 | K-PROD | 351 | +81.88 | +30.68 | +51.19 | 60.1% | 1.75 | 8.5 | 2e8df9cc |
| H1 | W3 | $0.30 | control / K-MIN / K-PROD | 253 / 268 / 351 | +72.74 / +77.64 / +85.91 | | | | 2.03 / 2.04 / 1.80 | 4.3 / 5.6 / 8.3 | |
| H4 | W3 | $0.65 | control | 114 | +21.68 | +4.91 | +16.77 | 57.9% | 1.59 | 5.4 | f3587bae |
| H4 | W3 | $0.65 | K-MIN | 117 | +22.57 | +4.91 | +17.66 | 58.1% | 1.59 | 5.4 | 3ed9a44e |
| H4 | W3 | $0.65 | K-PROD | 151 | +38.69 | +13.97 | +24.72 | 58.9% | 1.78 | 6.3 | b696b617 |
| H4 | W3 | $0.30 | control / K-MIN / K-PROD | 114 / 117 / 151 | +22.52 / +23.43 / +39.88 | | | | 1.61 / 1.62 / 1.81 | 5.0 / 5.0 / 6.0 | |
### Trade diffs at $0.65 (key = entry time + direction; W2 for M5/M15, W3 for H1/H4)
- M5 K-MIN: 37 added (+1.74R), 6 removed (+4.53R), 775 common (+143.12R vs +141.54R in the control:
the cooldown shifts a few entries). Grade mix B 572 → 606, A 187 → 186, C 21 → 19. Changed 43.
- M5 K-PROD: 74 added (+16.51R), 50 removed (+10.12R), 731 common (+130.83R vs +135.95R). Grade
mix A 187 → 263, A+ 1 → 3, C 21 → 8; XAU_ATR setups 32 → 57 (more A grades reach the ATR
generator). Changed 124.
- M15 K-MIN: 34 added (−0.34R), 20 removed (+3.42R), 793 common (+151.59R vs +150.73R). A+ 22 → 10
(the A+ confidence floor rises to 66.7), B 443 → 465. Changed 54.
- M15 K-PROD: 544 added (+2.66R), 251 removed (+16.64R), 562 common (+122.51R vs +137.50R). The
book grows 813 → 1106 (C 60 → 196, A+ 22 → 79); 295 candidate trades carry |score| < 25 and earn
+5.11R between them. Changed 795.
- H1 (for the record): K-MIN +20 / −5, 248 common, ΔR +4.79; K-PROD +126 / −28, 225 common,
ΔR +11.86 with PF 1.98 → 1.75 and max DD 4.4 → 8.5R; 63 trades with |score| < 25 (+7.38R).
- H4 (for the record): K-MIN +3 / −0, ΔR +0.89 (3 trades changed: no evidence); K-PROD +40 / −3,
ΔR +17.01 with PF 1.59 → 1.78 and DD 5.4 → 6.3R, 28 trades with |score| < 25 (+15.41R).
- No candidate book on any timeframe holds a trade with |score| < 15 except the M5 chart-pattern
entries the control also has (127 XAU_CP trades, forced grade B by design, identical in all three
M5 books). The D-rung property recorded above did not materialise.
- Step E (M5 W2 $0.65 K-PROD with `--record --record-bars`, book MD5 `a37dbb71…` = the Step C
book): 82 analysed bars carry the gate-1 warning `M5 FILTER: |score| … < 25 minimum` as their
first warning, every one of them ending at grade D. That is the whole M5 gate-1 interaction.
### Per year, 5-year window 2021-08-01 → 2026-08-04 at $0.65 (twin)
| TF | Year | control n / R | K-MIN n / R | K-PROD n / R |
|---|---|---|---|---|
| M5 | 2021 | 12 / −1.99 | 14 / −0.28 | 13 / −0.55 |
| M5 | 2022 | 50 / −2.18 | 54 / −0.43 | 52 / +1.29 |
| M5 | 2023 | 61 / −2.75 | 62 / −2.10 | 61 / −3.57 |
| M5 | 2024 | 102 / +0.97 | 108 / +4.05 | 106 / +4.19 |
| M5 | 2025 | 332 / +40.54 | 346 / +38.87 | 342 / +41.63 |
| M5 | 2026 | 348 / +92.49 | 361 / +87.15 | 358 / +85.73 |
| M5 | total | 905 / +127.08 (PF 1.39, DD 13.5) | 945 / +127.25 (1.37, 12.4) | 932 / +128.72 (1.38, 12.2) |
| M15 | 2021 | 78 / −10.88 | 84 / −11.20 | 90 / −6.29 |
| M15 | 2022 | 267 / −24.63 | 273 / −26.34 | 340 / −47.01 |
| M15 | 2023 | 211 / +11.74 | 216 / +11.10 | 261 / +8.63 |
| M15 | 2024 | 343 / +53.35 | 345 / +49.12 | 448 / +62.45 |
| M15 | 2025 | 425 / +81.16 | 438 / +85.91 | 576 / +83.40 |
| M15 | 2026 | 224 / +38.10 | 227 / +32.98 | 317 / +4.32 |
| M15 | total | 1548 / +148.84 (PF 1.25, DD 48.3) | 1583 / +141.58 (1.23, 48.7) | 2032 / +105.50 (1.13, 77.4) |
### Verdicts against the pre-registered rule (W2 at $0.65 unless stated)
| Candidate | TF | (a) ΔR ≥ +5 | (b) halves ≥ ctrl − 1 | (c) PF ≥ 1.3 & ≥ ctrl − 0.02 | (d) OOS ≥ ctrl − 1 | (e) W3 DD ≤ ctrl + 10% | (f) same sign at $0.30 | (g) 5-yr years | changed | Verdict |
|---|---|---|---|---|---|---|---|---|---|---|
| K-MIN | M5 | ✗ −1.21 | ✗ H1 24.73 < 25.42 | ✓ 1.50 | ✓ +14.32 vs 9.47 | ✗ 11.5 > 11.1 | ✓ −0.92 | ✓ | 43 | **FAIL (a, b, e)** |
| K-PROD | M5 | ✗ +1.26 | ✗ H2 118.06 < 118.65 | ✓ 1.52 | ✓ +13.03 | ✗ 11.6 > 11.1 | ✓ +1.87 | ✓ | 124 | **FAIL (a, b, e)** |
| K-MIN | M15 | ✗ −2.90 | ✗ 69.05 < 69.10, 82.21 < 83.04 | ✓ 1.51 | ✗ 65.64 < 67.50 | ✓ 21.3 ≤ 23.0 | ✓ −2.71 | ✓ | 54 | **FAIL (a, b, d)** |
| K-PROD | M15 | ✗ −28.98 | ✗ | ✗ 1.29 | ✗ 53.98 | ✗ 28.9 > 23.0 | ✓ −22.62 | ✓ (2026 +4.32, still positive) | 795 | **FAIL (a–e)** |
Reading. K-MIN is the control with a slightly looser confidence floor on B/C/BUY and a tighter one
on A+: it moves 43–54 trades on W2 and changes nothing of substance (−1.2R on M5, −2.9R on M15, PF
within 0.02, OOS mixed). It is a re-labelling of the same rule with a small loss, not a better
decision number. K-PROD is a different rule: on M15 it lets confidence substitute for score, grows
the book by a third and gives back −29R with PF 1.29 and a W3 drawdown of 28.9R (5-year 77.4R, PF
1.13); on M5 it is neutral. On H1 and H4 the product rule adds R (+11.9R and +17.0R on W3) by
taking 40–100 extra low-score trades; H1's PF drops 1.98 → 1.75 and its drawdown doubles, H4 stays
"unproven" (114 → 151 trades). Both are hypotheses for their own pre-registration, not evidence.
## Not shipped (2026-09-27)
No candidate passes on M5 or M15. The two-number rule stays as validated; `signals.grading_mode`
remains a research-only knob (default `pair`, byte-identical books). Follow-ups:
- The "easier to understand" half of the owner's question is a display task: the grade already
combines score, confidence and timeframe agreement, and the surfaces can lead with it and
word the two raw numbers in plain language (no signal change, books untouched).
- H1 / H4 K-PROD (+11.9R / +17.0R on W3, more trades, lower H1 PF, higher DD) may be pre-registered
on its own if the owner wants it; H4 has too few trades for rule 1 and H1's edge already lives
in a small book (253 trades in 3 years).
- Do not re-run these two formulas on M5 or M15 with other thresholds: a tuned threshold would
need its own holdout and the corner-point versions here bracket the plausible range.
+10
View File
@@ -103,6 +103,16 @@ class TechnicalConfig:
class SignalConfig: class SignalConfig:
"""Signal scoring & filtering.""" """Signal scoring & filtering."""
mtf_agreement_threshold: int = 2 # of 3 timeframes mtf_agreement_threshold: int = 2 # of 3 timeframes
# RESEARCH-ONLY grading rule switch (campaign 2026-09-27,
# backtest/research/conviction_campaign_2026-09-27.md). "pair" is the
# shipped two-number rule (a score floor AND a confidence floor, the
# literal table in signal_aggregator._quality_pair); "min" and "product"
# grade on ONE conviction number (min(|score|, 0.6 x confidence) or
# |score| x confidence / 100, thresholds in
# signal_aggregator.CONVICTION_THRESHOLDS). Read by
# SignalAggregator._compute_final_signal and gate 6c; packed for the CUDA
# twin. Any value other than "pair" is a rule-1 event.
grading_mode: str = "pair"
min_rr_ratio: float = 0.5 min_rr_ratio: float = 0.5
# TP tightening (May 2026) was tuned on the pre-spread backtester. # TP tightening (May 2026) was tuned on the pre-spread backtester.
+5 -3
View File
@@ -71,8 +71,9 @@
<h2>3 · Reading a bot message</h2> <h2>3 · Reading a bot message</h2>
<p>Here is a typical signal, with plain-English notes below it:</p> <p>Here is a typical signal, with plain-English notes below it:</p>
<div class="signal-box">🔍 XAU/USD Signal Analysis (H1) ← gold, on the 1-hour chart <div class="signal-box">🔍 XAU/USD Signal Analysis (H1) ← gold, on the 1-hour chart
🟢 BUY Grade: A | 64% confidence ← what to do + how good the setup looks 🟢 Grade A · BUY · XAU/USD · H1 🥇 ← how good the setup is + what to do
✅ MTF aligned ← bigger and smaller charts agree 📐 Direction: strong buy · Reliability:
good · timeframes agree ← the bot's two numbers, in words
📋 Indicators … ← the bot's 9 checks (you can skip this part) 📋 Indicators … ← the bot's 9 checks (you can skip this part)
━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━
🚀 TRADE SETUP ← ↓ THIS is the part you act on ↓ 🚀 TRADE SETUP ← ↓ THIS is the part you act on ↓
@@ -89,7 +90,8 @@
58% winners, avg +0.19R per trade ← what this kind of signal did in the backtests</div> 58% winners, avg +0.19R per trade ← what this kind of signal did in the backtests</div>
<ul> <ul>
<li><b>Grade (A+, A, B, C):</b> the bot's own mark for how clean the setup is. A+ is best. It never sends anything worse than C.</li> <li><b>Grade (A+, A, B, C):</b> the bot's own mark for how clean the setup is. A+ is best. It never sends anything worse than C.</li>
<li><b>Confidence and the indicator list:</b> extra detail for curious people. You don't need to understand it to trade the signal.</li> <li><b>Direction and reliability:</b> plain words for the bot's two internal numbers. Direction says how strongly the checks lean one way (very strong, strong, clear, weak). Reliability says how much the bot trusts that reading (high, good, fair, low). "Timeframes agree" means the bigger and smaller charts point the same way.</li>
<li><b>The indicator list:</b> extra detail for curious people. You don't need to understand it to trade the signal.</li>
<li><b>⚠️ Warning lines:</b> things the bot noticed, like high volatility. They are "be aware," not "don't trade."</li> <li><b>⚠️ Warning lines:</b> things the bot noticed, like high volatility. They are "be aware," not "don't trade."</li>
<li><b>🌪 Violent-move watch:</b> now and then the bot adds a line saying the next hour could produce very big candles — usually because an important American economic number is about to come out, or the market is in an hour that is historically wild, or the price has stretched a long way from normal. It is only a "hold on tight" note. It does not change the signal, and it is not a reason to close or move your stop loss early — the bot was tested doing exactly that and it lost money. Your stop loss is already your protection; leave it where it is.</li> <li><b>🌪 Violent-move watch:</b> now and then the bot adds a line saying the next hour could produce very big candles — usually because an important American economic number is about to come out, or the market is in an hour that is historically wild, or the price has stretched a long way from normal. It is only a "hold on tight" note. It does not change the signal, and it is not a reason to close or move your stop loss early — the bot was tested doing exactly that and it lost money. Your stop loss is already your protection; leave it where it is.</li>
<li><b>📰 News line:</b> a short plain-English note on what is moving the gold price right now and why, written from the last few hours of headlines. It is background information, not a reason to trade.</li> <li><b>📰 News line:</b> a short plain-English note on what is moving the gold price right now and why, written from the last few hours of headlines. It is background information, not a reason to trade.</li>
+1 -1
View File
@@ -69,7 +69,7 @@
<table> <table>
<tr><th style="width:28%">Field</th><th>What it means</th></tr> <tr><th style="width:28%">Field</th><th>What it means</th></tr>
<tr><td><b>Signal + grade</b></td><td>Direction (BUY / SELL / STRONG_*) plus a quality grade <b>A+ → F</b>. A trade setup is only attached at grade C or better. Higher grades = more indicator agreement and cleaner regime.</td></tr> <tr><td><b>Signal + grade</b></td><td>Direction (BUY / SELL / STRONG_*) plus a quality grade <b>A+ → F</b>. A trade setup is only attached at grade C or better. Higher grades = more indicator agreement and cleaner regime.</td></tr>
<tr><td><b>Score / confidence</b></td><td>The regime-weighted composite of all nine indicators. Bigger absolute score = stronger consensus.</td></tr> <tr><td><b>Direction / reliability line</b></td><td>Since 2026-09-27 the alert prints the two raw numbers in words. <i>Direction</i> is the regime-weighted composite of all nine indicators: <b>very strong</b> (|score| ≥ 40), <b>strong</b> (≥ 33), <b>clear</b> (≥ 25), <b>weak</b> (≥ 15) or <b>no clear direction</b>, with buy or sell from its sign. <i>Reliability</i> is the confidence after the penalties: <b>high</b> (≥ 65), <b>good</b> (≥ 55), <b>fair</b> (≥ 45) or <b>low</b>. The cut-offs are the grade table's own, so the words never disagree with the grade. The numbers themselves remain in <code>--json</code>, <code>--detail</code> and the dashboard meters.</td></tr>
<tr><td><b>MTF aligned</b></td><td>The two confirmation timeframes agree with the trade direction. Signals without alignment are filtered out on M5.</td></tr> <tr><td><b>MTF aligned</b></td><td>The two confirmation timeframes agree with the trade direction. Signals without alignment are filtered out on M5.</td></tr>
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr> <tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
<tr><td><b>Violent-move watch 🌪</b></td><td>An occasional extra line saying the next hour looks unusually likely to produce a very large candle — because a high-impact US release is due (or just landed), because the clock is in a historically wild slot, or because price is stretched outside its bands. It is a heads-up about <i>volatility</i>, not a direction call, and it <b>never</b> changes the signal, the grade or the trade setup — the same alert would be sent without it. Do not close or tighten an open trade because of it: every such protective action was tested over three years and lost money. Your stop is the protection.</td></tr> <tr><td><b>Violent-move watch 🌪</b></td><td>An occasional extra line saying the next hour looks unusually likely to produce a very large candle — because a high-impact US release is due (or just landed), because the clock is in a historically wild slot, or because price is stretched outside its bands. It is a heads-up about <i>volatility</i>, not a direction call, and it <b>never</b> changes the signal, the grade or the trade setup — the same alert would be sent without it. Do not close or tighten an open trade because of it: every such protective action was tested over three years and lost money. Your stop is the protection.</td></tr>
+40
View File
@@ -105,6 +105,44 @@ def _fmt_price(price: float) -> str:
return f"{price:.2f}" return f"{price:.2f}"
# ── Plain words for the two raw numbers (owner request 2026-09-27) ────
# The words sit on the grade table's own floors (CLAUDE.md §2: |score|
# 40 / 33 / 28 / 25 / 15, confidence 65 / 55 / 50 / 45), so they can never
# disagree with the printed grade. Display only: no gate reads them, and the
# numbers stay in --json, --detail and the dashboard meters.
def direction_words(score: float) -> str:
strength_of = abs(score)
side = "buy" if score > 0 else "sell" if score < 0 else ""
if strength_of >= 40:
strength = "very strong"
elif strength_of >= 33:
strength = "strong"
elif strength_of >= 25:
strength = "clear"
elif strength_of >= 15:
strength = "weak"
else:
return "no clear direction"
return f"{strength} {side}".strip()
def reliability_words(confidence: float) -> str:
if confidence >= 65:
return "high"
if confidence >= 55:
return "good"
if confidence >= 45:
return "fair"
return "low"
def plain_words(report: SignalReport) -> str:
"""One line a new trader can read instead of "Score +38.2 · Conf 62%"."""
agree = "timeframes agree" if report.mtf_agreement else "timeframes disagree"
return (f"Direction: {direction_words(report.final_score)} · "
f"Reliability: {reliability_words(report.final_confidence)} · {agree}")
def print_signal_report(report: SignalReport, detail: bool = False): def print_signal_report(report: SignalReport, detail: bool = False):
sc = signal_color(report.final_signal) sc = signal_color(report.final_signal)
qc = quality_color(report.signal_quality) qc = quality_color(report.signal_quality)
@@ -117,6 +155,7 @@ def print_signal_report(report: SignalReport, detail: bool = False):
f"{qc}{report.signal_quality}{Colors.RESET} · " f"{qc}{report.signal_quality}{Colors.RESET} · "
f"{sc}{report.final_score:+.1f}{Colors.RESET} · " f"{sc}{report.final_score:+.1f}{Colors.RESET} · "
f"{report.final_confidence:.0f}% · {mtf_tag}") f"{report.final_confidence:.0f}% · {mtf_tag}")
print(f" {plain_words(report)}")
print(f" {Colors.GRAY}{report.action_recommendation}{Colors.RESET}") print(f" {Colors.GRAY}{report.action_recommendation}{Colors.RESET}")
# ── 2. TRADE SETUP (if signal is actionable) ─────────────────── # ── 2. TRADE SETUP (if signal is actionable) ───────────────────
@@ -242,6 +281,7 @@ def report_to_dict(report: SignalReport) -> dict:
"score": round(report.final_score, 2), "score": round(report.final_score, 2),
"confidence": round(report.final_confidence, 2), "confidence": round(report.final_confidence, 2),
"quality": report.signal_quality, "quality": report.signal_quality,
"plain_words": plain_words(report),
"action": report.action_recommendation, "action": report.action_recommendation,
"market_regime": report.market_regime, "market_regime": report.market_regime,
"volatility_regime": report.volatility_regime, "volatility_regime": report.volatility_regime,
+141 -30
View File
@@ -33,6 +33,128 @@ import signal_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Grading rule (score + confidence -> final signal and grade) ─────────
# "pair" is the shipped rule; "min" / "product" are the 2026-09-27 research
# candidates (backtest/research/conviction_campaign_2026-09-27.md). The CUDA
# twin (backtest/cuda_signal_kernels.py, K2) mirrors these helpers and packs
# CONVICTION_THRESHOLDS from here, so this module is the single source.
GRADING_MODES = ("pair", "min", "product")
# (aplus, a, b, c, strong, buy, boost_a, boost_b) on the conviction scale.
# "min": the control's corners on the |score| axis (0.6 = the control's
# score / confidence ratio). "product": the control's corner products
# 40x65, 33x55, 28x50, 25x45, 35x55, 25x50, 30x50, 25x45, each / 100.
CONVICTION_THRESHOLDS = {
"min": (40.0, 33.0, 28.0, 25.0, 35.0, 25.0, 30.0, 25.0),
"product": (26.0, 18.15, 14.0, 11.25, 19.25, 12.5, 15.0, 11.25),
}
def grading_mode_of(signals_cfg) -> str:
"""The validated `signals.grading_mode`; an unknown value is a hard error."""
mode = signals_cfg.grading_mode
if mode not in GRADING_MODES:
raise ValueError(
f"signals.grading_mode={mode!r}; expected one of {GRADING_MODES}"
)
return mode
def conviction(abs_score: float, confidence: float, mode: str) -> float:
"""The single decision number of the research modes. Evaluation order is
load-bearing for the CUDA mirror: min(a, b) with b = 0.6 * confidence in
that argument order (PYMIN), product as (abs_score * confidence) / 100."""
if mode == "min":
return min(abs_score, 0.6 * confidence)
if mode == "product":
return abs_score * confidence / 100.0
raise ValueError(f"conviction() has no formula for grading_mode={mode!r}")
def _signal_pair(score: float, confidence: float) -> SignalStrength:
"""The shipped final-signal rule (validated literals; do not edit)."""
if score >= 35 and confidence >= 55:
return SignalStrength.STRONG_BUY
elif score >= 25 and confidence >= 50:
return SignalStrength.BUY
elif score <= -35 and confidence >= 55:
return SignalStrength.STRONG_SELL
elif score <= -25 and confidence >= 50:
return SignalStrength.SELL
return SignalStrength.NEUTRAL
def _quality_pair(abs_score: float, confidence: float, mtf: bool) -> str:
"""The shipped grade table (validated literals; do not edit)."""
if abs_score >= 40 and confidence >= 65 and mtf:
return "A+"
elif abs_score >= 33 and confidence >= 55 and mtf:
return "A"
elif abs_score >= 28 and confidence >= 50:
return "B"
elif abs_score >= 25 and confidence >= 45:
return "C"
elif abs_score >= 15:
return "D"
return "F"
def _signal_conv(score: float, conv: float, mode: str) -> SignalStrength:
t_strong, t_buy = CONVICTION_THRESHOLDS[mode][4:6]
if score > 0 and conv >= t_strong:
return SignalStrength.STRONG_BUY
elif score > 0 and conv >= t_buy:
return SignalStrength.BUY
elif score < 0 and conv >= t_strong:
return SignalStrength.STRONG_SELL
elif score < 0 and conv >= t_buy:
return SignalStrength.SELL
return SignalStrength.NEUTRAL
def _quality_conv(abs_score: float, conv: float, mtf: bool, mode: str) -> str:
t_aplus, t_a, t_b, t_c = CONVICTION_THRESHOLDS[mode][0:4]
if conv >= t_aplus and mtf:
return "A+"
elif conv >= t_a and mtf:
return "A"
elif conv >= t_b:
return "B"
elif conv >= t_c:
return "C"
elif abs_score >= 15:
return "D"
return "F"
def _final_signal_and_grade(score: float, confidence: float, mtf: bool, mode: str):
"""(final_signal, signal_quality) for the post-penalty score / confidence.
"pair" is the shipped rule; the research modes grade on one conviction
number. The single dispatch point the CUDA K2 twin mirrors."""
if mode == "pair":
return _signal_pair(score, confidence), _quality_pair(abs(score), confidence, mtf)
conv = conviction(abs(score), confidence, mode)
return _signal_conv(score, conv, mode), _quality_conv(abs(score), conv, mtf, mode)
def _transitioning_boost(abs_score: float, confidence: float, mtf: bool, mode: str) -> Optional[str]:
"""Gate 6c's grade lift in a transitioning regime: "A", "B" or None.
The "pair" branch is the shipped rule (validated literals; do not edit)."""
if mode == "pair":
if abs_score >= 30 and confidence >= 50 and mtf:
return "A"
elif abs_score >= 25 and confidence >= 45:
return "B"
return None
conv = conviction(abs_score, confidence, mode)
t_boost_a, t_boost_b = CONVICTION_THRESHOLDS[mode][6:8]
if conv >= t_boost_a and mtf:
return "A"
elif conv >= t_boost_b:
return "B"
return None
def _is_forex_weekend(now_utc: datetime) -> bool: def _is_forex_weekend(now_utc: datetime) -> bool:
"""Gold/FX market closed for the weekend: Friday 17:00 → Sunday 18:00 """Gold/FX market closed for the weekend: Friday 17:00 → Sunday 18:00
NEW YORK time (= Fri 21:00 UTC in US summer, 22:00 in winter — NEW YORK time (= Fri 21:00 UTC in US summer, 22:00 in winter —
@@ -169,6 +291,7 @@ class SignalAggregator:
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
self.config = config self.config = config
grading_mode_of(config.signals) # fail at start, not at the first bar
self.data_provider = make_data_provider(config) self.data_provider = make_data_provider(config)
self.tech_engine = TechnicalEngine(config.technical) self.tech_engine = TechnicalEngine(config.technical)
self._news_engine: Optional[NewsEngine] = None # lazy self._news_engine: Optional[NewsEngine] = None # lazy
@@ -431,9 +554,15 @@ class SignalAggregator:
multipliers actually in force (override included) and the multipliers actually in force (override included) and the
timeframe set. Logged on every row so indicator-weight research timeframe set. Logged on every row so indicator-weight research
can tell which rows share a scoring configuration.""" can tell which rows share a scoring configuration."""
signals = asdict(self.config.signals)
# The research grading switch enters the hash only when it is in
# force: under the shipped "pair" rule the scoring is byte-identical
# to the pre-2026-09-27 code, so live rows keep the same fingerprint.
if signals.get("grading_mode") == "pair":
signals.pop("grading_mode")
payload = { payload = {
"technical": asdict(self.config.technical), "technical": asdict(self.config.technical),
"signals": asdict(self.config.signals), "signals": signals,
"regime_weights": regime_weight_table(self.config.technical), "regime_weights": regime_weight_table(self.config.technical),
"primary": self.config.primary_timeframe.value, "primary": self.config.primary_timeframe.value,
"confirm": [tf.value for tf in self.config.confirmation_timeframes], "confirm": [tf.value for tf in self.config.confirmation_timeframes],
@@ -1147,10 +1276,14 @@ class SignalAggregator:
# ── 6c. Transitioning regime quality boost ────────────────── # ── 6c. Transitioning regime quality boost ──────────────────
if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"): if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"):
abs_score = abs(report.final_score) abs_score = abs(report.final_score)
if abs_score >= 30 and report.final_confidence >= 50 and report.mtf_agreement: boost = _transitioning_boost(
abs_score, report.final_confidence, report.mtf_agreement,
grading_mode_of(self.config.signals),
)
if boost == "A":
report.signal_quality = "A" report.signal_quality = "A"
report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend emerging, early entry" report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend emerging, early entry"
elif abs_score >= 25 and report.final_confidence >= 45: elif boost == "B":
report.signal_quality = "B" report.signal_quality = "B"
report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely" report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely"
@@ -1743,36 +1876,14 @@ class SignalAggregator:
) )
confidence = 0.0 confidence = 0.0
# Final signal # Final signal (grading_mode: "pair" = the shipped rule; the research
if score >= 35 and confidence >= 55: # modes grade on one conviction number — helpers at module top)
report.final_signal = SignalStrength.STRONG_BUY report.final_signal, report.signal_quality = _final_signal_and_grade(
elif score >= 25 and confidence >= 50: score, confidence, report.mtf_agreement, grading_mode_of(self.config.signals)
report.final_signal = SignalStrength.BUY )
elif score <= -35 and confidence >= 55:
report.final_signal = SignalStrength.STRONG_SELL
elif score <= -25 and confidence >= 50:
report.final_signal = SignalStrength.SELL
else:
report.final_signal = SignalStrength.NEUTRAL
report.final_score = score report.final_score = score
report.final_confidence = confidence report.final_confidence = confidence
# Quality grade
abs_score = abs(score)
if abs_score >= 40 and confidence >= 65 and report.mtf_agreement:
report.signal_quality = "A+"
elif abs_score >= 33 and confidence >= 55 and report.mtf_agreement:
report.signal_quality = "A"
elif abs_score >= 28 and confidence >= 50:
report.signal_quality = "B"
elif abs_score >= 25 and confidence >= 45:
report.signal_quality = "C"
elif abs_score >= 15:
report.signal_quality = "D"
else:
report.signal_quality = "F"
if not report.action_recommendation: if not report.action_recommendation:
sig_name = report.final_signal.name.replace("_", " ") sig_name = report.final_signal.name.replace("_", " ")
if report.signal_quality in ("A+", "A"): if report.signal_quality in ("A+", "A"):
+4
View File
@@ -341,6 +341,10 @@ h1, h2, h3, h4, h5 { font-weight: 600; }
.grade.grade-high { border-color: rgba(245, 179, 1, 0.55); background: var(--gold-wash); color: var(--gold); } .grade.grade-high { border-color: rgba(245, 179, 1, 0.55); background: var(--gold-wash); color: var(--gold); }
.tf-plain {
font-size: 0.88rem;
margin: 0.35rem 0 0;
}
.tf-action { .tf-action {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
+3
View File
@@ -548,6 +548,7 @@ function cardShell(tf) {
<span data-f="signal">Warming up</span> <span data-f="signal">Warming up</span>
<span class="grade d-none" data-f="grade"></span> <span class="grade d-none" data-f="grade"></span>
</div> </div>
<p class="tf-plain d-none" data-f="plain"></p>
<p class="tf-action" data-f="action"></p> <p class="tf-action" data-f="action"></p>
<div class="tf-readouts"> <div class="tf-readouts">
<div> <div>
@@ -670,6 +671,8 @@ function renderCard(tf) {
f("action").text(entry.status === "error" f("action").text(entry.status === "error"
? "Last good reading. The newest scan failed." ? "Last good reading. The newest scan failed."
: actionTail(r.action)); : actionTail(r.action));
// Plain words for the two numbers below (from main.plain_words, same on every surface).
f("plain").toggleClass("d-none", !r.plain_words).text(r.plain_words || "");
const score = r.score == null ? 0 : r.score; const score = r.score == null ? 0 : r.score;
f("score").text(signed(r.score, 1)) f("score").text(signed(r.score, 1))
+21 -7
View File
@@ -398,12 +398,22 @@ def _book_rate_line(report: SignalReport) -> Optional[str]:
f"{rate['win_rate']:.0f}% winners, avg {rate['mean_r']:+.2f}R per trade") f"{rate['win_rate']:.0f}% winners, avg {rate['mean_r']:+.2f}R per trade")
def _plain_words_line(report: SignalReport) -> str:
"""Grade first, then the two raw numbers in words (owner request
2026-09-27): "Direction: strong buy · Reliability: good · timeframes
agree" replaces "Score +38.2 · Conf 62% · MTF ✅". The words come from
main.plain_words so every surface says the same thing."""
from main import direction_words, reliability_words # display helper, no side effects
agree = "timeframes agree" if report.mtf_agreement else "timeframes disagree"
return (f"📐 Direction: *{direction_words(report.final_score)}* · "
f"Reliability: *{reliability_words(report.final_confidence)}* · {agree}")
def format_signal_message(report: SignalReport) -> str: def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜") emoji = _SIGNAL_EMOJI.get(sig, "⬜")
qe = _QUALITY_EMOJI.get(report.signal_quality, "") qe = _QUALITY_EMOJI.get(report.signal_quality, "")
label = _signal_label(sig) label = _signal_label(sig)
mtf_tag = "MTF ✅" if report.mtf_agreement else "MTF ❌"
tf = report.primary_analysis.timeframe if report.primary_analysis else "" tf = report.primary_analysis.timeframe if report.primary_analysis else ""
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
@@ -411,14 +421,14 @@ def format_signal_message(report: SignalReport) -> str:
vol_str = _escape_md(str(report.volatility_regime)) vol_str = _escape_md(str(report.volatility_regime))
lines: list[str] = [ lines: list[str] = [
f"{emoji} *{label} · {report.display_name} · {tf}* {qe}{report.signal_quality}", f"{emoji} *Grade {report.signal_quality} · {label} · {report.display_name} · {tf}* {qe}",
f"🕐 {now} · 💰 {_fmt_price(report.current_price)}", f"🕐 {now} · 💰 {_fmt_price(report.current_price)}",
] ]
live = _live_price_line(report.symbol) live = _live_price_line(report.symbol)
if live: if live:
lines.append(live) lines.append(live)
lines += [ lines += [
f"Score *{report.final_score:+.1f}* · Conf *{report.final_confidence:.0f}%* · {mtf_tag}", _plain_words_line(report),
f"📊 Regime: {regime_str} · Vol: {vol_str}", f"📊 Regime: {regime_str} · Vol: {vol_str}",
] ]
if report.action_recommendation: if report.action_recommendation:
@@ -498,7 +508,7 @@ def format_signal_message(report: SignalReport) -> str:
extra: list[str] = [] extra: list[str] = []
mtf = _mtf_line(report) mtf = _mtf_line(report)
if mtf: if mtf:
extra.append(f"📈 MTF: {mtf}") extra.append(f"📈 Other timeframes: {mtf}")
ind_summary = _indicator_summary(report) ind_summary = _indicator_summary(report)
if ind_summary: if ind_summary:
extra.append(f"📋 {ind_summary}") extra.append(f"📋 {ind_summary}")
@@ -1852,6 +1862,7 @@ async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
"", "",
] ]
from main import direction_words, reliability_words # display helper (2026-09-27)
for r in reports: for r in reports:
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜") emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
qe = _QUALITY_EMOJI.get(r.signal_quality, "") qe = _QUALITY_EMOJI.get(r.signal_quality, "")
@@ -1859,10 +1870,13 @@ async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
sig = _signal_label(r.final_signal) sig = _signal_label(r.final_signal)
regime = r.market_regime.replace("_", " ").title() regime = r.market_regime.replace("_", " ").title()
lines.append( lines.append(
f"{emoji} *{tf_label}* {sig} {qe}{r.signal_quality} " f"{emoji} *{tf_label}* Grade {r.signal_quality} {qe} · {sig}"
f"Score: {r.final_score:+.1f} {r.final_confidence:.0f}%"
) )
lines.append(f" _{_escape_md(regime)}_ | {'✅ MTF' if r.mtf_agreement else '❌ MTF'}") lines.append(
f" {direction_words(r.final_score)}, {reliability_words(r.final_confidence)} reliability, "
f"{'timeframes agree' if r.mtf_agreement else 'timeframes disagree'}"
)
lines.append(f" _{_escape_md(regime)}_")
if r.trade_setup and r.final_signal != SignalStrength.NEUTRAL: if r.trade_setup and r.final_signal != SignalStrength.NEUTRAL:
ts = r.trade_setup ts = r.trade_setup
d = "📈" if ts.direction == "BUY" else "📉" d = "📈" if ts.direction == "BUY" else "📉"
+252 -1
View File
@@ -1310,6 +1310,158 @@ def _():
assert report.action_recommendation != "" assert report.action_recommendation != ""
# ── grading_mode (research switch, campaign 2026-09-27) ──────────────
def _grading_report():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
return config, agg, agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
@test("SignalConfig.grading_mode defaults to 'pair'; _signal_pair/_quality_pair reproduce the CLAUDE.md §2 table")
def _():
from config import SignalConfig as _SC
from signal_aggregator import _signal_pair, _quality_pair, _transitioning_boost
assert _SC().grading_mode == "pair"
def table_signal(s, c):
if s >= 35 and c >= 55: return SignalStrength.STRONG_BUY
if s >= 25 and c >= 50: return SignalStrength.BUY
if s <= -35 and c >= 55: return SignalStrength.STRONG_SELL
if s <= -25 and c >= 50: return SignalStrength.SELL
return SignalStrength.NEUTRAL
def table_grade(a, c, m):
if a >= 40 and c >= 65 and m: return "A+"
if a >= 33 and c >= 55 and m: return "A"
if a >= 28 and c >= 50: return "B"
if a >= 25 and c >= 45: return "C"
if a >= 15: return "D"
return "F"
edges = []
for b in (15, 25, 28, 30, 33, 35, 40):
edges += [b - 0.01, b, b + 0.01]
scores = sorted({0.0} | {e for e in edges} | {-e for e in edges} | {-100.0, 100.0, 60.0, -60.0})
confs = [0.0, 44.99, 45.0, 49.99, 50.0, 54.99, 55.0, 64.99, 65.0, 95.0]
n = 0
for s in scores:
for c in confs:
assert _signal_pair(s, c) == table_signal(s, c), (s, c)
for m in (True, False):
assert _quality_pair(abs(s), c, m) == table_grade(abs(s), c, m), (s, c, m)
want = "A" if (abs(s) >= 30 and c >= 50 and m) else "B" if (abs(s) >= 25 and c >= 45) else None
assert _transitioning_boost(abs(s), c, m, "pair") == want, (s, c, m)
n += 1
assert n > 800
# the real report's grade is the helper of its own final values
config, agg, report = _grading_report()
assert report.signal_quality == _quality_pair(abs(report.final_score), report.final_confidence, report.mtf_agreement)
assert report.final_signal == _signal_pair(report.final_score, report.final_confidence)
@test("grading_mode='min': conviction = min(|score|, 0.6*conf); grades and signal at the boundaries")
def _():
from signal_aggregator import conviction, _signal_conv, _quality_conv, _transitioning_boost, CONVICTION_THRESHOLDS
conv = lambda s, c: conviction(abs(s), c, "min")
grade = lambda s, c, m=True: _quality_conv(abs(s), conv(s, c), m, "min")
sig = lambda s, c: _signal_conv(s, conv(s, c), "min")
assert conv(40, 66.7) >= 40 and conv(40, 66.6) < 40
assert conv(95, 95) == 0.6 * 95 and conv(10, 95) == 10 and conv(50, 95) == 50 # min picks the smaller axis
assert grade(40, 66.7) == "A+" and grade(40, 66.6) == "A" and grade(40, 66.7, False) == "B"
assert grade(28, 46.7) == "B" and grade(28, 46.6) == "C"
assert grade(25, 41.7) == "C" and grade(25, 41.6) == "D"
assert grade(14.9, 95) == "F" and grade(15, 0) == "D"
assert sig(35, 58.4) == SignalStrength.STRONG_BUY and sig(35, 58.3) == SignalStrength.BUY
assert sig(-35, 58.4) == SignalStrength.STRONG_SELL and sig(-25, 41.7) == SignalStrength.SELL
assert sig(0, 95) == SignalStrength.NEUTRAL and sig(60, 0) == SignalStrength.NEUTRAL
assert _transitioning_boost(26, 60, False, "min") == "B"
assert _transitioning_boost(30, 50, True, "min") == "A" and _transitioning_boost(30, 49.9, True, "min") == "B"
assert _transitioning_boost(24, 95, True, "min") is None
assert CONVICTION_THRESHOLDS["min"] == (40.0, 33.0, 28.0, 25.0, 35.0, 25.0, 30.0, 25.0)
# the real report under the mode: grade == helper of the recomputed final values
config, agg, report = _grading_report()
config.signals.grading_mode = "min"
agg._compute_final_signal(report)
assert report.signal_quality == _quality_conv(abs(report.final_score), conv(report.final_score, report.final_confidence),
report.mtf_agreement, "min")
@test("grading_mode='product': conviction = |score|*conf/100; the control corners reproduce; high confidence can lift a mid score")
def _():
from signal_aggregator import conviction, _signal_conv, _quality_conv, _transitioning_boost, CONVICTION_THRESHOLDS
conv = lambda s, c: conviction(abs(s), c, "product")
grade = lambda s, c, m=True: _quality_conv(abs(s), conv(s, c), m, "product")
sig = lambda s, c: _signal_conv(s, conv(s, c), "product")
assert conv(40, 65) == 26.0 and conv(33, 55) == 18.15 and conv(28, 50) == 14.0 and conv(25, 45) == 11.25
assert grade(40, 65) == "A+" and grade(33, 55) == "A" and grade(28, 50) == "B" and grade(25, 45) == "C"
assert grade(40, 64.9) == "A" and grade(33, 54.9) == "B"
assert sig(35, 55) == SignalStrength.STRONG_BUY and sig(35, 54.9) == SignalStrength.BUY
assert sig(25, 50) == SignalStrength.BUY and sig(-25, 50) == SignalStrength.SELL and sig(25, 49.9) == SignalStrength.NEUTRAL
assert grade(30, 87) == "A+" # |score| < 40 lifted by confidence
assert grade(24, 95) == "A" and grade(24, 95, False) == "B"
assert grade(20, 60) == "C" # |score| < 25: the M5 gate-1 interaction case
assert grade(20, 56) == "D" and grade(60, 0) == "D" # 11.2 < 11.25; conf 0 -> conviction 0
assert grade(14.9, 95) == "B" and grade(10, 95) == "F" # 14.155 >= 14 (the trade-off); 9.5 < 11.25 and |score| < 15
assert _transitioning_boost(30, 50, True, "product") == "A" and _transitioning_boost(25, 45, False, "product") == "B"
assert _transitioning_boost(25, 44.9, False, "product") is None
assert CONVICTION_THRESHOLDS["product"] == (26.0, 18.15, 14.0, 11.25, 19.25, 12.5, 15.0, 11.25)
config, agg, report = _grading_report()
config.signals.grading_mode = "product"
agg._compute_final_signal(report)
assert report.signal_quality == _quality_conv(abs(report.final_score), conv(report.final_score, report.final_confidence),
report.mtf_agreement, "product")
@test("grading_mode: gate 6c boost reads the same conviction in every mode")
def _():
from signal_aggregator import _transitioning_boost
for mode in ("pair", "min", "product"):
assert _transitioning_boost(30, 50, True, mode) == "A", mode
assert _transitioning_boost(30, 50, False, mode) == "B", mode
assert _transitioning_boost(26, 60, False, mode) == "B", mode
assert _transitioning_boost(10, 95, True, mode) is None, mode
assert _transitioning_boost(26, 44, False, "pair") is None
assert _transitioning_boost(26, 44, False, "min") == "B" # 0.6*44 = 26.4 >= 25
assert _transitioning_boost(26, 44, False, "product") == "B" # 11.44 >= 11.25
@test("grading_mode unknown raises at construction and at recompute; the fingerprint carries the field")
def _():
from signal_aggregator import grading_mode_of
config = PlatformConfig()
config.signals.grading_mode = "geomean"
try:
SignalAggregator(config)
raise AssertionError("an unknown grading_mode must fail at construction")
except ValueError as e:
assert "grading_mode" in str(e) and "geomean" in str(e), e
config, agg, report = _grading_report()
fp_pair = agg.engine_config_fingerprint()
config.signals.grading_mode = "geomean"
try:
agg._compute_final_signal(report)
raise AssertionError("an unknown grading_mode must fail at recompute")
except ValueError:
pass
config.signals.grading_mode = "product"
assert agg.engine_config_fingerprint() != fp_pair, "the mode is part of the scoring config"
config.signals.grading_mode = "pair"
assert agg.engine_config_fingerprint() == fp_pair
assert grading_mode_of(config.signals) == "pair"
from signal_aggregator import conviction as _conv
for bad in ("pair", "geomean", ""):
try:
_conv(30.0, 60.0, bad)
raise AssertionError(f"conviction() must refuse {bad!r}")
except ValueError:
pass
@test("Event-risk signal gating obeys news.event_risk_caps_signals") @test("Event-risk signal gating obeys news.event_risk_caps_signals")
def _(): def _():
config = PlatformConfig() config = PlatformConfig()
@@ -1728,11 +1880,50 @@ def _():
assert isinstance(msg, str) assert isinstance(msg, str)
assert len(msg) > 0 assert len(msg) > 0
assert "XAU/USD" in msg assert "XAU/USD" in msg
assert "Score" in msg assert "Score *" not in msg and "Conf *" not in msg and "MTF ✅" not in msg and "MTF ❌" not in msg, \
"raw numbers replaced by words (2026-09-27)"
assert "📈 MTF:" not in msg, "the per-timeframe detail line is labelled in words"
assert "📐 Direction: *" in msg and "Reliability: *" in msg and "timeframes " in msg
import telegram_bot as _tb
assert msg.splitlines()[0].startswith(f"{_tb._SIGNAL_EMOJI[report.final_signal]} *Grade {report.signal_quality} ·"), "grade first"
assert "Regime" in msg assert "Regime" in msg
assert "Not financial advice" in msg assert "Not financial advice" in msg
@test("plain words for score / confidence sit on the grade table's floors and reach every surface")
def _():
from main import direction_words, reliability_words, plain_words, report_to_dict
assert direction_words(40) == "very strong buy" and direction_words(39.99) == "strong buy"
assert direction_words(-33) == "strong sell" and direction_words(-32.99) == "clear sell"
assert direction_words(25) == "clear buy" and direction_words(24.99) == "weak buy"
assert direction_words(15) == "weak buy" and direction_words(14.99) == "no clear direction"
assert direction_words(0) == "no clear direction"
assert reliability_words(65) == "high" and reliability_words(64.99) == "good"
assert reliability_words(55) == "good" and reliability_words(54.99) == "fair"
assert reliability_words(45) == "fair" and reliability_words(44.99) == "low" and reliability_words(0) == "low"
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.final_score, report.final_confidence, report.mtf_agreement = 38.2, 62.0, True
assert plain_words(report) == "Direction: strong buy · Reliability: good · timeframes agree"
report.mtf_agreement = False
assert plain_words(report).endswith("timeframes disagree")
assert report_to_dict(report)["plain_words"] == plain_words(report)
# the words are consistent with the grade the same numbers would earn
from signal_aggregator import _quality_pair
for score, conf in ((40, 65), (33, 55), (28, 50), (25, 45), (15, 10), (5, 90)):
grade = _quality_pair(score, conf, True)
words = direction_words(score)
if grade == "A+":
assert words.startswith("very strong")
elif grade == "A":
assert words.startswith("strong")
elif grade in ("B", "C"):
assert words.startswith("clear"), (score, conf, words)
elif grade == "D":
assert words.startswith("weak")
else:
assert words == "no clear direction"
@test("format_signal_message makes chart pattern actions Markdown-safe") @test("format_signal_message makes chart pattern actions Markdown-safe")
def _(): def _():
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
@@ -14446,6 +14637,30 @@ def _():
assert dict(zip(_ck37.PARAMS_F, _ck37.pack_params(_mk37(Timeframe.M5), _m37_bt(_mk37(Timeframe.M5), dfs))[0].tolist()))["fb2_ema_lo"] == 0.9 assert dict(zip(_ck37.PARAMS_F, _ck37.pack_params(_mk37(Timeframe.M5), _m37_bt(_mk37(Timeframe.M5), dfs))[0].tolist()))["fb2_ema_lo"] == 0.9
with patch.dict(os.environ, {"FB2_EMA_LO": "wide"}): with patch.dict(os.environ, {"FB2_EMA_LO": "wide"}):
assert _ck37.fb2_ema_lo_from_env() == 0.5 assert _ck37.fb2_ema_lo_from_env() == 0.5
# grading_mode: the factories pack the shipped pair rule (code 0, no thresholds);
# the research modes pack 1 / 2 and the live module's threshold tuple verbatim
from signal_aggregator import CONVICTION_THRESHOLDS as _CT
conv_names = ("conv_aplus", "conv_a", "conv_b", "conv_c", "conv_strong", "conv_buy", "conv_boost_a", "conv_boost_b")
from signal_aggregator import GRADING_MODES as _GM
assert _ck37.GRADING_MODE_CODES == {"pair": 0, "min": 1, "product": 2} and set(_GM) == set(_ck37.GRADING_MODE_CODES)
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
pf0, pi0, _ = _ck37.pack_params(_mk37(tf), _m37_bt(_mk37(tf), dfs))
f0, i0 = dict(zip(_ck37.PARAMS_F, pf0.tolist())), dict(zip(_ck37.PARAMS_I, pi0.tolist()))
assert i0["grading_mode"] == 0 and all(f0[n] == 0.0 for n in conv_names), tf
for code, mode in ((1, "min"), (2, "product")):
cfg = _mk37(Timeframe.M15)
cfg.signals.grading_mode = mode
pfm, pim, _ = _ck37.pack_params(cfg, _m37_bt(cfg, dfs))
fm, im = dict(zip(_ck37.PARAMS_F, pfm.tolist())), dict(zip(_ck37.PARAMS_I, pim.tolist()))
assert im["grading_mode"] == code, mode
assert tuple(fm[n] for n in conv_names) == _CT[mode], mode
bad = _mk37(Timeframe.M15)
bad.signals.grading_mode = "geomean"
try:
_ck37.pack_params(bad, _m37_bt(bad, dfs))
raise AssertionError("the packer must refuse an unknown grading_mode")
except ValueError as e:
assert "grading_mode" in str(e), e
# the kernel #defines are generated from the same name lists # the kernel #defines are generated from the same name lists
src = _ck37._defines() src = _ck37._defines()
for k, name in enumerate(_ck37.PARAMS_F): for k, name in enumerate(_ck37.PARAMS_F):
@@ -14784,6 +14999,41 @@ def _():
print(f" ({t['n']} bars, {t['cands']} candidates, directions {sorted(t['direction'])})") print(f" ({t['n']} bars, {t['cands']} candidates, directions {sorted(t['direction'])})")
def _tweak_min37(cfg):
cfg.signals.grading_mode = "min"
def _tweak_product37(cfg):
cfg.signals.grading_mode = "product"
@test("CUDA stage: K2 == analyze_from_dataframes — M15 factory, grading_mode='min' (research conviction rule, all grades)")
def _():
if not _GPU37:
print(" (no CUDA: kernel not exercised)")
return
t = _k2_case37("M15 grading_mode=min", Timeframe.M15, _tweak_min37,
{"quality_filter": ["A+", "A", "B", "C", "D"]}, "main", 1, None)
assert {"A+", "A", "B", "C", "D", "F"} <= t["quality"], t["quality"]
assert t["cands"] >= 20, t["cands"]
print(f" ({t['n']} bars, {t['cands']} candidates, grades {sorted(t['quality'])})")
@test("CUDA stage: K2 == analyze_from_dataframes — M5 factory, grading_mode='product' (a low-score C reaches the M5 minimum-score gate)")
def _():
if not _GPU37:
print(" (no CUDA: kernel not exercised)")
return
t = _k2_case37("M5 grading_mode=product", Timeframe.M5, _tweak_product37,
{"quality_filter": ["A+", "A", "B", "C", "D"]}, "main", 1, None)
# (no product-C survives on this fixture: every low-score C is demoted by gate 1)
assert {"A+", "A", "B", "D", "F"} <= t["quality"], t["quality"]
# under the product rule a C/B can carry |score| < 25, so gate 1 fires
# (unreachable under the shipped pair rule — see the M5 factory case)
assert "m5_score" in t["warn"], sorted(t["warn"])
print(f" ({t['n']} bars, {t['cands']} candidates, m5_score demotions reachable)")
@test("CUDA stage: K2 == analyze_from_dataframes — H1 and H4 factories (H1 shorts blocked; D1 confirmation shorter than 30 bars)") @test("CUDA stage: K2 == analyze_from_dataframes — H1 and H4 factories (H1 shorts blocked; D1 confirmation shorter than 30 bars)")
def _(): def _():
if not _GPU37: if not _GPU37:
@@ -15031,6 +15281,7 @@ def _():
(lambda c: setattr(c.technical, "swing_sr_lookback", _ck37.MAX_SR_LOOKBACK), "swing_sr_lookback"), (lambda c: setattr(c.technical, "swing_sr_lookback", _ck37.MAX_SR_LOOKBACK), "swing_sr_lookback"),
(lambda c: setattr(c.xau_strategy, "cp_lookback", _ck37.MAX_CP_LOOKBACK), "cp_lookback"), (lambda c: setattr(c.xau_strategy, "cp_lookback", _ck37.MAX_CP_LOOKBACK), "cp_lookback"),
(lambda c: setattr(c.technical, "ema_mode", "3ema"), "ema_mode"), (lambda c: setattr(c.technical, "ema_mode", "3ema"), "ema_mode"),
(lambda c: setattr(c.signals, "grading_mode", "geomean"), "grading_mode"),
(lambda c: setattr(c, "confirmation_timeframes", (lambda c: setattr(c, "confirmation_timeframes",
[Timeframe.M5, Timeframe.H1, Timeframe.H4, Timeframe.D1, Timeframe.M30]), "confirmation timeframes"), [Timeframe.M5, Timeframe.H1, Timeframe.H4, Timeframe.D1, Timeframe.M30]), "confirmation timeframes"),
(lambda c: (setattr(c.xau_strategy, "big_candle_atr_multiple", 3.0), (lambda c: (setattr(c.xau_strategy, "big_candle_atr_multiple", 3.0),