#!/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 ": " 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()