1793d9167a
Two bodies of work that touch the same files, so one commit. Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule, written before the runs): - SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier) is removed as the last step of the core. One definition (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's host side; D1 is a required frame of the M15 scan; mirrored in cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs 1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs 2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R. - FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1 (−9.7R), buy-only and sell-only on M15, the wide-stop cap (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob). - New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15 pins byte for byte. Twin and reference script MD5-equal on every window; recorder and feature-export parity re-proved; base-rate table rebuilt. - DISPLAY (books byte-identical): exposure.py — the lot size for trade_size.risk_per_trade (new config.json block, off by default, private to the owner) and the count of alerted same-direction plans still open (signals.alerted_at, read-only open_alerted_setups); the daily trend in words on Telegram, the CLI and the dashboard; signals.daily_trend logged. - providers/oanda: a date window with no bars is an empty page, not an error (a D1 pad starting on a Saturday could not be fetched). - Reports only, nothing shipped: seven context screens (no state nominated) and eight outside data sources (none adds a forecast to gold's own daily trend). - Independent verification (report section 5.13): every figure reproduced on both scripts, scoring identical to the previous commit on all four timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022; the neighbour clause is thin (a simple 100-day average misses the R line by 1.2R); "never adds a trade" is an observation, not a guarantee. Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects elsewhere, plus 4 found by an independent review of the fixes; all fixed, each with a test; no trade book changed: - telegram_bot: one _deliver_alert for the candle scan and the level break. The row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and un-stamped when no chat received it or the task is cancelled mid-stamp, so two alerts at one candle close count each other and no SQLite write runs on the event loop. The 5-bar cooldown counts bars of the scan's own frame (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after the settlement pause) and is restored from signals.db at start-up. The lot-size line goes to the owner's own chat only (a group the owner ran /autoscan in used to receive it). /status gains a news-model line and escapes failure details. - signal_log: mark_alerted returns the stamp it wrote; unmark_alerted; last_alerted_bars (read-only). - advisory_outcome: candle continuity is judged bar by bar inside the walk; a hole after a trade's exit no longer voids it. The stored-candle replay resolves 108 of 111 logged setups instead of 99, all equal to the live resolver's R. - providers/oanda: an empty date window is accepted only when it is 5 days or shorter and no malformed candle was skipped; a longer one is retried, then refused, instead of being cached as history with a hole. - news_engine: blank RSS titles skipped; one unreadable headline no longer costs the local model its whole batch (checked on the real bundle); model health recorded; no DeepSeek audit call in a process that does not archive; event-direction cache keyed per release; peek_week_events never fetches. - llm_client: chat_json never raises and never logs the API key (requests quotes the Authorization value in InvalidHeader; error bodies are redacted). - signal_aggregator: the scan's news block is guarded; the replay path (use_news=False) assigns exactly the values it did before. - agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input (event timing over the cached calendar week, headlines newest first). scripts/panel_replay.py replays the logged payload per persona; scripts/label_scan_outcomes.py counts with an ISO cutoff. - test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34); tests that write Parquet print a notice and return without pyarrow. Verification after the last code change: suite 632 passed, 0 failed (also with both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37 green; static Python 3.9 / SQLite 3.34 check clean on every touched file. Evidence: backtest/research/daily_trend_campaign_2026-09-30.md, context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6, 8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
489 lines
21 KiB
Python
Executable File
489 lines
21 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 exposure import attach_exposure
|
|
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 daily_trend_words(report: SignalReport) -> str:
|
|
"""The daily trend in words, and whether the trade plan goes with it
|
|
("Daily trend: rising — this buy goes with it"). "" when the scan
|
|
carries no daily trend. Display only (2026-09-30): on M15 the signal
|
|
core already drops a plan against a clear daily trend; on the other
|
|
timeframes the line is information for the reader."""
|
|
trend = getattr(report, "daily_trend", None)
|
|
if trend is None:
|
|
return ""
|
|
if trend == 0:
|
|
return "Daily trend: no clear direction"
|
|
word = "rising" if trend > 0 else "falling"
|
|
setup = report.trade_setup
|
|
if setup is None:
|
|
return f"Daily trend: {word}"
|
|
side = "with" if (setup.direction == "BUY") == (trend > 0) else "against"
|
|
return f"Daily trend: {word} — this {setup.direction.lower()} goes {side} it"
|
|
|
|
|
|
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)}")
|
|
trend_words = daily_trend_words(report)
|
|
if trend_words:
|
|
print(f" {trend_words}")
|
|
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}")
|
|
# Lot size for the owner's risk limit and the alerted plans still
|
|
# open in this direction (exposure.attach_exposure; display only).
|
|
for line in (report.size_line, report.open_line):
|
|
if line:
|
|
print(f" {Colors.BOLD}{line}{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,
|
|
"daily_trend": report.daily_trend,
|
|
"daily_trend_words": daily_trend_words(report),
|
|
"size_line": report.size_line,
|
|
"open_line": report.open_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)
|
|
|
|
# Display-only lot size (off until config.json sets trade_size) and
|
|
# the alerted plans still open in this direction.
|
|
attach_exposure(report, surface="cli")
|
|
|
|
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()
|