diff --git a/.gitignore b/.gitignore
index 21c7d28..3ff71e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,6 @@ Thumbs.db
backtest/cache/
backtest/*.csv
backtest/__pycache__/
+
+# webapp server config (secrets live on the server only)
+webapp/config.php
diff --git a/telegram_bot.py b/telegram_bot.py
index 5c06083..6c1732c 100755
--- a/telegram_bot.py
+++ b/telegram_bot.py
@@ -68,6 +68,7 @@ from config import (
from data_provider import ForexDataProvider
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
+import web_publisher
# ── Force UTF-8 on Windows ────────────────────────────────────────────
if hasattr(sys.stdout, "reconfigure"):
@@ -619,6 +620,9 @@ _breach_tasks: Set[asyncio.Task] = set()
def _update_level_cache(reports: List[SignalReport]) -> None:
now = time.time()
for r in reports:
+ # Every report-producing path (boot, auto-scan, manual scan,
+ # breach rescan) flows through here — piggyback the web publish.
+ web_publisher.publish_report(r)
if r.primary_analysis and r.primary_analysis.key_levels:
levels = {
k: v for k, v in r.primary_analysis.key_levels.items()
@@ -701,7 +705,12 @@ async def _ws_heartbeat(ws) -> None:
alive through quiet periods (weekends, low-liquidity hours)."""
while True:
await asyncio.sleep(10)
- await ws.send(json.dumps({"action": "heartbeat"}))
+ try:
+ await ws.send(json.dumps({"action": "heartbeat"}))
+ except Exception:
+ # Connection is going down — the monitor loop reconnects;
+ # exiting quietly avoids an unretrieved-task-exception warning.
+ return
async def _ws_monitor(bot) -> None:
@@ -750,6 +759,8 @@ async def _ws_monitor(bot) -> None:
continue
breached = _check_level_breach(pair_symbol, price)
+ web_publisher.publish_tick(
+ pair_symbol, price, _last_prices.get(pair_symbol))
_last_prices[pair_symbol] = price
_last_price_ts[pair_symbol] = time.time()
@@ -785,9 +796,14 @@ async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
if not _feed_active():
break
logger.info(f"{label} auto-scan: firing")
- await _scan_timeframe(
- bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
- )
+ try:
+ await _scan_timeframe(
+ bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
+ )
+ except Exception:
+ # One bad scan (data outage, web publish, Telegram error)
+ # must never kill the loop — retry at the next candle.
+ logger.exception(f"{label} auto-scan iteration failed — will retry at next boundary")
except asyncio.CancelledError:
pass
finally:
diff --git a/test_all.py b/test_all.py
index 3e09260..96eb822 100644
--- a/test_all.py
+++ b/test_all.py
@@ -2493,6 +2493,122 @@ def _():
assert args2.cp is False
+# ══════════════════════════════════════════════════════════════════════
+# MODULE 13: web_publisher resilience — dashboard publishing must
+# NEVER be able to break the Telegram bot (no-raise contract) and a
+# failing scan must never kill an auto-scan loop.
+# ══════════════════════════════════════════════════════════════════════
+
+print("\n═══ Module 13: web_publisher resilience ═══")
+
+import asyncio as _aio
+import threading as _threading
+import web_publisher as wp
+
+
+@test("publish_* are silent no-ops when not configured")
+def _():
+ old = wp.INGEST_URL, wp.INGEST_TOKEN
+ wp.INGEST_URL, wp.INGEST_TOKEN = "", ""
+ try:
+ assert not wp.enabled()
+ before = wp._queue.qsize()
+ wp.publish_tick("XAU/USD", 4000.0, 3999.5)
+ wp.publish_report(object()) # junk input must not even be touched
+ assert wp._queue.qsize() == before
+ finally:
+ wp.INGEST_URL, wp.INGEST_TOKEN = old
+
+
+@test("publish_tick never raises: enqueue failure and junk input swallowed")
+def _():
+ old = wp.INGEST_URL, wp.INGEST_TOKEN
+ old_enq = wp._enqueue
+ wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:1/x", "t"
+
+ def boom(item):
+ raise RuntimeError("forced enqueue failure")
+
+ wp._enqueue = boom
+ try:
+ wp._last_tick_pub.clear()
+ wp.publish_tick("XAU/USD", 4000.0, None) # enqueue explodes
+ wp.publish_tick("TEST/A", "not-a-number", None) # float() explodes
+ finally:
+ wp._enqueue = old_enq
+ wp.INGEST_URL, wp.INGEST_TOKEN = old
+
+
+@test("publish_report never raises: broken report and enqueue failure swallowed")
+def _():
+ from signal_aggregator import SignalReport as _SR
+ old = wp.INGEST_URL, wp.INGEST_TOKEN
+ wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:1/x", "t"
+ old_enq = wp._enqueue
+ try:
+ wp.publish_report(object()) # build_payload explodes internally
+
+ def boom(item):
+ raise RuntimeError("forced enqueue failure")
+
+ wp._enqueue = boom
+ r = _SR(symbol="XAU/USD", display_name="XAU/USD",
+ timestamp="2026-07-17 12:45:00+00:00")
+ wp.publish_report(r) # valid report, enqueue explodes
+ finally:
+ wp._enqueue = old_enq
+ wp.INGEST_URL, wp.INGEST_TOKEN = old
+
+
+@test("build_payload works on a minimal report (no analysis, no setup)")
+def _():
+ from signal_aggregator import SignalReport as _SR
+ r = _SR(symbol="XAU/USD", display_name="XAU/USD",
+ timestamp="2026-07-17 12:45:00+00:00")
+ p = wp.build_payload(r)
+ assert p["signal"] == "NEUTRAL" and p["trade_setup"] is None
+ assert p["bar_ts"] == "2026-07-17 12:45:00"
+ assert "summary" in p and p["indicators"] == {}
+
+
+@test("worker thread survives a dead ingest endpoint (queue always drains)")
+def _():
+ old = wp.INGEST_URL, wp.INGEST_TOKEN
+ # port 9 (discard) on localhost → immediate connection refused
+ wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:9/ingest.php", "t"
+ try:
+ wp._last_tick_pub.clear()
+ wp.publish_tick("XAU/USD", 4001.0, None)
+ wp._queue.join() # hangs forever if task_done is ever skipped
+ workers = [t for t in _threading.enumerate() if t.name == "web-publisher"]
+ assert workers and workers[0].is_alive(), "publisher worker died"
+ finally:
+ wp.INGEST_URL, wp.INGEST_TOKEN = old
+
+
+@test("_candle_loop survives a failing scan iteration and keeps looping")
+def _():
+ import telegram_bot as tb
+ calls = {"n": 0}
+
+ async def fake_scan(bot, tf, confirms):
+ calls["n"] += 1
+ raise RuntimeError("forced scan failure")
+
+ orig = (tb._scan_timeframe, tb._seconds_until_boundary, tb._feed_active)
+ tb._scan_timeframe = fake_scan
+ tb._seconds_until_boundary = lambda m, buffer=10: 0.01
+ tb._feed_active = lambda: calls["n"] < 2
+ try:
+ _aio.run(_aio.wait_for(
+ tb._candle_loop(None, Timeframe.M5, 5), timeout=10))
+ assert calls["n"] == 2, \
+ f"loop died after a failing iteration (ran {calls['n']} of 2)"
+ finally:
+ (tb._scan_timeframe, tb._seconds_until_boundary,
+ tb._feed_active) = orig
+
+
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════
diff --git a/web_publisher.py b/web_publisher.py
new file mode 100644
index 0000000..adcf112
--- /dev/null
+++ b/web_publisher.py
@@ -0,0 +1,482 @@
+"""
+Web-dashboard publisher — pushes ticks and signal reports to the LAMP
+webapp's ingest endpoint (webapp/ingest.php), which stores them in MySQL.
+
+Disabled unless BOTH env vars are set:
+ WEBAPP_INGEST_URL e.g. https://example.com/webapp/ingest.php
+ WEBAPP_INGEST_TOKEN shared secret (must match webapp/config.php)
+
+All publishing is fire-and-forget through a bounded queue drained by a
+daemon thread: the bot's WebSocket loop and scan executor are never
+blocked by a slow or dead web server. Failures are dropped with a
+rate-limited warning.
+
+CONTRACT: the public functions (publish_tick, publish_report) NEVER
+raise — any internal error is swallowed and logged. The Telegram bot
+calls them from its WS tick loop and scan paths without guards; a
+dashboard/DB/web outage must never be able to break the bot.
+
+`build_payload()` is pure (no I/O) — it converts a SignalReport into the
+JSON the dashboard renders, including the plain-English translation of
+every indicator, warning, and the overall verdict.
+"""
+
+import json
+import logging
+import os
+import queue
+import threading
+import time
+from typing import Any, Dict, Optional
+
+import requests
+
+from config import SignalStrength
+from signal_aggregator import SignalReport
+
+logger = logging.getLogger(__name__)
+
+INGEST_URL = os.environ.get("WEBAPP_INGEST_URL", "")
+INGEST_TOKEN = os.environ.get("WEBAPP_INGEST_TOKEN", "")
+
+TICK_MIN_INTERVAL = 1.0 # seconds between published ticks per symbol
+_QUEUE_MAX = 200
+_WARN_EVERY = 60.0 # rate-limit failure warnings
+
+_queue: "queue.Queue[dict]" = queue.Queue(maxsize=_QUEUE_MAX)
+_worker_started = False
+_worker_lock = threading.Lock()
+_last_tick_pub: Dict[str, float] = {}
+_last_warn_ts = 0.0
+
+
+def enabled() -> bool:
+ return bool(INGEST_URL and INGEST_TOKEN)
+
+
+# ── Transport ────────────────────────────────────────────────────────
+
+def _ensure_worker() -> None:
+ global _worker_started
+ if _worker_started:
+ return
+ with _worker_lock:
+ if _worker_started:
+ return
+ t = threading.Thread(target=_drain, name="web-publisher", daemon=True)
+ t.start()
+ _worker_started = True
+
+
+def _warn_limited(msg: str) -> None:
+ """Rate-limited warning (max one per _WARN_EVERY seconds)."""
+ global _last_warn_ts
+ now = time.time()
+ if now - _last_warn_ts > _WARN_EVERY:
+ _last_warn_ts = now
+ logger.warning(msg)
+
+
+def _drain() -> None:
+ """Worker loop. Hardened so the thread itself can never die: every
+ step is guarded, and task_done() always runs so queue.join() can't
+ hang."""
+ while True:
+ try:
+ item = _queue.get()
+ except Exception:
+ time.sleep(1.0)
+ continue
+ try:
+ resp = requests.post(INGEST_URL, json=item, timeout=5)
+ if resp.status_code != 200:
+ raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
+ except Exception as exc:
+ _warn_limited(f"Web publish failed ({item.get('type')}): {exc}")
+ finally:
+ try:
+ _queue.task_done()
+ except Exception:
+ pass
+
+
+def _enqueue(item: dict) -> None:
+ _ensure_worker()
+ try:
+ _queue.put_nowait(item)
+ except queue.Full:
+ pass # dashboard data is best-effort; never back-pressure the bot
+
+
+# ── Public API ───────────────────────────────────────────────────────
+
+def publish_tick(symbol: str, price: float, prev: Optional[float] = None) -> None:
+ """Publish a live tick (throttled to 1/s per symbol). No-op unless
+ configured. Never raises (see module contract)."""
+ if not enabled():
+ return
+ try:
+ now = time.time()
+ if now - _last_tick_pub.get(symbol, 0.0) < TICK_MIN_INTERVAL:
+ return
+ _last_tick_pub[symbol] = now
+ _enqueue({
+ "token": INGEST_TOKEN,
+ "type": "tick",
+ "symbol": symbol,
+ "price": float(price),
+ "prev": float(prev) if prev is not None else None,
+ "tick_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(now)),
+ })
+ except Exception:
+ _warn_limited("publish_tick failed — tick dropped (bot unaffected)")
+
+
+def publish_report(report: SignalReport) -> None:
+ """Publish a full signal report. No-op unless configured. Never
+ raises (see module contract)."""
+ if not enabled():
+ return
+ try:
+ payload = build_payload(report)
+ _enqueue({
+ "token": INGEST_TOKEN,
+ "type": "report",
+ # display_name is the canonical "XAU/USD" form in both the
+ # live path and backtest-style replays; report.symbol is not.
+ "symbol": report.display_name or report.symbol,
+ "timeframe": payload["timeframe"],
+ "bar_ts": payload["bar_ts"],
+ "signal": payload["signal"],
+ "quality": payload["quality"] or "F",
+ "score": payload["score"],
+ "confidence": payload["confidence"],
+ "payload": payload,
+ })
+ except Exception:
+ _warn_limited("publish_report failed — report dropped (bot unaffected)")
+
+
+# ── Payload builder (pure) ───────────────────────────────────────────
+
+def build_payload(report: SignalReport) -> Dict[str, Any]:
+ """SignalReport → dashboard JSON, with plain-English throughout."""
+ pa = report.primary_analysis
+ tf = pa.timeframe if pa else "?"
+
+ indicators: Dict[str, Any] = {}
+ if pa:
+ for name, ind in pa.indicators.items():
+ if name.startswith("_"):
+ continue
+ indicators[name] = {
+ "value": _num(ind.value),
+ "signal": ind.signal.name,
+ "confidence": round(float(ind.confidence), 1),
+ "description": ind.description,
+ "plain": _plain_indicator(name, ind, report),
+ }
+
+ d: Dict[str, Any] = {
+ "v": 1,
+ "symbol": report.symbol,
+ "display_name": report.display_name,
+ "timeframe": tf,
+ "bar_ts": _bar_ts(report.timestamp),
+ "price": _num(report.current_price),
+ "signal": report.final_signal.name,
+ "score": round(float(report.final_score), 2),
+ "confidence": round(float(report.final_confidence), 2),
+ "quality": report.signal_quality,
+ "market_regime": report.market_regime,
+ "volatility_regime": report.volatility_regime,
+ "regime_plain": _plain_regime(report.market_regime,
+ report.volatility_regime),
+ "mtf_agreement": bool(report.mtf_agreement),
+ "mtf_signals": {k: v.name for k, v in report.mtf_signals.items()},
+ "mtf_plain": _plain_mtf(report),
+ "summary": _summary(report),
+ "warnings": list(report.warnings),
+ "warnings_plain": [_plain_warning(w) for w in report.warnings],
+ "indicators": indicators,
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
+ }
+
+ if report.trade_setup:
+ ts = report.trade_setup
+ d["trade_setup"] = {
+ "direction": ts.direction,
+ "strategy": ts.strategy,
+ "entry": _num(ts.entry_price),
+ "stop_loss": _num(ts.stop_loss),
+ "take_profit_1": _num(ts.take_profit_1),
+ "take_profit_2": _num(ts.take_profit_2),
+ "risk_reward": _num(ts.risk_reward),
+ "risk_usd": _num(abs(ts.entry_price - ts.stop_loss)),
+ }
+ else:
+ d["trade_setup"] = None
+ return d
+
+
+def _num(v) -> Optional[float]:
+ try:
+ f = float(v)
+ except (TypeError, ValueError):
+ return None
+ return round(f, 5)
+
+
+def _bar_ts(ts: str) -> str:
+ """Pandas index label ('2026-07-17 14:45:00+00:00') → MySQL DATETIME."""
+ s = str(ts).replace("T", " ")
+ s = s.split("+")[0].split(".")[0].strip()
+ return s[:19]
+
+
+# ── Plain-English layer ──────────────────────────────────────────────
+
+_BUYISH = (SignalStrength.BUY, SignalStrength.STRONG_BUY)
+_SELLISH = (SignalStrength.SELL, SignalStrength.STRONG_SELL)
+
+
+def _plain_indicator(name: str, ind, report: SignalReport) -> str:
+ sig = ind.signal
+ desc = ind.description or ""
+ low = desc.lower()
+ v = None
+ try:
+ v = float(ind.value)
+ except (TypeError, ValueError):
+ pass
+
+ if name == "ema_trend":
+ if sig in _BUYISH:
+ txt = "Price is trading above its stacked moving averages — the short-term trend points up."
+ elif sig in _SELLISH:
+ txt = "Price is trading below its stacked moving averages — the short-term trend points down."
+ else:
+ txt = "The moving averages are tangled together — no clear trend direction right now."
+ if "compressed" in low:
+ txt += " The averages are squeezed tight — often the calm before a bigger move."
+ return txt
+
+ if name == "rsi":
+ r = v if v is not None else 50.0
+ base = f"RSI is {r:.0f}. "
+ if r >= 70:
+ zone = "Price has climbed unusually fast — overbought territory, where pullbacks are common."
+ elif r >= 60:
+ zone = "Buyers currently have the momentum."
+ elif r > 40:
+ zone = "Momentum is balanced — neither buyers nor sellers dominate."
+ elif r > 30:
+ zone = "Sellers currently have the momentum."
+ else:
+ zone = "Price has dropped unusually fast — oversold territory, where bounces are common."
+ if "pullback zone" in low:
+ zone += " In this trend, that dip is exactly what the engine looks to buy into." \
+ if sig in _BUYISH else \
+ " In this downtrend, that bounce is exactly what the engine looks to sell into."
+ return base + zone
+
+ if name == "macd":
+ if sig == SignalStrength.STRONG_BUY:
+ return "Upward momentum is building and accelerating."
+ if sig == SignalStrength.BUY:
+ return "Momentum has just turned upward."
+ if sig == SignalStrength.SELL:
+ return "Momentum has just turned downward."
+ if sig == SignalStrength.STRONG_SELL:
+ return "Downward momentum is building and accelerating."
+ if "fading" in low:
+ return "The most recent push is losing steam."
+ return "Momentum is flat — no real push in either direction."
+
+ if name == "bollinger":
+ p = v if v is not None else 0.5
+ if p < 0:
+ txt = "Price has broken below its typical range — unusually stretched to the downside."
+ elif p < 0.2:
+ txt = "Price is near the bottom of its recent range."
+ elif p > 1:
+ txt = "Price has broken above its typical range — unusually stretched to the upside."
+ elif p > 0.8:
+ txt = "Price is near the top of its recent range."
+ else:
+ txt = "Price is inside its normal range — nothing unusual."
+ if "squeeze" in low:
+ txt += " The range is tightening — bigger moves often follow."
+ return txt
+
+ if name == "adx":
+ a = v if v is not None else 0.0
+ if a < 20:
+ txt = "No meaningful trend — the market is drifting."
+ elif a < 25:
+ txt = "A trend may be forming, but it is not confirmed yet."
+ elif a < 50:
+ txt = "A confirmed trend is in force."
+ else:
+ txt = "A very strong trend is in force."
+ if sig in _BUYISH:
+ txt += " Buyers are the stronger side."
+ elif sig in _SELLISH:
+ txt += " Sellers are the stronger side."
+ if "emerging" in low:
+ txt += " Trend strength is rising quickly — a new trend may be igniting."
+ return txt
+
+ if name == "roc":
+ r = v if v is not None else 0.0
+ txt = f"Price is {r:+.2f}% versus 10 bars ago"
+ if sig in _BUYISH:
+ return txt + " — momentum points up."
+ if sig in _SELLISH:
+ return txt + " — momentum points down."
+ return txt + " — momentum is flat."
+
+ if name == "keltner":
+ if "squeeze" in low and ("released" in low or "fired" in low):
+ side = "upward" if sig in _BUYISH else "downward" if sig in _SELLISH else "one"
+ return f"A volatility squeeze just released with {side} force — these bursts often start real moves."
+ if "squeeze" in low:
+ return "Volatility is coiled tight — a bigger move often follows a squeeze like this."
+ if sig in _BUYISH:
+ return "Price is riding above its volatility channel — a sign the up-move wants to continue."
+ if sig in _SELLISH:
+ return "Price is pressing below its volatility channel — a sign the down-move wants to continue."
+ return "Price is inside its normal volatility channel — nothing unusual."
+
+ if name == "divergence":
+ if sig in _BUYISH:
+ return ("Price made a lower low but momentum did not — selling pressure "
+ "may be quietly fading (bullish divergence).")
+ if sig in _SELLISH:
+ return ("Price made a higher high but momentum did not — buying pressure "
+ "may be quietly fading (bearish divergence).")
+ n = int(v) if v is not None else 0
+ if n > 0:
+ return "Both bullish and bearish divergences are present — a mixed message."
+ return "Price and momentum agree — no hidden warning signs."
+
+ if name == "candlestick":
+ if sig == SignalStrength.NEUTRAL and ("no " in low or not desc):
+ return "No notable candle pattern on the last bar."
+ hint = ("a bullish hint" if sig in _BUYISH
+ else "a bearish hint" if sig in _SELLISH
+ else "an indecision signal")
+ return f"{desc} — {hint} from the last candle's shape."
+
+ if name == "atr":
+ a = v if v is not None else 0.0
+ vol = {
+ "low": "Very quiet market.",
+ "normal": "Normal volatility.",
+ "high": "Livelier than usual — swings are wider.",
+ "extreme": "Unusually wild — moves are far larger than normal.",
+ }.get(report.volatility_regime, "")
+ return f"Bars are moving about ${a:.2f} on average. {vol}".strip()
+
+ return desc or "—"
+
+
+_WARNING_PLAIN = [
+ ("TREND EXHAUSTION", "Price has already travelled a long way without a rest — chasing here is risky."),
+ ("EXHAUSTED TREND", "The trend looks exhausted — late entries are blocked."),
+ ("EXTREME VOLATILITY", "The market is unusually violent right now — stops must be much wider."),
+ ("EXTREME", "The market is unusually volatile right now."),
+ ("LOW VOLATILITY", "Very quiet market — a breakout may be brewing."),
+ ("SESSION FILTER", "Outside the strategy's approved trading hours — no trades are taken."),
+ ("DAY FILTER", "Trading is disabled on this weekday."),
+ ("CONFLICT", "The timeframes disagree with each other — a mixed picture."),
+ ("SELL FILTER", "A safety rule is blocking short trades here."),
+ ("SELL SUPPRESSED", "A safety rule is blocking short trades here."),
+ ("M5 FILTER", "An M5-specific noise filter blocked this signal."),
+ ("NO PULLBACK", "Price is far from its average — better to wait for a pullback than chase."),
+ ("WEAK PULLBACK", "The entry is slightly extended past the ideal pullback."),
+ ("NEAR EMA200", "Price is sitting at a major long-term average, where moves often stall or reverse."),
+ ("FAR FROM EMA200", "Price is unusually far above/below its long-term average."),
+ ("COUNTER-TREND", "This signal points against the bigger trend."),
+ ("EVENT RISK", "A high-impact news event is near — expect sudden spikes."),
+ ("STALE", "Market data is stale — the signal is suppressed."),
+ ("RSI", "Momentum is already stretched in this direction."),
+ ("BB ", "Price is pressed against the edge of its range — a poor entry point."),
+]
+
+
+def _plain_warning(w: str) -> str:
+ up = w.upper()
+ for key, plain in _WARNING_PLAIN:
+ if key in up:
+ return plain
+ return w
+
+
+_REGIME_PLAIN = {
+ "trending_up": "a clear uptrend is in force",
+ "trending_down": "a clear downtrend is in force",
+ "trending": "there is strong momentum but its direction is unclear (mixed)",
+ "transitioning": "a new trend may be starting",
+ "ranging": "the market is moving sideways with no trend",
+ "unknown": "the market state is unclear",
+}
+
+_VOL_PLAIN = {
+ "low": "very quiet",
+ "normal": "normal",
+ "high": "livelier than usual",
+ "extreme": "unusually wild",
+}
+
+
+def _plain_regime(regime: str, vol: str) -> str:
+ r = _REGIME_PLAIN.get(regime, regime)
+ v = _VOL_PLAIN.get(vol, vol)
+ return f"Right now {r}, and volatility is {v}."
+
+
+def _plain_mtf(report: SignalReport) -> str:
+ if not report.mtf_signals:
+ return ""
+ parts = [f"{tf} says {sig.name.replace('_', ' ')}"
+ for tf, sig in report.mtf_signals.items()]
+ txt = "Other timeframes: " + ", ".join(parts) + "."
+ if report.final_signal != SignalStrength.NEUTRAL:
+ txt += (" They back the main signal." if report.mtf_agreement
+ else " They do not line up with the main signal.")
+ return txt
+
+
+_SIG_PHRASE = {
+ "STRONG_BUY": "Strong BUY signal",
+ "BUY": "BUY signal",
+ "NEUTRAL": "No trade",
+ "SELL": "SELL signal",
+ "STRONG_SELL": "Strong SELL signal",
+}
+
+
+def _summary(report: SignalReport) -> str:
+ sig = report.final_signal.name
+ phrase = _SIG_PHRASE.get(sig, sig)
+ score = float(report.final_score)
+ conf = float(report.final_confidence)
+ q = report.signal_quality or "F"
+
+ if report.trade_setup:
+ ts = report.trade_setup
+ risk = abs(ts.entry_price - ts.stop_loss)
+ head = (f"{phrase} — grade {q}. {ts.direction} near {ts.entry_price:.2f}, "
+ f"stop {ts.stop_loss:.2f} (${risk:.2f} risk), targets "
+ f"{ts.take_profit_1:.2f} and {ts.take_profit_2:.2f}.")
+ elif sig == "NEUTRAL":
+ lean = ("leans bullish" if score >= 10
+ else "leans bearish" if score <= -10 else "is flat")
+ head = (f"No trade. The overall read {lean} "
+ f"(score {score:+.0f} on a ±100 scale, confidence {conf:.0f}/100) — "
+ f"the engine needs at least ±25 with confidence 50 before acting. Grade {q}.")
+ else:
+ head = (f"{phrase} (grade {q}, score {score:+.0f}, confidence {conf:.0f}), "
+ f"but no trade setup passed the risk checks.")
+ return head
diff --git a/webapp/.htaccess b/webapp/.htaccess
new file mode 100644
index 0000000..5a89eda
--- /dev/null
+++ b/webapp/.htaccess
@@ -0,0 +1,5 @@
+# Deny web access to config, schema, and docs (needs AllowOverride enabled;
+# if AllowOverride is Off, copy these rules into the vhost instead).
+
+ Require all denied
+
diff --git a/webapp/README.md b/webapp/README.md
new file mode 100644
index 0000000..92ed503
--- /dev/null
+++ b/webapp/README.md
@@ -0,0 +1,65 @@
+# ForexBot2 web dashboard (LAMP)
+
+Numbers-only live dashboard: latest XAU/USD price plus each timeframe's
+signal, indicator values, and a plain-English reading of every one.
+
+## How it flows
+
+```
+telegram_bot.py (owns the single Twelve Data WebSocket)
+ ├─ every tick ──► POST ingest.php ──► MySQL live_price (throttled 1/s)
+ └─ every scan ──► POST ingest.php ──► MySQL signal_reports
+ │
+browser ──► index.html + app.js ──► GET api.php ┘
+ (price poll 2 s · reports poll 15 s)
+```
+
+The dashboard needs **no Python on the web server** — only PHP + MySQL.
+The bot can run anywhere with outbound HTTPS to the site.
+
+## Server setup (once)
+
+1. Copy this directory to the web root, e.g. `/var/www/html/webapp/`.
+2. Create DB + tables: `mysql -u root -p < schema.sql`
+ (also create the app user — commands at the bottom of schema.sql).
+3. `cp config.sample.php config.php` and fill in DB credentials and a
+ fresh `ingest_token` (`openssl rand -hex 32`).
+4. Apache must allow `.htaccess` (`AllowOverride FileInfo Limit` or
+ `All`) so config/schema stay unreachable — or copy those deny rules
+ into the vhost. Verify: `curl -I https://site/webapp/config.php`
+ must return 403.
+
+## Bot setup
+
+Set two environment variables where `telegram_bot.py` runs:
+
+```bash
+export WEBAPP_INGEST_URL="https://your-site/webapp/ingest.php"
+export WEBAPP_INGEST_TOKEN=""
+```
+
+Unset = publishing is disabled and the bot behaves exactly as before.
+Publishing is fire-and-forget (bounded queue + daemon thread): a dead
+web server can never block or slow the bot.
+
+## Security notes (public access)
+
+- `api.php` is read-only and public by design; `ingest.php` requires the
+ shared token (use HTTPS so it isn't sent in the clear).
+- `config.php` / `schema.sql` are denied by `.htaccess`.
+- Optional hardening: rate-limit `api.php` (mod_evasive/fail2ban), and
+ restrict `ingest.php` to the bot host's IP in the vhost.
+
+## Data growth
+
+- `live_price`: 1 row per symbol (upserted in place).
+- `signal_reports`: ~400 rows/day for XAU (M5+M15+H1+H4) — ~150k
+ rows/year, no retention job needed. It doubles as a durable log of
+ every signal the engine produced (the `payload` column holds the full
+ report JSON).
+
+## Local smoke test (no Apache needed)
+
+```bash
+php -S 127.0.0.1:8090 -t webapp/ # then open http://127.0.0.1:8090/
+```
diff --git a/webapp/api.php b/webapp/api.php
new file mode 100644
index 0000000..ca5f855
--- /dev/null
+++ b/webapp/api.php
@@ -0,0 +1,79 @@
+ false, 'error' => $msg]);
+ exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
+ fail(405, 'GET only');
+}
+
+$cfg = require __DIR__ . '/config.php';
+
+$symbol = (string)($_GET['symbol'] ?? $cfg['symbols'][0]);
+if (!in_array($symbol, $cfg['symbols'], true)) {
+ fail(400, 'unknown symbol');
+}
+$what = (string)($_GET['what'] ?? 'all');
+
+try {
+ $pdo = new PDO($cfg['db_dsn'], $cfg['db_user'], $cfg['db_pass'], [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ ]);
+} catch (PDOException $e) {
+ fail(500, 'db unavailable');
+}
+
+$out = ['ok' => true, 'server_time' => gmdate('Y-m-d H:i:s')];
+
+try {
+ $st = $pdo->prepare(
+ 'SELECT price, prev_price, tick_at,
+ TIMESTAMPDIFF(SECOND, tick_at, UTC_TIMESTAMP()) AS age_sec
+ FROM live_price WHERE symbol = :s'
+ );
+ $st->execute([':s' => $symbol]);
+ $row = $st->fetch(PDO::FETCH_ASSOC);
+ $out['price'] = $row === false ? null : [
+ 'price' => (float)$row['price'],
+ 'prev_price' => $row['prev_price'] === null ? null : (float)$row['prev_price'],
+ 'tick_at' => $row['tick_at'],
+ 'age_sec' => (int)$row['age_sec'],
+ ];
+
+ if ($what !== 'price') {
+ $st = $pdo->prepare(
+ 'SELECT r.timeframe, r.payload, r.updated_at,
+ TIMESTAMPDIFF(SECOND, r.updated_at, UTC_TIMESTAMP()) AS age_sec
+ FROM signal_reports r
+ JOIN (SELECT timeframe, MAX(id) AS mid
+ FROM signal_reports WHERE symbol = :s
+ GROUP BY timeframe) x ON x.mid = r.id'
+ );
+ $st->execute([':s' => $symbol]);
+ $reports = [];
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
+ $p = json_decode((string)$r['payload'], true);
+ if (is_array($p)) {
+ $p['stored_at'] = $r['updated_at'];
+ $p['age_sec'] = (int)$r['age_sec'];
+ $reports[$r['timeframe']] = $p;
+ }
+ }
+ $out['reports'] = $reports;
+ }
+} catch (PDOException $e) {
+ error_log('api.php: ' . $e->getMessage());
+ fail(500, 'db error');
+}
+
+echo json_encode($out, JSON_UNESCAPED_UNICODE);
diff --git a/webapp/app.js b/webapp/app.js
new file mode 100644
index 0000000..819d843
--- /dev/null
+++ b/webapp/app.js
@@ -0,0 +1,286 @@
+/* ForexBot2 dashboard — polls api.php, renders numbers + plain English.
+ No frameworks, no build step. All text lands via textContent. */
+"use strict";
+
+const API = "api.php";
+const PRICE_POLL_MS = 2000;
+const REPORT_POLL_MS = 15000;
+const TF_ORDER = ["5min", "15min", "1h", "4h"];
+const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
+const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
+
+const SIG_TEXT = {
+ STRONG_BUY: "▲ STRONG BUY", BUY: "▲ BUY", NEUTRAL: "— NEUTRAL",
+ SELL: "▼ SELL", STRONG_SELL: "▼ STRONG SELL",
+};
+const SIG_CLASS = {
+ STRONG_BUY: "sig-buy", BUY: "sig-buy", NEUTRAL: "sig-neutral",
+ SELL: "sig-sell", STRONG_SELL: "sig-sell",
+};
+const IND_LABEL = {
+ ema_trend: "EMA trend", rsi: "RSI", macd: "MACD", bollinger: "Bollinger",
+ adx: "ADX", roc: "Momentum (ROC)", keltner: "Keltner", divergence: "Divergence",
+ candlestick: "Candlestick", atr: "Volatility (ATR)",
+};
+const IND_ORDER = ["ema_trend", "rsi", "macd", "roc", "keltner", "bollinger",
+ "adx", "divergence", "candlestick", "atr"];
+
+const state = { price: null, reports: {}, activeTf: "5min", lastPrice: null };
+
+const $ = (id) => document.getElementById(id);
+const el = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined) n.textContent = text;
+ return n;
+};
+
+// ── polling ──────────────────────────────────────────────────────────
+
+async function fetchJson(url) {
+ const r = await fetch(url, { cache: "no-store" });
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+}
+
+async function pollPrice() {
+ try {
+ const d = await fetchJson(API + "?what=price");
+ if (d.ok) { state.price = d.price; hideError(); renderPrice(); }
+ } catch (e) { showError("Price feed unreachable — retrying…"); }
+}
+
+async function pollReports() {
+ try {
+ const d = await fetchJson(API);
+ if (d.ok) {
+ state.price = d.price;
+ state.reports = d.reports || {};
+ if (!state.reports[state.activeTf]) {
+ const first = TF_ORDER.find(tf => state.reports[tf]);
+ if (first) state.activeTf = first;
+ }
+ hideError();
+ renderPrice(); renderStrip(); renderDetail();
+ }
+ } catch (e) { showError("Server unreachable — retrying…"); }
+}
+
+function showError(msg) {
+ const b = $("error-banner");
+ b.textContent = msg; b.classList.remove("hidden");
+}
+function hideError() { $("error-banner").classList.add("hidden"); }
+
+// ── rendering ────────────────────────────────────────────────────────
+
+function renderPrice() {
+ const p = state.price;
+ const hero = $("hero-price"), dot = $("feed-dot"), status = $("feed-status");
+ if (!p) {
+ hero.textContent = "—";
+ status.textContent = "no price data yet";
+ dot.className = "dot";
+ return;
+ }
+ const v = Number(p.price);
+ hero.textContent = v.toFixed(2);
+ hero.classList.remove("up", "down");
+ if (state.lastPrice !== null && v !== state.lastPrice) {
+ hero.classList.add(v > state.lastPrice ? "up" : "down");
+ }
+ state.lastPrice = v;
+
+ const age = Number(p.age_sec);
+ if (age <= 60) {
+ dot.className = "dot live";
+ status.textContent = "live · tick " + age + "s ago";
+ } else {
+ dot.className = "dot stale";
+ status.textContent = "feed stale — last tick " + fmtAge(age) + " ago (market closed or bot offline)";
+ }
+}
+
+function renderStrip() {
+ const strip = $("tf-strip");
+ strip.textContent = "";
+ for (const tf of TF_ORDER) {
+ const rep = state.reports[tf];
+ const tile = el("button", "tf-tile");
+ tile.setAttribute("aria-selected", String(tf === state.activeTf));
+ tile.addEventListener("click", () => { state.activeTf = tf; renderStrip(); renderDetail(); });
+ tile.appendChild(el("div", "tf-name", TF_LABEL[tf] || tf));
+ if (rep) {
+ const sig = el("div", "tf-sig", SIG_TEXT[rep.signal] || rep.signal);
+ const cls = SIG_CLASS[rep.signal];
+ if (cls === "sig-buy") sig.style.color = "var(--good)";
+ if (cls === "sig-sell") sig.style.color = "var(--critical)";
+ tile.appendChild(sig);
+ tile.appendChild(el("div", "tf-sub",
+ "grade " + (rep.quality || "F") + " · score " + fmtNum(rep.score, 0)));
+ } else {
+ tile.appendChild(el("div", "tf-sig", "…"));
+ tile.appendChild(el("div", "tf-sub", "no data yet"));
+ }
+ strip.appendChild(tile);
+ }
+}
+
+function renderDetail() {
+ const root = $("detail");
+ root.textContent = "";
+ const tf = state.activeTf;
+ const rep = state.reports[tf];
+ if (!rep) {
+ root.appendChild(el("div", "empty",
+ "No " + (TF_LABEL[tf] || tf) + " report yet — it appears after the next candle close."));
+ return;
+ }
+
+ // verdict card
+ const verdict = el("section", "card");
+ const row = el("div", "verdict-row");
+ const sigChip = el("span", "chip " + (SIG_CLASS[rep.signal] || ""), SIG_TEXT[rep.signal] || rep.signal);
+ row.appendChild(sigChip);
+ row.appendChild(el("span", "chip grade", "grade " + (rep.quality || "F")));
+ row.appendChild(el("span", "chip",
+ "score " + fmtNum(rep.score, 0) + " · conf " + fmtNum(rep.confidence, 0)));
+ verdict.appendChild(row);
+ verdict.appendChild(el("div", "summary", rep.summary || ""));
+ if (rep.regime_plain) verdict.appendChild(el("div", "meta-line", rep.regime_plain));
+ if (rep.mtf_plain) verdict.appendChild(el("div", "meta-line", rep.mtf_plain));
+ const age = Number(rep.age_sec || 0);
+ const staleTxt = age > 2 * (TF_SECONDS[tf] || 3600)
+ ? " · ⚠ this report is stale" : "";
+ verdict.appendChild(el("div", "bar-time",
+ "Signal bar: " + (rep.bar_ts || "?") + " UTC · updated " + fmtAge(age) + " ago" + staleTxt));
+ root.appendChild(verdict);
+
+ // trade setup card
+ if (rep.trade_setup) {
+ const ts = rep.trade_setup;
+ const card = el("section", "card");
+ card.appendChild(el("h2", null, "Trade setup (" + ts.direction + " · " + (ts.strategy || "") + ")"));
+ const grid = el("div", "setup-grid");
+ const stats = [
+ ["Entry", fmtNum(ts.entry, 2)],
+ ["Stop loss", fmtNum(ts.stop_loss, 2)],
+ ["Target 1", fmtNum(ts.take_profit_1, 2)],
+ ["Target 2", fmtNum(ts.take_profit_2, 2)],
+ ["Risk (per oz)", "$" + fmtNum(ts.risk_usd, 2)],
+ ["Reward : risk", fmtNum(ts.risk_reward, 2)],
+ ];
+ for (const [label, value] of stats) {
+ const s = el("div", "stat");
+ s.appendChild(el("div", "label", label));
+ s.appendChild(el("div", "value", value));
+ grid.appendChild(s);
+ }
+ card.appendChild(grid);
+ root.appendChild(card);
+ }
+
+ // warnings card
+ const warns = rep.warnings_plain || [];
+ if (warns.length) {
+ const card = el("section", "card");
+ card.appendChild(el("h2", null, "Things to know"));
+ const ul = el("ul", "warn-list");
+ warns.forEach((w, i) => {
+ const li = el("li");
+ li.appendChild(el("span", "w-ico", "⚠"));
+ li.appendChild(document.createTextNode(w));
+ if (rep.warnings && rep.warnings[i]) li.title = rep.warnings[i];
+ ul.appendChild(li);
+ });
+ card.appendChild(ul);
+ root.appendChild(card);
+ }
+
+ // indicators table
+ const inds = rep.indicators || {};
+ const keys = IND_ORDER.filter(k => inds[k]).concat(
+ Object.keys(inds).filter(k => !IND_ORDER.includes(k)));
+ if (keys.length) {
+ const card = el("section", "card");
+ card.appendChild(el("h2", null, "What each indicator says"));
+ const tbl = el("table", "ind");
+ const thead = el("thead");
+ const hr = el("tr");
+ for (const h of ["Indicator", "Value", "Reading", "What it means"]) {
+ hr.appendChild(el("th", null, h));
+ }
+ thead.appendChild(hr); tbl.appendChild(thead);
+ const tbody = el("tbody");
+ for (const k of keys) {
+ const ind = inds[k];
+ const tr = el("tr");
+ tr.appendChild(el("td", "ind-name", IND_LABEL[k] || k));
+ tr.appendChild(el("td", "num", fmtIndValue(k, ind.value)));
+ const rd = el("td", "num");
+ const chip = el("span", "chip " + (SIG_CLASS[ind.signal] || ""),
+ SIG_TEXT[ind.signal] || ind.signal);
+ chip.style.fontSize = "12px";
+ rd.appendChild(chip);
+ tr.appendChild(rd);
+ const what = el("td", "ind-plain");
+ what.appendChild(el("div", null, ind.plain || ""));
+ if (ind.description) what.appendChild(el("div", "ind-desc", ind.description));
+ tr.appendChild(what);
+ tbody.appendChild(tr);
+ }
+ tbl.appendChild(tbody);
+ card.appendChild(tbl);
+ root.appendChild(card);
+ }
+}
+
+// ── formatting ───────────────────────────────────────────────────────
+
+function fmtNum(v, dp) {
+ const n = Number(v);
+ return Number.isFinite(n) ? n.toFixed(dp) : "—";
+}
+
+function fmtIndValue(name, v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return "—";
+ switch (name) {
+ case "rsi": case "adx": return n.toFixed(1);
+ case "roc": return n.toFixed(2) + "%";
+ case "bollinger": return (n * 100).toFixed(0) + "%";
+ case "atr": return "$" + n.toFixed(2);
+ case "divergence": return String(Math.round(n));
+ case "ema_trend": return n.toFixed(2);
+ default: return n.toFixed(2);
+ }
+}
+
+function fmtAge(sec) {
+ if (sec < 90) return sec + "s";
+ if (sec < 5400) return Math.round(sec / 60) + "m";
+ return (sec / 3600).toFixed(1) + "h";
+}
+
+// ── boot ─────────────────────────────────────────────────────────────
+
+let priceTimer = null, reportTimer = null;
+
+function startPolling() {
+ stopPolling();
+ pollReports();
+ priceTimer = setInterval(pollPrice, PRICE_POLL_MS);
+ reportTimer = setInterval(pollReports, REPORT_POLL_MS);
+}
+function stopPolling() {
+ if (priceTimer) clearInterval(priceTimer);
+ if (reportTimer) clearInterval(reportTimer);
+ priceTimer = reportTimer = null;
+}
+
+document.addEventListener("visibilitychange", () => {
+ if (document.hidden) stopPolling(); else startPolling();
+});
+
+renderStrip();
+startPolling();
diff --git a/webapp/config.sample.php b/webapp/config.sample.php
new file mode 100644
index 0000000..b6e4486
--- /dev/null
+++ b/webapp/config.sample.php
@@ -0,0 +1,19 @@
+ 'mysql:host=127.0.0.1;dbname=forexbot_web;charset=utf8mb4',
+ 'db_user' => 'forexbot',
+ 'db_pass' => 'CHANGE-ME',
+
+ // Shared secret for ingest.php. Must equal the bot's
+ // WEBAPP_INGEST_TOKEN environment variable. Generate with:
+ // openssl rand -hex 32
+ 'ingest_token' => 'CHANGE-ME-TOO',
+
+ // Symbols the public API will serve (whitelist).
+ 'symbols' => ['XAU/USD'],
+];
diff --git a/webapp/index.html b/webapp/index.html
new file mode 100644
index 0000000..89d1544
--- /dev/null
+++ b/webapp/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ForexBot2 — XAU/USD live signals
+
+
+
+
+
+
+
+
ForexBot2
+
XAU/USD · gold spot
+
+
+
—
+
+
+ connecting…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/ingest.php b/webapp/ingest.php
new file mode 100644
index 0000000..5b4a05e
--- /dev/null
+++ b/webapp/ingest.php
@@ -0,0 +1,120 @@
+ false, 'error' => $msg]);
+ exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ fail(405, 'POST only');
+}
+
+$cfg = require __DIR__ . '/config.php';
+
+$raw = file_get_contents('php://input');
+if ($raw === false || strlen($raw) > 512 * 1024) {
+ fail(400, 'bad body');
+}
+$msg = json_decode($raw, true);
+if (!is_array($msg)) {
+ fail(400, 'invalid JSON');
+}
+
+$token = (string)($msg['token'] ?? '');
+if ($token === '' || !hash_equals((string)$cfg['ingest_token'], $token)) {
+ fail(403, 'bad token');
+}
+
+$symbol = (string)($msg['symbol'] ?? '');
+if ($symbol === '' || !in_array($symbol, $cfg['symbols'], true)) {
+ fail(400, 'unknown symbol');
+}
+
+try {
+ $pdo = new PDO($cfg['db_dsn'], $cfg['db_user'], $cfg['db_pass'], [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ ]);
+} catch (PDOException $e) {
+ fail(500, 'db unavailable');
+}
+
+$type = (string)($msg['type'] ?? '');
+
+try {
+ if ($type === 'tick') {
+ $price = filter_var($msg['price'] ?? null, FILTER_VALIDATE_FLOAT);
+ if ($price === false || $price === null) {
+ fail(400, 'bad price');
+ }
+ $prev = filter_var($msg['prev'] ?? null, FILTER_VALIDATE_FLOAT);
+ $tickAt = (string)($msg['tick_at'] ?? gmdate('Y-m-d H:i:s'));
+ $st = $pdo->prepare(
+ 'INSERT INTO live_price (symbol, price, prev_price, tick_at)
+ VALUES (:s, :p, :pp, :t)
+ ON DUPLICATE KEY UPDATE
+ prev_price = VALUES(prev_price),
+ price = VALUES(price),
+ tick_at = VALUES(tick_at)'
+ );
+ $st->execute([
+ ':s' => $symbol,
+ ':p' => $price,
+ ':pp' => ($prev === false || $prev === null) ? null : $prev,
+ ':t' => substr($tickAt, 0, 19),
+ ]);
+ echo json_encode(['ok' => true]);
+ exit;
+ }
+
+ if ($type === 'report') {
+ $tf = (string)($msg['timeframe'] ?? '');
+ $barTs = substr((string)($msg['bar_ts'] ?? ''), 0, 19);
+ $signal = (string)($msg['signal'] ?? '');
+ $quality = (string)($msg['quality'] ?? 'F');
+ $score = filter_var($msg['score'] ?? null, FILTER_VALIDATE_FLOAT);
+ $conf = filter_var($msg['confidence'] ?? null, FILTER_VALIDATE_FLOAT);
+ $payload = $msg['payload'] ?? null;
+ if ($tf === '' || strlen($tf) > 8 || $barTs === '' || $signal === ''
+ || $score === false || $score === null
+ || $conf === false || $conf === null
+ || !is_array($payload)) {
+ fail(400, 'bad report fields');
+ }
+ $st = $pdo->prepare(
+ 'INSERT INTO signal_reports
+ (symbol, timeframe, bar_ts, signal_name, quality,
+ score, confidence, payload)
+ VALUES (:s, :tf, :bt, :sig, :q, :sc, :c, :pl)
+ ON DUPLICATE KEY UPDATE
+ signal_name = VALUES(signal_name),
+ quality = VALUES(quality),
+ score = VALUES(score),
+ confidence = VALUES(confidence),
+ payload = VALUES(payload)'
+ );
+ $st->execute([
+ ':s' => $symbol,
+ ':tf' => $tf,
+ ':bt' => $barTs,
+ ':sig' => substr($signal, 0, 12),
+ ':q' => substr($quality, 0, 2),
+ ':sc' => $score,
+ ':c' => $conf,
+ ':pl' => json_encode($payload, JSON_UNESCAPED_UNICODE),
+ ]);
+ echo json_encode(['ok' => true]);
+ exit;
+ }
+
+ fail(400, 'unknown type');
+} catch (PDOException $e) {
+ error_log('ingest.php: ' . $e->getMessage());
+ fail(500, 'db error');
+}
diff --git a/webapp/schema.sql b/webapp/schema.sql
new file mode 100644
index 0000000..fce7324
--- /dev/null
+++ b/webapp/schema.sql
@@ -0,0 +1,47 @@
+-- ForexBot2 web dashboard schema (MySQL 8.0+ / MariaDB 10.5+)
+-- Load once: mysql -u root -p < schema.sql
+-- (edit the database/user names to taste; keep them in sync with config.php)
+
+CREATE DATABASE IF NOT EXISTS forexbot_web
+ CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+USE forexbot_web;
+
+-- Latest tick per symbol — a single row upserted on every published tick
+-- (the bot throttles to 1/s). Tick history is deliberately NOT stored:
+-- XAU produces 100k+ ticks/day; add a separate append table with a purge
+-- job if charting is ever needed.
+CREATE TABLE IF NOT EXISTS live_price (
+ symbol VARCHAR(16) NOT NULL PRIMARY KEY,
+ price DECIMAL(12,5) NOT NULL,
+ prev_price DECIMAL(12,5) NULL,
+ tick_at DATETIME NOT NULL, -- UTC, set by the bot
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB;
+
+-- One row per (symbol, timeframe, signal bar). Re-scans of the same bar
+-- (e.g. the bot's level-breach rescan) update the row in place, so the
+-- table is also a clean historical signal log (~400 rows/day for XAU on
+-- M5+M15+H1+H4 — no retention job needed).
+CREATE TABLE IF NOT EXISTS signal_reports (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ symbol VARCHAR(16) NOT NULL,
+ timeframe VARCHAR(8) NOT NULL, -- '5min','15min','1h','4h'
+ bar_ts DATETIME NOT NULL, -- signal bar timestamp (UTC)
+ signal_name VARCHAR(12) NOT NULL, -- STRONG_BUY..STRONG_SELL
+ quality VARCHAR(2) NOT NULL, -- A+, A, B, C, D, F
+ score DECIMAL(6,2) NOT NULL,
+ confidence DECIMAL(5,2) NOT NULL,
+ payload JSON NOT NULL, -- full dashboard payload
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_bar (symbol, timeframe, bar_ts),
+ KEY idx_latest (symbol, timeframe, id)
+) ENGINE=InnoDB;
+
+-- Dedicated app user with least privilege (adjust host/password!):
+-- CREATE USER 'forexbot'@'localhost' IDENTIFIED BY 'CHANGE-ME';
+-- GRANT SELECT, INSERT, UPDATE ON forexbot_web.* TO 'forexbot'@'localhost';
+-- FLUSH PRIVILEGES;
diff --git a/webapp/style.css b/webapp/style.css
new file mode 100644
index 0000000..f063c36
--- /dev/null
+++ b/webapp/style.css
@@ -0,0 +1,153 @@
+/* ForexBot2 dashboard — numbers-only stat tiles.
+ Color roles follow the validated reference palette:
+ status good #0ca30c · warning #fab219 · critical #d03b3b (fixed, never
+ themed); surfaces #fcfcfb / #1a1a19; text wears text tokens only. */
+
+:root {
+ color-scheme: light;
+ --surface-1: #fcfcfb;
+ --surface-2: #f4f3f0;
+ --border: #e3e2dd;
+ --text-primary: #0b0b0b;
+ --text-secondary: #52514e;
+ --text-muted: #8a8985;
+ --good: #0ca30c;
+ --warning: #b97e00; /* darkened warning for light-surface text */
+ --warning-bg: #fff6e0;
+ --critical: #d03b3b;
+ --chip-neutral-bg: #ececea;
+ --good-bg: #e8f6e8;
+ --critical-bg: #fbe9e9;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: dark;
+ --surface-1: #1a1a19;
+ --surface-2: #232322;
+ --border: #3a3a37;
+ --text-primary: #ffffff;
+ --text-secondary: #c3c2b7;
+ --text-muted: #8f8e86;
+ --good: #2fbf2f;
+ --warning: #fab219;
+ --warning-bg: #33290e;
+ --critical: #e66767;
+ --chip-neutral-bg: #32322f;
+ --good-bg: #14320f;
+ --critical-bg: #3b1414;
+ }
+}
+
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--surface-1);
+ color: var(--text-primary);
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+}
+.wrap { max-width: 980px; margin: 0 auto; padding: 20px 16px 40px; }
+
+/* header / hero figure */
+.top {
+ display: flex; justify-content: space-between; align-items: flex-end;
+ gap: 16px; flex-wrap: wrap; margin-bottom: 18px;
+}
+.brand { font-size: 20px; font-weight: 700; }
+.pair { color: var(--text-secondary); }
+.hero { text-align: right; }
+.hero-price {
+ font-size: 52px; font-weight: 650; line-height: 1.05;
+ font-variant-numeric: tabular-nums;
+ transition: color .4s ease;
+}
+.hero-price.up { color: var(--good); }
+.hero-price.down { color: var(--critical); }
+.hero-meta { color: var(--text-muted); font-size: 13px; }
+.dot {
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
+ background: var(--text-muted); margin-right: 6px;
+}
+.dot.live { background: var(--good); }
+.dot.stale { background: var(--warning); }
+
+.banner {
+ background: var(--warning-bg); color: var(--text-primary);
+ border: 1px solid var(--warning); border-radius: 8px;
+ padding: 10px 14px; margin-bottom: 14px;
+}
+.hidden { display: none; }
+
+/* timeframe strip — stat tiles */
+.tf-strip {
+ display: grid; grid-template-columns: repeat(4, 1fr);
+ gap: 10px; margin-bottom: 18px;
+}
+@media (max-width: 640px) { .tf-strip { grid-template-columns: repeat(2, 1fr); } }
+.tf-tile {
+ background: var(--surface-2); border: 1px solid var(--border);
+ border-radius: 10px; padding: 10px 12px; cursor: pointer;
+ text-align: left; font: inherit; color: inherit;
+}
+.tf-tile[aria-selected="true"] { border-color: var(--text-secondary); box-shadow: inset 0 0 0 1px var(--text-secondary); }
+.tf-tile .tf-name { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .04em; }
+.tf-tile .tf-sig { font-weight: 700; margin-top: 2px; }
+.tf-tile .tf-sub { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+
+/* cards */
+.card {
+ background: var(--surface-2); border: 1px solid var(--border);
+ border-radius: 12px; padding: 16px 18px; margin-bottom: 14px;
+}
+.card h2 {
+ margin: 0 0 10px; font-size: 13px; font-weight: 600;
+ color: var(--text-secondary); text-transform: uppercase; letter-spacing: .05em;
+}
+.summary { font-size: 17px; }
+.meta-line { color: var(--text-secondary); margin-top: 8px; }
+.bar-time { color: var(--text-muted); font-size: 13px; margin-top: 8px; }
+
+/* signal + grade chips — status meaning always icon+word, never color alone */
+.chip {
+ display: inline-block; padding: 2px 10px; border-radius: 999px;
+ font-size: 13px; font-weight: 700; background: var(--chip-neutral-bg);
+ color: var(--text-primary); white-space: nowrap;
+}
+.chip.sig-buy { background: var(--good-bg); color: var(--good); }
+.chip.sig-sell { background: var(--critical-bg); color: var(--critical); }
+.chip.grade { font-variant-numeric: tabular-nums; }
+.verdict-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
+
+/* trade setup */
+.setup-grid {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 10px;
+}
+.setup-grid .stat .label { font-size: 12px; color: var(--text-secondary); }
+.setup-grid .stat .value { font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; }
+
+/* warnings */
+.warn-list { list-style: none; margin: 0; padding: 0; }
+.warn-list li { padding: 4px 0; color: var(--text-secondary); }
+.warn-list .w-ico { color: var(--warning); margin-right: 8px; }
+
+/* indicators table */
+table.ind {
+ width: 100%; border-collapse: collapse; font-size: 14px;
+}
+table.ind th {
+ text-align: left; font-size: 12px; color: var(--text-secondary);
+ text-transform: uppercase; letter-spacing: .04em;
+ border-bottom: 1px solid var(--border); padding: 6px 8px;
+}
+table.ind td { border-bottom: 1px solid var(--border); padding: 8px; vertical-align: top; }
+table.ind tr:last-child td { border-bottom: none; }
+td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
+.ind-name { font-weight: 600; white-space: nowrap; }
+.ind-plain { }
+.ind-desc { color: var(--text-muted); font-size: 12.5px; margin-top: 2px; }
+
+.foot { color: var(--text-muted); font-size: 13px; margin-top: 24px; }
+.muted { color: var(--text-muted); }
+.empty {
+ color: var(--text-muted); text-align: center; padding: 40px 0;
+}