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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-09-27 14:54:36 -04:00
parent 1454ea1838
commit 6af1237c2d
13 changed files with 808 additions and 59 deletions
+141 -30
View File
@@ -33,6 +33,128 @@ import signal_log
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:
"""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 —
@@ -169,6 +291,7 @@ class SignalAggregator:
def __init__(self, config: PlatformConfig):
self.config = config
grading_mode_of(config.signals) # fail at start, not at the first bar
self.data_provider = make_data_provider(config)
self.tech_engine = TechnicalEngine(config.technical)
self._news_engine: Optional[NewsEngine] = None # lazy
@@ -431,9 +554,15 @@ class SignalAggregator:
multipliers actually in force (override included) and the
timeframe set. Logged on every row so indicator-weight research
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 = {
"technical": asdict(self.config.technical),
"signals": asdict(self.config.signals),
"signals": signals,
"regime_weights": regime_weight_table(self.config.technical),
"primary": self.config.primary_timeframe.value,
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
@@ -1147,10 +1276,14 @@ class SignalAggregator:
# ── 6c. Transitioning regime quality boost ──────────────────
if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"):
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.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.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely"
@@ -1743,36 +1876,14 @@ class SignalAggregator:
)
confidence = 0.0
# Final signal
if score >= 35 and confidence >= 55:
report.final_signal = SignalStrength.STRONG_BUY
elif score >= 25 and confidence >= 50:
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
# Final signal (grading_mode: "pair" = the shipped rule; the research
# modes grade on one conviction number — helpers at module top)
report.final_signal, report.signal_quality = _final_signal_and_grade(
score, confidence, report.mtf_agreement, grading_mode_of(self.config.signals)
)
report.final_score = score
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:
sig_name = report.final_signal.name.replace("_", " ")
if report.signal_quality in ("A+", "A"):