Add web dashboard (Redis + FastAPI + WebSocket), scope XAU penalties

Signal daemon (signal_web.py): candle-boundary scanner mirroring
telegram_bot.py, publishes serialized SignalReport JSON to Redis.
Web API (web/app.py): thin FastAPI reader behind Apache, serves
/api/signals REST + /ws WebSocket from Redis pub/sub.
Frontend (web/static/index.html): 3-pair grid, color-coded signals,
indicator breakdown per timeframe, trade setup display.

Also: XAU-specific penalty scoping in signal_aggregator (softer
confidence penalties for gold, original strict values for forex),
pair-aware session filter routing XAU to its own blocked hours.
This commit is contained in:
harwinder
2026-05-14 23:15:37 -04:00
parent 31ecdf4292
commit bb3c475f22
10 changed files with 1401 additions and 883 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
ForexBot2 — Web API
====================
Thin FastAPI process that reads signal data from Redis and serves it
via REST + WebSocket. Apache reverse-proxies to this.
Endpoints:
GET /api/signals — current snapshot from Redis
WS /ws — real-time updates via Redis pub/sub
No signal computation here — signal_web.py daemon handles that.
Usage:
uvicorn web.app:app --host 127.0.0.1 --port 8000
"""
import asyncio
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Optional, Set
import redis.asyncio as aioredis
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
datefmt="%H:%M:%S",
)
logger = logging.getLogger("web_api")
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
REDIS_KEY = "forexbot2:signals"
REDIS_CHANNEL = "forexbot2:updates"
_redis: Optional[aioredis.Redis] = None
_ws_clients: Set[WebSocket] = set()
_subscriber_task: Optional[asyncio.Task] = None
async def _redis_subscriber() -> None:
pubsub = _redis.pubsub()
await pubsub.subscribe(REDIS_CHANNEL)
try:
async for msg in pubsub.listen():
if msg["type"] != "message":
continue
payload = msg["data"]
dead = []
for ws in _ws_clients:
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
_ws_clients.discard(ws)
except asyncio.CancelledError:
pass
finally:
await pubsub.unsubscribe(REDIS_CHANNEL)
await pubsub.close()
@asynccontextmanager
async def lifespan(application: FastAPI):
global _redis, _subscriber_task
_redis = aioredis.from_url(REDIS_URL, decode_responses=True)
await _redis.ping()
logger.info(f"Redis connected: {REDIS_URL}")
_subscriber_task = asyncio.create_task(_redis_subscriber())
logger.info("Redis subscriber started")
yield
_subscriber_task.cancel()
await asyncio.gather(_subscriber_task, return_exceptions=True)
await _redis.close()
logger.info("Web API stopped")
app = FastAPI(title="ForexBot2 API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
@app.get("/api/signals")
async def api_signals():
raw = await _redis.get(REDIS_KEY)
if raw:
return json.loads(raw)
return {"type": "update", "timestamp": "", "data": {}}
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
_ws_clients.add(ws)
try:
raw = await _redis.get(REDIS_KEY)
if raw:
await ws.send_text(raw)
while True:
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
_ws_clients.discard(ws)
if __name__ == "__main__":
port = int(os.environ.get("WEB_PORT", "8000"))
logger.info(f"Starting ForexBot2 API on http://127.0.0.1:{port}")
uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
+482
View File
@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ForexBot2 — Live Dashboard</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d27;
--border: #2a2d3a;
--text: #e1e4eb;
--text-dim: #8b8fa3;
--buy: #22c55e;
--buy-bg: rgba(34, 197, 94, 0.08);
--sell: #ef4444;
--sell-bg: rgba(239, 68, 68, 0.08);
--neutral: #6b7280;
--neutral-bg: rgba(107, 114, 128, 0.06);
--strong-buy: #16a34a;
--strong-sell: #dc2626;
--accent: #6366f1;
--gold: #eab308;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
header h1 {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--text-dim);
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--sell);
display: inline-block;
}
.status-dot.connected { background: var(--buy); }
#last-update { color: var(--text-dim); }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 20px 24px;
max-width: 1600px;
margin: 0 auto;
}
@media (max-width: 1100px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.pair-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: border-color 0.3s;
}
.pair-card.buy { border-color: var(--buy); background: var(--buy-bg); }
.pair-card.sell { border-color: var(--sell); background: var(--sell-bg); }
.pair-card.neutral { border-color: var(--border); }
.card-header {
padding: 16px 18px 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border);
}
.pair-name {
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
}
.pair-price {
font-size: 14px;
color: var(--text-dim);
margin-top: 2px;
}
.signal-badge {
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.signal-badge.buy, .signal-badge.strong_buy { background: var(--buy); color: #000; }
.signal-badge.sell, .signal-badge.strong_sell { background: var(--sell); color: #fff; }
.signal-badge.neutral { background: var(--neutral); color: #fff; }
.card-meta {
padding: 10px 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.meta-item { display: flex; flex-direction: column; gap: 2px; }
.meta-label { color: var(--text-dim); text-transform: uppercase; font-size: 9px; letter-spacing: 0.5px; }
.meta-value { font-weight: 600; font-size: 13px; }
.quality-A\+, .quality-A { color: var(--buy); }
.quality-B { color: var(--gold); }
.quality-C { color: #f97316; }
.quality-D, .quality-F { color: var(--sell); }
.tf-section {
padding: 8px 18px;
border-bottom: 1px solid var(--border);
}
.tf-section:last-of-type { border-bottom: none; }
.tf-header {
font-size: 11px;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.tf-signal-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
}
.tf-signal-tag.buy { background: rgba(34,197,94,0.2); color: var(--buy); }
.tf-signal-tag.sell { background: rgba(239,68,68,0.2); color: var(--sell); }
.tf-signal-tag.neutral { background: rgba(107,114,128,0.2); color: var(--neutral); }
.indicator-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
font-size: 12px;
}
.ind-name { color: var(--text-dim); text-transform: uppercase; font-size: 10px; min-width: 80px; }
.ind-desc { flex: 1; margin: 0 8px; color: var(--text); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ind-signal { font-weight: 600; font-size: 11px; min-width: 50px; text-align: right; }
.ind-signal.buy, .ind-signal.strong_buy { color: var(--buy); }
.ind-signal.sell, .ind-signal.strong_sell { color: var(--sell); }
.ind-signal.neutral { color: var(--neutral); }
.trade-setup {
padding: 12px 18px;
border-top: 2px solid var(--border);
background: rgba(99, 102, 241, 0.05);
}
.trade-setup h4 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--accent);
margin-bottom: 8px;
}
.trade-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
font-size: 12px;
}
.trade-grid .label { color: var(--text-dim); }
.trade-grid .val { font-weight: 600; text-align: right; }
.trade-grid .val.buy-dir { color: var(--buy); }
.trade-grid .val.sell-dir { color: var(--sell); }
.reasons-section {
padding: 8px 18px 12px;
font-size: 11px;
}
.reasons-section .bull { color: var(--buy); }
.reasons-section .bear { color: var(--sell); }
.reasons-section .warn { color: var(--gold); }
.reason-line { padding: 1px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.loading {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
font-size: 14px;
}
.loading .spinner {
width: 32px; height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.flash { animation: pulse 0.6s ease-in-out; }
.mtf-bar {
display: flex;
gap: 6px;
padding: 6px 18px 10px;
font-size: 10px;
}
.mtf-chip {
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
letter-spacing: 0.3px;
}
.mtf-chip.buy { background: rgba(34,197,94,0.15); color: var(--buy); }
.mtf-chip.sell { background: rgba(239,68,68,0.15); color: var(--sell); }
.mtf-chip.neutral { background: rgba(107,114,128,0.12); color: var(--neutral); }
</style>
</head>
<body>
<header>
<h1>ForexBot2 Live Dashboard</h1>
<div class="status-bar">
<span><span class="status-dot" id="ws-dot"></span> <span id="ws-status">Connecting…</span></span>
<span id="last-update"></span>
</div>
</header>
<div class="grid" id="grid">
<div class="loading" id="loading">
<div class="spinner"></div>
Running initial scan — this takes 30–60 seconds…
</div>
</div>
<script>
const PAIRS = ["EUR/USD", "GBP/USD", "XAU/USD"];
const TF_ORDER = ["15min", "1h", "4h"];
const TF_LABELS = {"5min":"M5","15min":"M15","1h":"H1","4h":"H4","1d":"D1"};
let ws = null;
let reconnectDelay = 1000;
function signalClass(sig) {
if (!sig) return "neutral";
const s = sig.toLowerCase();
if (s === "strong_buy" || s === "buy") return "buy";
if (s === "strong_sell" || s === "sell") return "sell";
return "neutral";
}
function signalLabel(sig) {
if (!sig) return "NEUTRAL";
return sig.replace("_", " ");
}
function fmtPrice(price, pair) {
if (!price) return "—";
if (pair === "XAU/USD") return price.toFixed(2);
return price.toFixed(5);
}
function buildIndicatorRows(indicators) {
if (!indicators || Object.keys(indicators).length === 0) return "";
let html = "";
for (const [name, ind] of Object.entries(indicators)) {
const cls = signalClass(ind.signal);
html += `<div class="indicator-row">
<span class="ind-name">${name.replace(/_/g, " ")}</span>
<span class="ind-desc">${ind.description || ""}</span>
<span class="ind-signal ${cls}">${signalLabel(ind.signal)}</span>
</div>`;
}
return html;
}
function buildCard(pair, tfData) {
if (!tfData || Object.keys(tfData).length === 0) return "";
let best = null;
for (const tf of TF_ORDER) {
if (tfData[tf]) { best = tfData[tf]; break; }
}
if (!best) return "";
const sc = signalClass(best.signal);
const priceStr = fmtPrice(best.price, pair);
let mtfHtml = "";
for (const tf of TF_ORDER) {
const d = tfData[tf];
if (!d) continue;
const cls = signalClass(d.signal);
mtfHtml += `<span class="mtf-chip ${cls}">${TF_LABELS[tf] || tf} ${signalLabel(d.signal)}</span>`;
}
let metaHtml = `<div class="card-meta">
<div class="meta-item"><span class="meta-label">Score</span><span class="meta-value">${best.score >= 0 ? "+" : ""}${best.score}</span></div>
<div class="meta-item"><span class="meta-label">Confidence</span><span class="meta-value">${best.confidence}%</span></div>
<div class="meta-item"><span class="meta-label">Quality</span><span class="meta-value quality-${best.quality}">${best.quality || "—"}</span></div>
</div>
<div class="card-meta">
<div class="meta-item"><span class="meta-label">Regime</span><span class="meta-value">${(best.regime || "—").replace(/_/g, " ")}</span></div>
<div class="meta-item"><span class="meta-label">Volatility</span><span class="meta-value">${best.volatility || "—"}</span></div>
<div class="meta-item"><span class="meta-label">MTF</span><span class="meta-value">${best.mtf_agreement ? "✓ Aligned" : "✗ Diverged"}</span></div>
</div>`;
let tfSections = "";
for (const tf of TF_ORDER) {
const d = tfData[tf];
if (!d) continue;
let indicators = d.indicators || {};
const tfCls = signalClass(d.signal);
tfSections += `<div class="tf-section">
<div class="tf-header">
<span>${TF_LABELS[tf] || tf} — Score: ${d.score >= 0 ? "+" : ""}${d.score} | ${d.confidence}%</span>
<span class="tf-signal-tag ${tfCls}">${signalLabel(d.signal)}</span>
</div>
${buildIndicatorRows(indicators)}`;
if (d.confirmations) {
for (const cf of d.confirmations) {
const cfCls = signalClass(cf.signal);
tfSections += `<div class="tf-header" style="margin-top:6px;">
<span>${TF_LABELS[cf.timeframe] || cf.timeframe} (confirm) — ${cf.score >= 0 ? "+" : ""}${cf.score} | ${cf.confidence}%</span>
<span class="tf-signal-tag ${cfCls}">${signalLabel(cf.signal)}</span>
</div>`;
tfSections += buildIndicatorRows(cf.indicators);
}
}
tfSections += `</div>`;
}
let tradeHtml = "";
if (best.trade_setup) {
const t = best.trade_setup;
const dirCls = t.direction === "BUY" ? "buy-dir" : "sell-dir";
tradeHtml = `<div class="trade-setup">
<h4>Trade Setup</h4>
<div class="trade-grid">
<span class="label">Direction</span><span class="val ${dirCls}">${t.direction}</span>
<span class="label">Entry</span><span class="val">${t.entry}</span>
<span class="label">Stop Loss</span><span class="val">${t.sl}</span>
<span class="label">Take Profit</span><span class="val">${t.tp1}</span>
<span class="label">Risk/Reward</span><span class="val">1:${t.rr}</span>
<span class="label">Risk</span><span class="val">${t.risk_pips} pips</span>
</div>
</div>`;
}
let reasonsHtml = "";
const bulls = best.bullish_reasons || [];
const bears = best.bearish_reasons || [];
const warns = best.warnings || [];
if (bulls.length + bears.length + warns.length > 0) {
reasonsHtml = `<div class="reasons-section">`;
for (const r of bulls.slice(0, 3)) reasonsHtml += `<div class="reason-line bull">+ ${r}</div>`;
for (const r of bears.slice(0, 3)) reasonsHtml += `<div class="reason-line bear">- ${r}</div>`;
for (const w of warns.slice(0, 2)) reasonsHtml += `<div class="reason-line warn">⚠ ${w}</div>`;
reasonsHtml += `</div>`;
}
return `<div class="pair-card ${sc}" id="card-${pair.replace("/","")}">
<div class="card-header">
<div>
<div class="pair-name">${pair}</div>
<div class="pair-price">${priceStr}</div>
</div>
<span class="signal-badge ${sc}">${signalLabel(best.signal)}</span>
</div>
${metaHtml}
<div class="mtf-bar">${mtfHtml}</div>
${tfSections}
${tradeHtml}
${reasonsHtml}
</div>`;
}
function render(data) {
const grid = document.getElementById("grid");
const loading = document.getElementById("loading");
if (loading) loading.remove();
let html = "";
for (const pair of PAIRS) {
const tfData = data[pair] || {};
html += buildCard(pair, tfData);
}
grid.innerHTML = html;
grid.querySelectorAll(".pair-card").forEach(c => {
c.classList.add("flash");
setTimeout(() => c.classList.remove("flash"), 600);
});
}
function connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById("ws-dot").classList.add("connected");
document.getElementById("ws-status").textContent = "Connected";
reconnectDelay = 1000;
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "update" && msg.data) {
render(msg.data);
document.getElementById("last-update").textContent = msg.timestamp || "";
}
} catch (err) {
console.error("Parse error:", err);
}
};
ws.onclose = () => {
document.getElementById("ws-dot").classList.remove("connected");
document.getElementById("ws-status").textContent = "Reconnecting…";
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = () => { ws.close(); };
}
connect();
</script>
</body>
</html>