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
+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();