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:
@@ -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` |
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
// 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 final_signal;
|
||||||
|
int quality;
|
||||||
|
if (gmode == 0) {
|
||||||
if (score >= 35.0 && confidence >= 55.0) final_signal = 2;
|
if (score >= 35.0 && confidence >= 55.0) final_signal = 2;
|
||||||
else if (score >= 25.0 && confidence >= 50.0) final_signal = 1;
|
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 <= -35.0 && confidence >= 55.0) final_signal = -2;
|
||||||
else if (score <= -25.0 && confidence >= 50.0) final_signal = -1;
|
else if (score <= -25.0 && confidence >= 50.0) final_signal = -1;
|
||||||
else final_signal = 0;
|
else final_signal = 0;
|
||||||
const double abs_score0 = fabs(score);
|
|
||||||
int quality;
|
|
||||||
if (abs_score0 >= 40.0 && confidence >= 65.0 && mtf) quality = Q_APLUS;
|
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 >= 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 >= 28.0 && confidence >= 50.0) quality = Q_B;
|
||||||
else if (abs_score0 >= 25.0 && confidence >= 45.0) quality = Q_C;
|
else if (abs_score0 >= 25.0 && confidence >= 45.0) quality = Q_C;
|
||||||
else if (abs_score0 >= 15.0) quality = Q_D;
|
else if (abs_score0 >= 15.0) quality = Q_D;
|
||||||
else quality = Q_F;
|
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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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"):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user