Add LAMP web dashboard: bot→MySQL publisher, PHP API, numbers-only page

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNEHBuwwy6sgRM9f9sPx5
This commit is contained in:
Harwinder Sekhon
2026-07-17 11:41:03 -04:00
parent 85b7567677
commit 85a5ba09b8
13 changed files with 1437 additions and 4 deletions
+3
View File
@@ -25,3 +25,6 @@ Thumbs.db
backtest/cache/ backtest/cache/
backtest/*.csv backtest/*.csv
backtest/__pycache__/ backtest/__pycache__/
# webapp server config (secrets live on the server only)
webapp/config.php
+20 -4
View File
@@ -68,6 +68,7 @@ from config import (
from data_provider import ForexDataProvider from data_provider import ForexDataProvider
from pair_signals import PAIR_PRODUCERS from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
import web_publisher
# ── Force UTF-8 on Windows ──────────────────────────────────────────── # ── Force UTF-8 on Windows ────────────────────────────────────────────
if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stdout, "reconfigure"):
@@ -619,6 +620,9 @@ _breach_tasks: Set[asyncio.Task] = set()
def _update_level_cache(reports: List[SignalReport]) -> None: def _update_level_cache(reports: List[SignalReport]) -> None:
now = time.time() now = time.time()
for r in reports: 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: if r.primary_analysis and r.primary_analysis.key_levels:
levels = { levels = {
k: v for k, v in r.primary_analysis.key_levels.items() 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).""" alive through quiet periods (weekends, low-liquidity hours)."""
while True: while True:
await asyncio.sleep(10) 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: async def _ws_monitor(bot) -> None:
@@ -750,6 +759,8 @@ async def _ws_monitor(bot) -> None:
continue continue
breached = _check_level_breach(pair_symbol, price) 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_prices[pair_symbol] = price
_last_price_ts[pair_symbol] = time.time() _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(): if not _feed_active():
break break
logger.info(f"{label} auto-scan: firing") logger.info(f"{label} auto-scan: firing")
await _scan_timeframe( try:
bot, tf, AUTOSCAN_CONFIRMATIONS[tf] 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: except asyncio.CancelledError:
pass pass
finally: finally:
+116
View File
@@ -2493,6 +2493,122 @@ def _():
assert args2.cp is False 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 # SUMMARY
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
+482
View File
@@ -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
+5
View File
@@ -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).
<FilesMatch "^(config.*\.php|schema\.sql|README\.md)$">
Require all denied
</FilesMatch>
+65
View File
@@ -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="<same token as config.php>"
```
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/
```
+79
View File
@@ -0,0 +1,79 @@
<?php
// Public read-only API for the dashboard page.
// GET api.php → latest price + latest report per timeframe
// GET api.php?what=price → latest price only (cheap 2-second poll)
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function fail(int $code, string $msg): never {
http_response_code($code);
echo json_encode(['ok' => 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);
+286
View File
@@ -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();
+19
View File
@@ -0,0 +1,19 @@
<?php
// ForexBot2 dashboard config — copy to config.php on the server and edit.
// cp config.sample.php config.php
// config.php is denied to web clients by .htaccess; keep it out of git.
return [
// PDO MySQL connection
'db_dsn' => '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'],
];
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ForexBot2 — XAU/USD live signals</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<div class="brand">ForexBot2</div>
<div class="pair">XAU/USD · gold spot</div>
</div>
<div class="hero">
<div class="hero-price" id="hero-price">—</div>
<div class="hero-meta">
<span class="dot" id="feed-dot" aria-hidden="true"></span>
<span id="feed-status">connecting…</span>
</div>
</div>
</header>
<div class="banner hidden" id="error-banner" role="alert"></div>
<nav class="tf-strip" id="tf-strip" aria-label="Timeframes"></nav>
<main id="detail" aria-live="polite"></main>
<footer class="foot">
<p>Signals refresh automatically at each candle close. Live price
updates every ~2&nbsp;seconds. All times UTC.</p>
<p class="muted">Educational information, not financial advice.
Trading involves substantial risk of loss.</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
<?php
// Ingest endpoint — the bot POSTs ticks and signal reports here.
// Auth: shared token in the JSON body, compared with hash_equals().
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function fail(int $code, string $msg): never {
http_response_code($code);
echo json_encode(['ok' => 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');
}
+47
View File
@@ -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;
+153
View File
@@ -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;
}