"""ForexBot2 architecture diagram → docs/architecture.png. Regenerate after architecture changes: venv/bin/python docs/architecture_diagram.py (matplotlib only — no graphviz on the dev box).""" import os import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import FancyBboxPatch, FancyArrowPatch W, H = 100.0, 100.0 fig, ax = plt.subplots(figsize=(16, 11.5)) ax.set_xlim(0, W); ax.set_ylim(0, H); ax.axis("off") INK = "#263238"; MUT = "#607D8B"; ARR = "#455A64"; HAUL = "#7B8FA1" def box(x, y, w, h, title, lines, fc, ec, title_fs=8.2, body_fs=6.6, lw=1.3): ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.35,rounding_size=0.9", facecolor=fc, edgecolor=ec, linewidth=lw, zorder=2)) cy = y + h - 1.9 ax.text(x + w / 2, cy, title, ha="center", va="center", fontsize=title_fs, fontweight="bold", color=INK, zorder=3) cy -= 2.3 for ln in lines: ax.text(x + w / 2, cy, ln, ha="center", va="center", fontsize=body_fs, color=INK, zorder=3) cy -= 1.85 def band(x, y, w, h, label, color): ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.3,rounding_size=1.2", facecolor="none", edgecolor=color, linewidth=1.0, linestyle=(0, (4, 3)), zorder=1)) ax.text(x + 0.9, y + h - 1.4, label, ha="left", va="center", fontsize=7.2, fontweight="bold", color=color, zorder=3) def arrow(p1, p2, color=ARR, lw=1.5, style="-|>", conn="arc3,rad=0.0", label=None, lx=0, ly=0, ls="solid", fs=6.4, rot=0): ax.add_patch(FancyArrowPatch(p1, p2, arrowstyle=style, mutation_scale=11, color=color, linewidth=lw, linestyle=ls, connectionstyle=conn, zorder=4)) if label: mx, my = (p1[0] + p2[0]) / 2 + lx, (p1[1] + p2[1]) / 2 + ly ax.text(mx, my, label, fontsize=fs, color=MUT, ha="center", va="center", zorder=5, rotation=rot, bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.9)) # ── title ──────────────────────────────────────────────────────────── ax.text(2, 98.4, "ForexBot2 — XAU/USD Signal-Intelligence Platform", fontsize=15, fontweight="bold", color=INK, ha="left") ax.text(2, 96.0, "one pipeline, four delivery surfaces · closed bars only · live == backtest parity", fontsize=8.5, color=MUT, ha="left") EXT_FC, EXT_EC = "#ECEFF1", "#90A4AE" DAT_FC, DAT_EC = "#E3F2FD", "#5C9BD1" NWS_FC, NWS_EC = "#E0F2F1", "#3D9C8F" COR_FC, COR_EC = "#FFFDF5", "#C9A227" REP_FC, REP_EC = "#FFE9A8", "#B8860B" DEL_FC, DEL_EC = "#E8F5E9", "#5FAF63" BTS_FC, BTS_EC = "#EDE7F6", "#8E6BC6" # ── external services (top band) ───────────────────────────────────── band(1, 85.0, 98, 9.4, "EXTERNAL SERVICES", "#78909C") box(2.5, 86.2, 15.5, 6.4, "Oanda v20 API", ["candles (live feed)", "practice orders"], EXT_FC, EXT_EC) box(19.5, 86.2, 15.5, 6.4, "Twelve Data · Tiingo", ["registered fallbacks", "(Tiingo: tick stream)"], EXT_FC, EXT_EC) box(36.5, 86.2, 15.5, 6.4, "Forex Factory", ["economic calendar", "MyFXBook RSS fallback"], EXT_FC, EXT_EC) box(53.5, 86.2, 12.5, 6.4, "News RSS ×4", ["gold / macro", "headlines"], EXT_FC, EXT_EC) box(67.5, 86.2, 13.5, 6.4, "DeepSeek API", ["V4-Flash LLM", "thinking: disabled"], EXT_FC, EXT_EC) box(82.5, 86.2, 15, 6.4, "Telegram API", ["owner bot +", "subscriber channel"], EXT_FC, EXT_EC) # ── data & context layer ───────────────────────────────────────────── box(2.5, 69.8, 33, 11.0, "providers/ (BaseDataProvider)", ["single-flight fetch · TTL+boundary cache", "forming-candle DROP · validate/clean", "paginated fetch_range · optional tick stream", "active feed knob: data_providers.active = oanda"], DAT_FC, DAT_EC) box(38.5, 69.8, 33, 11.0, "news_engine.py + llm_client.py", ["calendar events + keyword sentiment (gold-inverse)", "DeepSeek enrichment: batch headline scores,", "event gold-direction, market blurb — display-only,", "keyword fallback, daily request cap"], NWS_FC, NWS_EC) box(74.5, 69.8, 23, 11.0, "app_config.py", ["credentials & deployment:", "env → config.json → refuse", "(no hardcoded fallbacks)", ".env loaded at import"], "#FAFAFA", "#9E9E9E") # ── signal core container ──────────────────────────────────────────── ax.add_patch(FancyBboxPatch((2.5, 40.5), 95, 26.5, boxstyle="round,pad=0.35,rounding_size=1.2", facecolor=COR_FC, edgecolor=COR_EC, linewidth=1.6, zorder=1)) ax.text(50, 65.2, "SIGNAL CORE — runs identically in live scans and backtests (closed bars only)", ha="center", fontsize=8.4, fontweight="bold", color="#8a6d1a", zorder=3) box(4.5, 51.8, 28, 12.0, "engine.py — TechnicalEngine", ["9 scored indicators + ATR context:", "EMA · RSI · MACD · BB · ADX · ROC ·", "Keltner · divergence · candlestick", "regime → adaptive weights → composite"], "white", COR_EC) box(4.5, 42.0, 28, 8.4, "pair_signals/xauusd.py — factory", ["ALWAYS used: config.json overlay +", "validated per-TF deltas (hour blocks, trail…)"], "white", COR_EC) box(35.5, 42.0, 60, 20.7, "signal_aggregator.py — analyze_pair → analyze_from_dataframes", ["multi-TF agreement (M5→[M15,H1] · M15→[M5,H1] · H1→[M15,H4] · H4→[H1,D1])", "→ news context (warn-only by default) → diminishing-decay penalties", "→ exhaustion hard block → quality grade A+…F", "→ ordered post-grade gate chain (session · XAU SELL vol/hour · regime · M5 noise)", "→ setup generators: XAU ATR (A+/A) · generic swing · XAU_PB · XAU_CP", "→ entry · SL · TP1/TP2 · trailing plan"], "white", COR_EC, body_fs=6.9) # ── SignalReport hub ───────────────────────────────────────────────── box(31, 33.2, 38, 5.2, "SignalReport", ["signal (−2…+2) · grade · trade setup · reasons · news summary"], REP_FC, REP_EC, title_fs=8.8) # ── delivery surfaces ──────────────────────────────────────────────── band(1, 13.2, 98, 17.2, "DELIVERY SURFACES", "#4C8F50") box(2.5, 15.0, 12, 12.6, "main.py", ["CLI report", "--detail", "--json"], DEL_FC, DEL_EC) box(42.5, 15.0, 31, 12.6, "dashboard_publisher.py → Apache", ["independent daemon · scans at candle closes", "atomic publish to /var/www/trade:", "dashboard.html · snapshot.json · news.json", "+ news_summary.php — on-demand AI", "gold-impact summary (whitelist · cache · cap)"], DEL_FC, DEL_EC) box(76, 15.0, 21.5, 12.6, "telegram_bot.py", ["candle-boundary auto-scans", "quality threshold + dedup", "alerts + LLM context line", "WS level-breach monitor"], DEL_FC, DEL_EC) # ── backtest / QA ──────────────────────────────────────────────────── box(2.5, 3.2, 71.8, 8.0, "backtest/gpu_backtest.py — bar-by-bar replay through the SAME analyze_from_dataframes", ["realism (results-critical): next-open entries · raw-path resolution · spread charged · SL-first ties · close-only trail", "CuPy GPU / NumPy fallback · parquet cache · fed by providers.fetch_range (news off)"], BTS_FC, BTS_EC) box(77.5, 3.2, 20, 8.0, "test_all.py", ["461 tests · 31 modules", "no network (mocked)"], "#FAFAFA", "#9E9E9E") # ── arrows: short structural hops (solid dark) ─────────────────────── arrow((10, 86.2), (14, 81.2), label="OHLCV", lx=-3.4, ly=0.2) # oanda → providers arrow((27, 86.2), (24, 81.2), ls=(0, (3, 2)), lw=1.1) # fallbacks → providers arrow((44, 86.2), (48, 81.2)) # FF → news arrow((59.5, 86.2), (56, 81.2)) # RSS → news arrow((72, 86.2), (62, 81.2), label="LLM", lx=3.4, ly=0.6) # deepseek → news arrow((19, 69.8), (19, 64.2), label="DataFrames (UTC, closed bars)", lx=0.2, ly=0.9) arrow((33.5, 57.5), (35.5, 55.5)) # engine → aggregator arrow((32.5, 46.0), (35.5, 48.0)) # factory → aggregator arrow((55, 69.8), (55, 63.1), label="warn-only context", lx=0, ly=1.0) arrow((86, 69.8), (86, 67.3), ls=(0, (3, 2)), lw=1.1) # config → core arrow((50, 40.5), (50, 38.8)) # core → report arrow((36, 34.5), (8.5, 27.95), conn="arc3,rad=0.12") # report → cli arrow((60, 33.2), (58, 27.95)) # report → dashboard arrow((66, 34.5), (86.5, 27.95), conn="arc3,rad=-0.12") # report → telegram # ── long-haul edges (thin dashed, routed through gutters) ──────────── # telegram bot ↔ Telegram API — right-margin arc, clear of app_config arrow((95, 86.2), (95, 27.95), conn="arc3,rad=0.15", color=HAUL, lw=1.2, ls=(0, (4, 2))) ax.text(99.2, 57, "alerts / commands", fontsize=6.4, color=MUT, rotation=90, ha="center", va="center") # browser click → news_summary.php → DeepSeek arrow((66, 27.95), (73, 86.2), conn="arc3,rad=-0.18", color=HAUL, lw=1.2, ls=(0, (4, 2)), label="on-demand AI summary\n(browser click → PHP)", lx=2.5, ly=11.6) # providers.fetch_range → backtester — far-left margin channel arrow((1.5, 69.8), (1.5, 11.0), color=BTS_EC, lw=1.1, ls=(0, (3, 2)), label="fetch_range\n(history)", lx=3.6, ly=-1.2) # backtester replays the signal core — right gutter between boxes arrow((74.0, 11.6), (74.9, 40.3), color=HAUL, lw=1.3) ax.text(81.8, 38.9, "replays the exact signal code", fontsize=6.4, color=MUT, ha="center", va="center", zorder=5, bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.9)) if __name__ == "__main__": # importing the module must never rewrite the PNG out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "architecture.png") fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white") print(f"saved {out}")