Files
ForexBot2/main.py
T
Harwinder Sekhon d81cdf845b 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
2026-09-27 14:54:36 -04:00

453 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""
ForexBot2 — XAU/USD Signal Intelligence
========================================
Usage:
python main.py # Live signal on H1
python main.py --timeframe H4 --detail # Detailed H4 signal
python main.py --json # JSON output
"""
import argparse
import json
import logging
import sys
from datetime import datetime, timezone
# Force UTF-8 output on Windows so box-drawing chars render
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
from agent_panel import attach_panel
from app_config import get_app_config, violent_watch_enabled
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
from violent_watch import attach_watch
# ══════════════════════════════════════════════════════════════════
# TERMINAL FORMATTING
# ══════════════════════════════════════════════════════════════════
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
UNDERLINE = "\033[4m"
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
MAGENTA = "\033[95m"
CYAN = "\033[96m"
WHITE = "\033[97m"
GRAY = "\033[90m"
def signal_color(signal: SignalStrength) -> str:
return {
SignalStrength.STRONG_BUY: Colors.GREEN + Colors.BOLD,
SignalStrength.BUY: Colors.GREEN,
SignalStrength.NEUTRAL: Colors.GRAY,
SignalStrength.SELL: Colors.RED,
SignalStrength.STRONG_SELL: Colors.RED + Colors.BOLD,
}.get(signal, Colors.RESET)
def quality_color(q: str) -> str:
return {
"A+": Colors.GREEN + Colors.BOLD,
"A": Colors.GREEN,
"B": Colors.CYAN,
"C": Colors.YELLOW,
"D": Colors.GRAY,
"F": Colors.DIM,
}.get(q, Colors.RESET)
def print_header(title: str = "XAU/USD SIGNAL INTELLIGENCE"):
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN} {title}{Colors.RESET}")
print(f"{Colors.GRAY} Time: {now}{Colors.RESET}")
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
# ── Live signal renderer ───────────────────────────────────────────
_INDICATOR_PREFIXES = (
"EMA Trend", "RSI", "Bollinger Bands", "Divergence",
"Candlestick", "ADX", "ATR", "News", "Regime",
)
def _compact_reason(reason: str) -> str:
"""Shorten a verbose reason string for the compact view."""
s = reason
if s.startswith("STRONG: "):
s = s[8:]
# Strip the leading "<Indicator>: " prefix — it's always an indicator name
for prefix in _INDICATOR_PREFIXES:
if s.startswith(prefix + ": "):
s = s[len(prefix) + 2:]
break
# Drop trailing detail after first ' | '
if " | " in s:
s = s.split(" | ", 1)[0]
return s.strip()
def _fmt_price(price: float) -> str:
"""Gold-scale price, 2 dp."""
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):
sc = signal_color(report.final_signal)
qc = quality_color(report.signal_quality)
sig_name = report.final_signal.name.replace("_", " ")
# ── 1. DECISION ────────────────────────────────────────────────
mtf_tag = (f"{Colors.GREEN}MTF✓{Colors.RESET}" if report.mtf_agreement
else f"{Colors.YELLOW}MTF✗{Colors.RESET}")
print(f" {sc}▶ {sig_name}{Colors.RESET} "
f"{qc}{report.signal_quality}{Colors.RESET} · "
f"{sc}{report.final_score:+.1f}{Colors.RESET} · "
f"{report.final_confidence:.0f}% · {mtf_tag}")
print(f" {plain_words(report)}")
print(f" {Colors.GRAY}{report.action_recommendation}{Colors.RESET}")
# ── 2. TRADE SETUP (if signal is actionable) ───────────────────
if report.trade_setup:
ts = report.trade_setup
dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED
print(f"\n {Colors.BOLD}Setup{Colors.RESET} "
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{_fmt_price(ts.entry_price)}{Colors.RESET} "
f"→ SL {Colors.RED}{_fmt_price(ts.stop_loss)}{Colors.RESET} "
f"({ts.risk_pips:.1f}p = 1R) "
f"→ TP1 guide {Colors.GREEN}{_fmt_price(ts.take_profit_1)}{Colors.RESET} "
f"({ts.reward_pips:.1f}p) "
f"planned TP1 RR 1:{ts.risk_reward:.1f}")
# The trail is the tested exit (the backtester never exits at
# TP1); print it with the setup so the CLI says the same thing
# as the Telegram alert (2026-09-26).
risk_dist = abs(ts.entry_price - ts.stop_loss)
arm_r = getattr(ts, "trail_arm_r", 0.0) or 0.0
dist_r = getattr(ts, "trail_dist_r", 0.0) or 0.0
if arm_r > 0 and risk_dist > 0:
arm_price = (ts.entry_price + arm_r * risk_dist if ts.direction == "BUY"
else ts.entry_price - arm_r * risk_dist)
print(f" {Colors.GRAY}Exit rule (tested): once price reaches "
f"{_fmt_price(arm_price)} (+{arm_r:.1f}R), trail the SL "
f"${dist_r * risk_dist:.2f} behind the best price at candle close; "
f"TP1 is a guide only.{Colors.RESET}")
# ── 3. CONTEXT (price · regime · vol · MTF breakdown) ──────────
print(f"\n {Colors.WHITE}{report.display_name}{Colors.RESET} "
f"{Colors.BOLD}{_fmt_price(report.current_price)}{Colors.RESET} · "
f"{Colors.CYAN}{report.market_regime}{Colors.RESET} · "
f"vol {report.volatility_regime}")
if report.mtf_signals:
parts = []
for tf, sig in report.mtf_signals.items():
tf_sc = signal_color(sig)
sc_val = report.mtf_scores.get(tf, 0)
parts.append(f"{tf} {tf_sc}{sig.name.replace('_',' ')}{Colors.RESET} {sc_val:+.1f}")
print(f" {Colors.GRAY}MTF{Colors.RESET} " + " · ".join(parts))
# ── 4. WHY — top 3 each side, compact ──────────────────────────
bulls = [_compact_reason(r) for r in report.bullish_reasons[:3]]
bears = [_compact_reason(r) for r in report.bearish_reasons[:3]]
if bulls or bears:
print()
rows = max(len(bulls), len(bears))
for i in range(rows):
left = f"{Colors.GREEN}+{Colors.RESET} {bulls[i]}" if i < len(bulls) else ""
right = f"{Colors.RED}-{Colors.RESET} {bears[i]}" if i < len(bears) else ""
print(f" {left:<60} {right}")
# ── 5. WARNINGS / NEWS / EVENTS ────────────────────────────────
if report.warnings:
for w in report.warnings:
print(f" {Colors.YELLOW}⚠{Colors.RESET} {w}")
if report.watch_line:
print(f" {Colors.YELLOW}🌪{Colors.RESET} {report.watch_line}")
if report.event_description and report.event_risk != "clear":
print(f" {Colors.YELLOW}📅{Colors.RESET} {report.event_description}")
if report.news_description and report.news_description != "Neutral news sentiment":
print(f" {Colors.MAGENTA}📰{Colors.RESET} {report.news_description}")
if report.news_summary:
print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}")
if report.panel_verdict and report.panel_verdict_source == "mean":
base = (report.panel_context or {}).get("base_rate") or {}
usual = base.get("win_rate")
usual_txt = (f" (usual for this kind of setup: {usual:.0f}%)"
if usual is not None else "")
print(f" {Colors.CYAN}🤖{Colors.RESET} AI estimate: about a "
f"{Colors.BOLD}{report.panel_probability:.0f}%{Colors.RESET} "
f"chance this trade pays off{usual_txt}. "
f"{report.panel_rationale} Still being tested.")
for t in report.panel_takes:
p_win = t.get("p_win")
p_txt = f"{p_win:3.0f}%" if p_win is not None else " - "
label = t['persona'] if len(report.panel_takes) > 1 else "reasons"
print(f" {Colors.GRAY}{label:<11} {p_txt} "
f"for: {t.get('reason_for') or '-'} | against: "
f"{t.get('reason_against') or '-'}{Colors.RESET}")
elif report.panel_verdict:
panel_label = ("AI macro view"
if report.panel_verdict_source == "macro" else "AI Panel")
print(f" {Colors.CYAN}🤖{Colors.RESET} {panel_label}: "
f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} "
f"({report.panel_confidence:.0f}%) — {report.panel_rationale}")
for t in report.panel_takes:
print(f" {Colors.GRAY}{t['persona']:<11} {t['stance']:<8} "
f"{t['note']}{Colors.RESET}")
# ── 6. DETAIL (only with --detail) ─────────────────────────────
if detail and report.primary_analysis:
print(f"\n {Colors.BOLD}Indicators ({report.primary_analysis.timeframe}){Colors.RESET}")
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
isc = signal_color(ind.signal)
print(f" {ind.name:<16} {isc}{ind.signal.name.replace('_',' '):<11}{Colors.RESET} "
f"{ind.confidence:>3.0f}% {Colors.GRAY}│ {ind.description}{Colors.RESET}")
levels = report.primary_analysis.key_levels
if levels:
swing_keys = sorted(
[k for k in levels if k.startswith("swing_")],
key=lambda k: levels[k], reverse=True,
)
if swing_keys:
print(f"\n {Colors.BOLD}Structure Levels{Colors.RESET}")
for k in swing_keys:
val = levels[k]
color = Colors.RED if val > report.current_price else Colors.GREEN
label = k.replace("_", " ").title()
print(f" {label:>20} {color}{val:.3f}{Colors.RESET}")
def report_to_dict(report: SignalReport) -> dict:
d = {
"symbol": report.symbol,
"display_name": report.display_name,
"timestamp": report.timestamp,
"current_price": report.current_price,
"signal": report.final_signal.name,
"score": round(report.final_score, 2),
"confidence": round(report.final_confidence, 2),
"quality": report.signal_quality,
"plain_words": plain_words(report),
"action": report.action_recommendation,
"market_regime": report.market_regime,
"volatility_regime": report.volatility_regime,
"mtf_agreement": report.mtf_agreement,
"mtf_signals": {k: v.name for k, v in report.mtf_signals.items()},
"event_risk": report.event_risk,
"event_description": report.event_description,
"news_sentiment": round(report.news_sentiment, 3),
"news_description": report.news_description,
"news_summary": report.news_summary,
"panel_verdict": report.panel_verdict,
"panel_confidence": round(report.panel_confidence, 1),
"panel_rationale": report.panel_rationale,
"panel_takes": report.panel_takes,
"panel_created_at": report.panel_created_at,
"panel_cache_status": report.panel_cache_status,
"panel_payload_hash": report.panel_payload_hash,
"panel_verdict_source": report.panel_verdict_source,
"panel_prompt_version": report.panel_prompt_version,
"panel_probability": round(report.panel_probability, 1),
"panel_context": report.panel_context,
"watch_line": report.watch_line,
"bullish_reasons": report.bullish_reasons,
"bearish_reasons": report.bearish_reasons,
"warnings": report.warnings,
}
if report.trade_setup:
ts = report.trade_setup
d["trade_setup"] = {
"direction": ts.direction,
"entry": ts.entry_price,
"stop_loss": ts.stop_loss,
"take_profit_1": ts.take_profit_1,
"take_profit_2": ts.take_profit_2,
"risk_reward": ts.risk_reward,
"risk_pips": round(ts.risk_pips, 1),
"reward_pips": round(ts.reward_pips, 1),
"strategy": ts.strategy,
"setup_pattern": ts.setup_pattern,
"round_trip_cost": ts.round_trip_cost,
"trail_arm_r": ts.trail_arm_r,
"trail_dist_r": ts.trail_dist_r,
}
if report.primary_analysis:
d["indicators"] = {
name: {
"signal": ind.signal.name,
"confidence": round(ind.confidence, 1),
"description": ind.description,
}
for name, ind in report.primary_analysis.indicators.items()
if not name.startswith("_") and ind.weight != 0
}
d["key_levels"] = {
k: round(v, 5) for k, v in report.primary_analysis.key_levels.items()
}
return d
# ══════════════════════════════════════════════════════════════════
# CLI
# ══════════════════════════════════════════════════════════════════
def parse_args():
parser = argparse.ArgumentParser(
description="ForexBot2 — XAU/USD Signal Intelligence",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python main.py Live signal (H1, summary)
python main.py --detail Live signal with full breakdown
python main.py --timeframe H4 --detail H4 timeframe
python main.py --json JSON output
""",
)
parser.add_argument("--pair", "-p", default="XAUUSD",
help="Pair to analyze: XAUUSD (default: XAUUSD)")
parser.add_argument("--timeframe", "-tf", default="H1",
choices=list(_TF_BY_NAME),
help="Primary analysis timeframe (default: H1)")
parser.add_argument("--detail", action="store_true",
help="Show full indicator breakdown")
parser.add_argument("--panel", action="store_true",
help="Run the LLM agent panel on the result "
"(advisory only; needs a DeepSeek key)")
parser.add_argument("--json", action="store_true",
help="Output as JSON")
parser.add_argument("--verbose", "-v", action="store_true",
help="Debug logging")
return parser.parse_args()
# The four validated primary timeframes (the same set the bot and the
# dashboard scan). M30/D1 were never validated as primaries.
_TF_BY_NAME = {
"M5": Timeframe.M5, "M15": Timeframe.M15,
"H1": Timeframe.H1, "H4": Timeframe.H4,
}
def _resolve_pair(s: str) -> str:
aliases = {
"XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD,
}
key = s.upper().strip()
if key in aliases:
return aliases[key]
raise SystemExit(f"Unknown pair '{s}'. Try one of: {', '.join(sorted(aliases))}")
def build_config(args) -> PlatformConfig:
"""Per-pair tuned config from the pair_signals factory.
The factory owns the validated confirmation map (M5->[M15,H1],
M15->[M5,H1], H1->[M15,H4], H4->[H1,D1]); passing None here keeps
the CLI on the same map as the bot and the dashboard. A CLI-local
table once paired M5 with the unvalidated [M1, M15].
"""
pair = _resolve_pair(args.pair)
return PAIR_PRODUCERS[pair](_TF_BY_NAME[args.timeframe], None)
def run_live(args, config: PlatformConfig):
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
aggregator = SignalAggregator(config)
report = aggregator.analyze_pair(symbol=config.symbol, log_source="cli")
# Explicit user intent: force bypasses the enabled/surface/quality
# toggles, but the daily panel budget and LLM cap still apply.
if getattr(args, "panel", False):
attach_panel(report, surface="cli", force=True)
# Display-only violent-move watch (dark by default; never touches
# the signal, only report.watch_line).
if violent_watch_enabled():
attach_watch(report, config.primary_timeframe,
get_app_config().violent_watch_settings)
if args.json:
payload = report_to_dict(report)
print(json.dumps(payload, indent=2))
return
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
print_signal_report(report, detail=args.detail)
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
def main():
args = parse_args()
log_level = logging.DEBUG if args.verbose else logging.WARNING
logging.basicConfig(
level=log_level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
config = build_config(args)
run_live(args, config)
if __name__ == "__main__":
main()