From 85a5ba09b80a7f898ab0048e76aaefd154f615a5 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Fri, 17 Jul 2026 11:41:03 -0400 Subject: [PATCH] =?UTF-8?q?Add=20LAMP=20web=20dashboard:=20bot=E2=86=92MyS?= =?UTF-8?q?QL=20publisher,=20PHP=20API,=20numbers-only=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - webapp/: deployable LAMP app for /var/www/ — ingest.php (token-gated writes), api.php (public read-only), schema.sql (live_price + signal_reports), vanilla-JS dashboard with plain-English indicator readings, .htaccess, deploy README - web_publisher.py: fire-and-forget publisher (bounded queue + daemon thread) with plain-English payload builder; public API contractually never raises; disabled unless WEBAPP_INGEST_URL/TOKEN are set - telegram_bot.py: publish hooks (tick in WS loop, report in _update_level_cache); _candle_loop per-iteration guard so a failing scan can never kill an auto-scan loop; quiet heartbeat teardown - test_all.py: Module 13 — 6 resilience tests (never-raise contract, worker survival, candle-loop survival); suite 189/189 - .gitignore: webapp/config.php (server-side secrets) Verified end-to-end on a scratch MariaDB 10.11 + PHP 8.3 built-in server incl. headless-Firefox render of the live page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KYNEHBuwwy6sgRM9f9sPx5 --- .gitignore | 3 + telegram_bot.py | 24 +- test_all.py | 116 ++++++++++ web_publisher.py | 482 +++++++++++++++++++++++++++++++++++++++ webapp/.htaccess | 5 + webapp/README.md | 65 ++++++ webapp/api.php | 79 +++++++ webapp/app.js | 286 +++++++++++++++++++++++ webapp/config.sample.php | 19 ++ webapp/index.html | 42 ++++ webapp/ingest.php | 120 ++++++++++ webapp/schema.sql | 47 ++++ webapp/style.css | 153 +++++++++++++ 13 files changed, 1437 insertions(+), 4 deletions(-) create mode 100644 web_publisher.py create mode 100644 webapp/.htaccess create mode 100644 webapp/README.md create mode 100644 webapp/api.php create mode 100644 webapp/app.js create mode 100644 webapp/config.sample.php create mode 100644 webapp/index.html create mode 100644 webapp/ingest.php create mode 100644 webapp/schema.sql create mode 100644 webapp/style.css 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… +
+
+
+ + + + + +
+ +
+

Signals refresh automatically at each candle close. Live price + updates every ~2 seconds. All times UTC.

+

Educational information, not financial advice. + Trading involves substantial risk of loss.

+
+ +
+ + + 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; +}