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:
+286
@@ -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();
|
||||
Reference in New Issue
Block a user