Web dashboard v2: static publisher + Apache, with calendar & news panels

- dashboard_publisher.py: standalone candle-close daemon that atomically
  writes snapshot.json/news.json/dashboard.html to an Apache-served dir
  (DASHBOARD_OUT_DIR). Fully independent of telegram_bot/oanda_daemon —
  local confirmation map + boundary math, no imports either way, no new
  deps, no open port. Due-TF classification from the RAW wake time.
- static/dashboard.html: self-contained dark page — hero rail, four TF
  cards (score bar, 3x3 indicator grid, MTF, setup, reasons), DeepSeek
  market context, week economic calendar, scored newswire. Clock offset
  from the HTTP Date header (publish-time epoch is stale between closes).
- news_engine.get_week_events(): additive read-only accessor over the
  cached FF week; no signal path calls it.
- deploy/: systemd unit + live-server (LAMP/SELinux) checklist.
- test_all.py: Module 20 (9 tests) + get_week_events test; 296 green.

Claude-Session: https://claude.ai/code/session_01Wc9ZoqKLYBT2QJcpRWKoGm
This commit is contained in:
Harwinder Sekhon
2026-08-03 17:10:29 -04:00
parent 9e1c321f05
commit 4e654b5edb
7 changed files with 1574 additions and 1 deletions
+4 -1
View File
@@ -11,10 +11,12 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
## Commands
```bash
python test_all.py # QA suite: 286 tests, 19 modules, no network (mocked)
python test_all.py # QA suite: 296 tests, 20 modules, no network (mocked)
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump)
python telegram_bot.py # the bot (refuses to boot without credentials)
python oanda_daemon.py # auto-execution daemon on Oanda practice account
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
# (omit --once for the candle-close daemon; deploy/README-dashboard.md)
# Backtests
python backtest/gpu_backtest.py --pair XAUUSD --days 365
@@ -56,6 +58,7 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
- **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown, trail ratchets at bar close exactly once per bar. Accepted divergence: one position total.
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`/`oanda_daemon`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. Deployment checklist: `deploy/README-dashboard.md`.
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
## Rules for making changes (read before editing signal logic)
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""
XAU/USD static dashboard publisher.
Standalone daemon that scans XAU/USD at candle closes and atomically
writes `snapshot.json` + `news.json` (plus a copy of
`static/dashboard.html`) into an Apache-served directory. Apache does
all web serving — this process never opens a port, so a failure here
can only ever mean a stale page.
Independence rules (owner requirement — the telegram bot must be
immune to anything on the web side):
* Runs as its own process; never imported by, and never imports,
`telegram_bot` / `oanda_daemon` (telegram_bot resolves credentials
at import). The confirmation map and boundary math are therefore
duplicated here as local constants.
* The only shared-code dependency beyond the standard signal path is
the additive `NewsEngine.get_week_events` accessor, which no
production signal path calls.
Scheduling: every M15/H1/H4 boundary is also an M5 boundary (all
intervals are multiples of 5, anchored to UTC midnight), so one
sequential loop sleeps to the next M5 boundary (+10 s buffer) and scans
whichever timeframes just closed. Due-classification uses the RAW wake
time — flooring 12:00:10 to the 5-minute grid yields the 12:00 boundary
that just closed. Do NOT subtract the buffer first: 12:00:10 − 15 s
floors to 11:55 and silently skips every H1/H4 scan (v1 bug, 2026-08-02).
Scans run sequentially M5→M15→H1→H4 through one shared data provider so
later timeframes hit the warm boundary cache — that ordering IS the
duplicate-fetch dedup (providers have no in-flight dedup; Oanda has no
throttle). Keep it sequential.
Run: python dashboard_publisher.py [--once] [--out DIR]
Out dir precedence: --out → DASHBOARD_OUT_DIR env → /var/www/html/gold-dashboard
"""
import argparse
import json
import logging
import math
import numbers
import os
import shutil
import sys
import time
from datetime import datetime, timezone
from logging.handlers import TimedRotatingFileHandler
from typing import Dict, List, Optional
from config import PlatformConfig, Timeframe, XAUUSD
from main import report_to_dict
from news_engine import NewsEngine
from pair_signals import produce_signal
from providers import make_data_provider
import providers.base as _providers_base
logger = logging.getLogger("dashboard")
DASHBOARD_TFS = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
# Local copy of the bot's confirmation map (see independence rules above).
CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
TF_INTERVAL_MINUTES = {
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.H1: 60, Timeframe.H4: 240,
}
DEFAULT_OUT_DIR = "/var/www/html/gold-dashboard"
SCAN_BUFFER_SECONDS = 10
SNAPSHOT_FILE = "snapshot.json"
NEWS_FILE = "news.json"
PAGE_FILE = "dashboard.html"
# ══════════════════════════════════════════════════════════════════
# Pure layer (no network, fully covered by test_all.py)
# ══════════════════════════════════════════════════════════════════
def _safe_num(v):
"""Coerce to a JSON-safe float; NaN/±inf become None."""
try:
f = float(v)
except (TypeError, ValueError):
return v
if math.isnan(f) or math.isinf(f):
return None
return f
def _sanitize(obj):
"""Recursively make a payload safe for json.dumps(allow_nan=False):
NaN/inf → None, numpy scalars → native ints/floats."""
if isinstance(obj, bool):
return obj
if isinstance(obj, dict):
return {k: _sanitize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_sanitize(v) for v in obj]
if isinstance(obj, numbers.Integral):
return int(obj)
if isinstance(obj, numbers.Real):
return _safe_num(obj)
return obj
def analysis_to_dict(analysis) -> Optional[dict]:
"""Serialize a TechnicalAnalysis. Iterates analysis.indicators only —
never vars()/asdict(), which would drag dynamic pandas attrs
(_rsi_series, _macd_histogram, …) into the payload. ATR (weight 0)
is surfaced separately as context, not in the directional grid."""
if analysis is None:
return None
indicators = {}
atr_context = None
for name, ind in analysis.indicators.items():
if name.startswith("_"):
continue
entry = {
"signal": ind.signal.name,
"value": _safe_num(ind.value),
"confidence": _safe_num(ind.confidence),
"weight": _safe_num(ind.weight),
"description": ind.description,
}
if name == "atr":
atr_context = entry
elif ind.weight != 0:
indicators[name] = entry
return {
"timeframe": analysis.timeframe,
"timestamp": str(analysis.timestamp),
"composite_signal": analysis.composite_signal.name,
"composite_score": _safe_num(analysis.composite_score),
"confidence": _safe_num(analysis.confidence),
"market_regime": analysis.market_regime,
"volatility_regime": analysis.volatility_regime,
"pattern_signals": list(analysis.pattern_signals),
"key_levels": {k: _safe_num(v) for k, v in analysis.key_levels.items()},
"indicators": indicators,
"atr_context": atr_context,
}
def report_to_webapp_dict(report) -> dict:
"""main.report_to_dict + the per-TF detail the dashboard needs."""
d = report_to_dict(report)
d["primary_signal"] = report.primary_signal.name
d["primary_score"] = _safe_num(report.primary_score)
d["mtf_scores"] = {k: _safe_num(v) for k, v in report.mtf_scores.items()}
d["exhaustion_count"] = report.exhaustion_count
d["primary_analysis"] = analysis_to_dict(report.primary_analysis)
d["confirmation_analyses"] = [
analysis_to_dict(a) for a in report.confirmation_analyses
]
return _sanitize(d)
def event_to_dict(ev) -> dict:
"""EconomicEvent → dict. surprise/minutes_until/is_high_impact are
properties, so asdict() would skip them — added explicitly.
actual/forecast/previous stay strings as published ('228K', '0.3%')."""
return {
"time": ev.time.astimezone(timezone.utc).isoformat(),
"currency": ev.currency,
"impact": ev.impact,
"event": ev.event,
"forecast": ev.forecast,
"previous": ev.previous,
"actual": ev.actual,
"surprise": ev.surprise,
"minutes_until": _safe_num(ev.minutes_until),
"is_high_impact": ev.is_high_impact,
}
def headline_to_dict(h) -> dict:
return {
"headline": h.headline,
"source": h.source,
"timestamp": h.timestamp.astimezone(timezone.utc).isoformat()
if h.timestamp else None,
"currencies": list(h.currencies_mentioned),
"score": _safe_num(h.sentiment_score),
"keywords": list(h.keywords),
"score_by_currency": {
k: _safe_num(v) for k, v in h.sentiment_by_currency.items()
},
}
def build_news_payload(context, week_events, now: datetime) -> dict:
"""news.json: full calendar week + scored headlines + LLM summary."""
headlines = sorted(
context.news_headlines,
key=lambda h: h.timestamp or now, reverse=True,
)
return _sanitize({
"generated_at": now.isoformat(),
"llm_summary": context.llm_summary,
"sentiment": dict(context.news_sentiment),
"high_impact_warning": context.high_impact_warning,
"warning_message": context.warning_message,
"calendar_available": context.calendar_available,
"events": [event_to_dict(e) for e in week_events],
"headlines": [headline_to_dict(h) for h in headlines],
})
def due_timeframes(wake_dt: datetime) -> List[Timeframe]:
"""Timeframes whose candle just closed at the boundary this wake
belongs to. Takes the RAW wake time (boundary + buffer): flooring to
the 5-minute grid yields the boundary that just passed. Never
subtract the buffer first — see module docstring (v1 bug)."""
minutes = wake_dt.hour * 60 + wake_dt.minute
floored = minutes - (minutes % 5)
return [tf for tf in DASHBOARD_TFS if floored % TF_INTERVAL_MINUTES[tf] == 0]
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
# Same math as telegram_bot.py / oanda_daemon.py — kept local so the
# dashboard never imports bot modules (independence rules above).
now = datetime.now(timezone.utc)
total_minutes = now.hour * 60 + now.minute
elapsed_minutes = total_minutes % interval_minutes
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
interval_seconds = interval_minutes * 60
remaining = interval_seconds - elapsed_seconds + buffer
if remaining <= buffer:
remaining += interval_seconds
return remaining
def _empty_tf_entry() -> dict:
return {"status": "warming", "scanned_at": None, "scan_seconds": None,
"error": None, "error_at": None, "report": None}
def build_snapshot_payload(snapshots: Dict[str, dict], now: datetime) -> dict:
"""snapshot.json: one entry per dashboard TF. Timeframes not yet
scanned appear as status="warming" so the page can render a
placeholder instead of a hole. server_time_epoch lets the client
compute its clock offset once so countdowns don't drift."""
timeframes = {}
for tf in DASHBOARD_TFS:
interval = TF_INTERVAL_MINUTES[tf]
minutes = now.hour * 60 + now.minute
secs_into = (minutes % interval) * 60 + now.second + now.microsecond / 1e6
next_close = now.timestamp() + (interval * 60 - secs_into)
entry = _empty_tf_entry()
entry.update(snapshots.get(tf.value) or {})
entry["next_close_epoch"] = next_close
entry["next_scan_epoch"] = next_close + SCAN_BUFFER_SECONDS
timeframes[tf.value] = entry
return _sanitize({
"symbol": XAUUSD,
"server_time_epoch": now.timestamp(),
"market_closed": _providers_base._is_forex_weekend(now),
"timeframes": timeframes,
})
def publish(obj: dict, path: str) -> None:
"""Atomic JSON write: temp file + os.replace, so Apache never serves
a torn file. allow_nan=False — a NaN slipping through would render
the page blank (JSON.parse rejects bare NaN)."""
data = json.dumps(obj, allow_nan=False, separators=(",", ":"))
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(data)
os.replace(tmp, path)
# ══════════════════════════════════════════════════════════════════
# Runtime layer (constructed only from main(); never at import)
# ══════════════════════════════════════════════════════════════════
def _setup_logging(level: str = "INFO") -> None:
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "dashboard.log")
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
file_handler = TimedRotatingFileHandler(
log_file, when="h", interval=3, backupCount=240, utc=True)
file_handler.setFormatter(fmt)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(fmt)
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
handlers=[file_handler, console_handler],
force=True)
class DashboardPublisher:
def __init__(self, out_dir: str):
self.out_dir = out_dir
self.snapshots: Dict[str, dict] = {}
self.news_payload: Optional[dict] = None
self._dp = None
self._news_engine: Optional[NewsEngine] = None
def _provider(self):
if self._dp is None:
self._dp = make_data_provider(PlatformConfig())
return self._dp
def _news(self) -> NewsEngine:
if self._news_engine is None:
self._news_engine = NewsEngine(PlatformConfig().news)
return self._news_engine
def install_static(self) -> None:
"""Copy the repo's dashboard.html into the out dir (atomic), so
git pull + restart also updates the page."""
src = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"static", PAGE_FILE)
if not os.path.exists(src):
logger.warning(f"{src} missing — page not installed")
return
dst = os.path.join(self.out_dir, PAGE_FILE)
shutil.copyfile(src, dst + ".tmp")
os.replace(dst + ".tmp", dst)
def scan_tf(self, tf: Timeframe) -> None:
started = time.time()
try:
report = produce_signal(XAUUSD, tf, self._provider(),
CONFIRMATIONS[tf])
self.snapshots[tf.value] = {
"status": "ok",
"scanned_at": datetime.now(timezone.utc).isoformat(),
"scan_seconds": round(time.time() - started, 2),
"error": None,
"error_at": None,
"report": report_to_webapp_dict(report),
}
logger.info(f"{tf.value}: {report.final_signal.name} "
f"q={report.signal_quality or '-'} "
f"({time.time() - started:.1f}s)")
except Exception as e:
# Keep the last good report; the page shows it with an
# error badge instead of going blank.
logger.exception(f"{tf.value} scan failed")
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
entry["status"] = "error"
entry["error"] = f"{type(e).__name__}: {e}"
entry["error_at"] = datetime.now(timezone.utc).isoformat()
self.snapshots[tf.value] = entry
def refresh_news(self) -> None:
try:
ne = self._news()
context = ne.get_fundamental_context()
week = ne.get_week_events()
self.news_payload = build_news_payload(
context, week, datetime.now(timezone.utc))
except Exception:
logger.exception("news refresh failed — keeping last payload")
def publish_all(self) -> None:
now = datetime.now(timezone.utc)
publish(build_snapshot_payload(self.snapshots, now),
os.path.join(self.out_dir, SNAPSHOT_FILE))
if self.news_payload is not None:
publish(self.news_payload, os.path.join(self.out_dir, NEWS_FILE))
def run_cycle(self, tfs: List[Timeframe]) -> None:
for tf in tfs: # sequential — see module docstring (cache dedup)
self.scan_tf(tf)
self.refresh_news()
self.publish_all()
def run_forever(self) -> None:
self.run_cycle(DASHBOARD_TFS) # populate the page at boot
while True:
delay = seconds_until_boundary(5, buffer=SCAN_BUFFER_SECONDS)
logger.info(f"next M5 boundary in {delay:.0f}s")
time.sleep(delay)
try:
due = due_timeframes(datetime.now(timezone.utc))
logger.info(f"boundary wake — scanning {[t.value for t in due]}")
self.run_cycle(due)
except Exception:
logger.exception("cycle failed")
def main(argv=None) -> None:
ap = argparse.ArgumentParser(description="XAU/USD static dashboard publisher")
ap.add_argument("--once", action="store_true",
help="one scan+publish cycle, then exit")
ap.add_argument("--out",
default=os.environ.get("DASHBOARD_OUT_DIR", DEFAULT_OUT_DIR),
help="output directory served by Apache "
f"(default: $DASHBOARD_OUT_DIR or {DEFAULT_OUT_DIR})")
args = ap.parse_args(argv)
_setup_logging()
os.makedirs(args.out, exist_ok=True)
pub = DashboardPublisher(args.out)
pub.install_static()
if args.once:
pub.run_cycle(DASHBOARD_TFS)
print(f"Published to {args.out}")
return
pub.run_forever()
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
# Deploying the web dashboard (live LAMP server)
The dashboard is a **static publisher**: `dashboard_publisher.py` runs as its
own daemon, scans XAU/USD at candle closes, and atomically writes
`dashboard.html` + `snapshot.json` + `news.json` into a folder Apache already
serves. Apache does all web serving — no new port, no Python web server, and
the telegram bot process is completely unaffected by anything that happens
here (see the module docstring for the independence rules).
All steps below run **on the live server** (`/home/Forex/ForexBot2`).
## 1. Get the code
```bash
cd /home/Forex/ForexBot2
git pull
venv/bin/python test_all.py # optional sanity: suite must be green
```
No new Python dependencies are required.
## 2. Create the web folder
```bash
sudo mkdir -p /var/www/html/gold-dashboard
sudo chown Forex: /var/www/html/gold-dashboard # the user the daemon runs as
```
RHEL/Alma/CentOS with SELinux enforcing — label the folder so httpd may read it
(the daemon writes as a normal user; only httpd is SELinux-confined):
```bash
sudo semanage fcontext -a -t httpd_sys_content_t '/var/www/html/gold-dashboard(/.*)?'
sudo restorecon -R /var/www/html/gold-dashboard
```
## 3. Smoke-test once
```bash
venv/bin/python dashboard_publisher.py --once
ls /var/www/html/gold-dashboard # dashboard.html snapshot.json news.json
```
Then open `http://<server>/gold-dashboard/dashboard.html` — all four
timeframe cards, the calendar, and the newswire should populate.
## 4. Install the daemon
```bash
sudo cp deploy/gold-dashboard.service /etc/systemd/system/
# edit User= if the repo owner is not "Forex"
sudo systemctl daemon-reload
sudo systemctl enable --now gold-dashboard
systemctl status gold-dashboard
tail -f logs/dashboard.log
```
The daemon scans all four timeframes at boot, then wakes at every M5
boundary (+10 s) and rescans whichever timeframes just closed. Logs rotate
every 3 h (240 backups), same scheme as the bot.
## 5. Optional Apache polish
The page cache-busts its JSON fetches (`?t=<epoch>`), so no Apache config is
strictly needed. To be explicit anyway:
```apache
<Directory "/var/www/html/gold-dashboard">
<FilesMatch "\.json$">
Header set Cache-Control "no-cache"
</FilesMatch>
</Directory>
```
The page is deliberately public (owner decision 2026-08-03). To restrict it
later, add standard Apache basic auth (`htpasswd` + `Require valid-user`) on
that directory — no code changes needed.
## Operational notes
- **Updating**: `git pull` + `systemctl restart gold-dashboard`. The daemon
re-copies `static/dashboard.html` into the web folder at startup.
- **If the daemon dies**, systemd restarts it in 30 s; worst case the page
shows stale data with its own "updated N min ago" evidence. Apache and the
telegram bot are unaffected.
- **DeepSeek**: the publisher process has its own daily request cap
(`deepseek.daily_request_cap`, default 500) separate from the bot's —
expect roughly up to ~430 extra calls/day worst case, V4-Flash pricing.
- **Credentials**: uses the same env/.env/config.json resolution as the rest
of the platform (Oanda candles feed; news needs no key; DeepSeek optional).
+23
View File
@@ -0,0 +1,23 @@
# systemd unit for the XAU/USD static dashboard publisher.
# Install (as root, on the live server):
# cp deploy/gold-dashboard.service /etc/systemd/system/
# systemctl daemon-reload && systemctl enable --now gold-dashboard
# See deploy/README-dashboard.md for the full checklist.
[Unit]
Description=XAU/USD dashboard publisher (writes static JSON/HTML for Apache)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Adjust to the account that owns /home/Forex/ForexBot2:
User=Forex
WorkingDirectory=/home/Forex/ForexBot2
Environment=DASHBOARD_OUT_DIR=/var/www/html/gold-dashboard
ExecStart=/home/Forex/ForexBot2/venv/bin/python dashboard_publisher.py
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
+18
View File
@@ -426,6 +426,24 @@ class NewsEngine:
return net, desc
def get_week_events(
self, currencies: Optional[List[str]] = None
) -> List[EconomicEvent]:
"""Full cached calendar week (past and upcoming), filtered to
`currencies` (default: tracked_currencies), sorted by time.
Read-only accessor for display surfaces (the web dashboard) —
rides the same class-level cache as signal scans, so it adds no
network I/O. No production signal path calls this.
"""
if currencies is None:
currencies = self.config.tracked_currencies
events = self._cached_fetch("_calendar_cache", self._fetch_calendar_raw)
return sorted(
(e for e in events if e.currency in currencies),
key=lambda e: e.time,
)
# ── Private Methods ────────────────────────────────────────────
# Maps country names in MyFXBook titles to currency codes
+773
View File
@@ -0,0 +1,773 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Gold Desk — XAU/USD</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23d4a72c'/%3E%3C/svg%3E">
<style>
/* ── Gold Desk tokens ─────────────────────────────────────────── */
:root {
--bg: #0f0e0b; /* page plane, warm near-black */
--card: #17150f; /* card surface */
--card2: #1d1b14; /* nested surface (setup, grid cells) */
--line: rgba(242,239,230,0.08);
--line-strong: rgba(242,239,230,0.16);
--ink: #f2efe6;
--ink2: #b5ae9c;
--muted: #7d7768;
--gold: #d4a72c; /* identity accent — never a data encoding */
--gold-bright: #e6c35c;
--gold-wash: rgba(212,167,44,0.10);
--bull: #0ca30c; /* status good — always paired with arrow/text */
--bull-wash: rgba(12,163,12,0.12);
--bear: #d03b3b; /* status critical */
--bear-text: #e66767; /* lighter step for small red text */
--bear-wash: rgba(208,59,59,0.12);
--amber: #fab219; /* warnings / medium impact */
--amber-wash: rgba(250,178,25,0.10);
--neutral-mid: #383835; /* diverging midpoint */
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, Menlo, monospace;
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--bg); }
body {
font-family: var(--sans);
color: var(--ink);
background: var(--bg);
font-size: 14px;
line-height: 1.45;
padding: 14px clamp(10px, 2.5vw, 28px) 40px;
max-width: 1500px;
margin: 0 auto;
}
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ── Eyebrow section labels ───────────────────────────────────── */
.eyebrow {
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--muted); font-weight: 600; margin: 26px 0 10px;
display: flex; align-items: center; gap: 10px;
}
.eyebrow::after { content: ""; flex: 1; height: 1px; background: var(--line); }
/* ── Hero consensus rail ──────────────────────────────────────── */
#hero {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 14px 18px;
background: linear-gradient(180deg, var(--card) 0%, var(--bg) 140%);
border: 1px solid var(--line); border-radius: 10px;
}
#hero .symbol {
font-family: var(--mono); font-weight: 700; font-size: 15px;
letter-spacing: 0.08em; color: var(--gold);
}
#hero .price {
font-family: var(--mono); font-variant-numeric: tabular-nums;
font-size: 30px; font-weight: 600; color: var(--gold-bright);
line-height: 1;
}
#hero .rail { display: flex; gap: 8px; flex-wrap: wrap; }
.rail-chip {
display: inline-flex; align-items: baseline; gap: 6px;
font-family: var(--mono); font-size: 12.5px;
padding: 5px 9px; border-radius: 6px;
background: var(--card2); border: 1px solid var(--line);
color: var(--ink2);
}
.rail-chip .tfname { color: var(--muted); font-weight: 600; }
#hero .meta { margin-left: auto; text-align: right; font-size: 12px; color: var(--ink2); }
#hero .meta .num { color: var(--ink); }
.live-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--bull); margin-right: 5px;
animation: pulse 2.4s ease-in-out infinite;
}
.live-dot.closed { background: var(--amber); animation: none; }
@keyframes pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; }
* { transition: none !important; }
}
/* ── Banners / toast ──────────────────────────────────────────── */
.banner {
margin-top: 10px; padding: 9px 14px; border-radius: 8px;
font-size: 13px; display: none;
}
#banner-closed { background: var(--amber-wash); border: 1px solid rgba(250,178,25,0.35); color: var(--amber); }
#banner-fail { background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.4); color: var(--bear-text); }
/* ── Market context strip ─────────────────────────────────────── */
#context {
margin-top: 14px; padding: 12px 16px;
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
}
#context .blurb { flex: 1 1 380px; color: var(--ink2); font-size: 13px; }
#context .blurb b { color: var(--ink); font-weight: 600; }
#context .chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; padding: 4px 9px; border-radius: 999px;
border: 1px solid var(--line-strong); color: var(--ink2);
white-space: nowrap;
}
.chip .num { font-size: 12px; }
.chip.pos { border-color: rgba(12,163,12,0.45); background: var(--bull-wash); color: var(--ink); }
.chip.neg { border-color: rgba(208,59,59,0.45); background: var(--bear-wash); color: var(--ink); }
.chip.risk-clear { border-color: rgba(12,163,12,0.45); color: var(--bull); }
.chip.risk-caution { border-color: rgba(250,178,25,0.5); background: var(--amber-wash); color: var(--amber); }
.chip.risk-danger { border-color: rgba(208,59,59,0.55); background: var(--bear-wash); color: var(--bear-text); }
/* ── Timeframe cards ──────────────────────────────────────────── */
#tf-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr));
}
.tf-card {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 14px 16px 12px; display: flex; flex-direction: column; gap: 11px;
}
.tf-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tf-name {
font-family: var(--mono); font-weight: 700; font-size: 15px;
color: var(--gold); letter-spacing: 0.05em; min-width: 42px;
}
.sig-badge {
font-weight: 700; font-size: 12.5px; letter-spacing: 0.04em;
padding: 3px 10px; border-radius: 6px; border: 1px solid var(--line-strong);
color: var(--ink2);
}
.sig-badge.buy { background: var(--bull-wash); border-color: rgba(12,163,12,0.5); color: var(--bull); }
.sig-badge.sell { background: var(--bear-wash); border-color: rgba(208,59,59,0.55); color: var(--bear-text); }
.q-chip {
font-family: var(--mono); font-weight: 700; font-size: 12px;
padding: 3px 8px; border-radius: 6px;
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
}
.q-chip.hi { background: var(--gold-wash); border-color: rgba(212,167,44,0.5); color: var(--gold-bright); }
.regime { font-size: 11.5px; color: var(--muted); }
.tf-head .spacer { margin-left: auto; }
.err-badge {
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 6px;
background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.5);
color: var(--bear-text); cursor: help;
}
/* score bar: zero-centered diverging, neutral midpoint tick */
.scorebar { display: flex; align-items: center; gap: 10px; }
.scorebar .track {
position: relative; flex: 1; height: 8px; border-radius: 4px;
background: var(--card2); overflow: hidden;
}
.scorebar .track::after { /* midpoint tick */
content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
width: 2px; background: var(--neutral-mid); transform: translateX(-1px);
}
.scorebar .fill {
position: absolute; top: 0; bottom: 0; border-radius: 4px;
transition: width 0.5s ease;
}
.scorebar .fill.pos { left: 50%; background: var(--bull); }
.scorebar .fill.neg { right: 50%; background: var(--bear); }
.scorebar .val { width: 52px; text-align: right; font-size: 13px; color: var(--ink); }
.scorebar .lbl { font-size: 11px; color: var(--muted); width: 38px; }
/* 3×3 indicator grid */
.ind-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
}
.ind-cell {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 9px; border-radius: 6px;
background: var(--card2); border: 1px solid transparent;
font-family: var(--mono); font-size: 12.5px; cursor: help;
}
.ind-cell:hover, .ind-cell:focus-visible { border-color: var(--line-strong); }
.ind-cell .k { color: var(--ink2); }
.ind-cell .arrow { font-size: 14px; font-weight: 700; }
.ind-cell.buy .arrow { color: var(--bull); }
.ind-cell.sell .arrow { color: var(--bear-text); }
.ind-cell.neutral .arrow { color: var(--muted); }
.ind-cell.absent { opacity: 0.45; }
.ind-cell.absent .arrow { color: var(--muted); }
.atr-line { font-size: 12px; color: var(--muted); }
.atr-line .num { color: var(--ink2); }
.patterns { display: flex; gap: 6px; flex-wrap: wrap; }
.pat-tag {
font-size: 11px; font-family: var(--mono); padding: 2px 7px;
border-radius: 5px; background: var(--gold-wash); color: var(--gold);
border: 1px solid rgba(212,167,44,0.3);
}
/* MTF row */
.mtf-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
font-size: 12px; color: var(--ink2);
}
.mtf-row .agree { font-weight: 700; }
.mtf-row .agree.yes { color: var(--bull); }
.mtf-row .agree.no { color: var(--muted); }
.mtf-chip {
font-family: var(--mono); font-size: 11.5px; padding: 2px 7px;
border-radius: 5px; background: var(--card2); border: 1px solid var(--line);
}
/* trade setup sub-card */
.setup {
border: 1px solid var(--line-strong); border-radius: 8px;
padding: 10px 12px; background: var(--card2);
}
.setup.buy { border-left: 3px solid var(--bull); }
.setup.sell { border-left: 3px solid var(--bear); }
.setup .s-head {
display: flex; align-items: baseline; gap: 8px; margin-bottom: 7px;
font-weight: 700; font-size: 13px;
}
.setup .s-head .dir.buy { color: var(--bull); }
.setup .s-head .dir.sell { color: var(--bear-text); }
.setup .s-head .strat { font-family: var(--mono); font-size: 11px; color: var(--muted); font-weight: 400; }
.setup table { border-collapse: collapse; width: 100%; }
.setup td { padding: 2px 0; font-size: 12.5px; }
.setup td:first-child { color: var(--muted); padding-right: 12px; white-space: nowrap; }
.setup td.num { color: var(--ink); }
/* reasons & warnings */
.reasons { font-size: 12.5px; display: flex; flex-direction: column; gap: 3px; }
.reasons .r-bull::before { content: "▲ "; color: var(--bull); }
.reasons .r-bear::before { content: "▼ "; color: var(--bear-text); }
.reasons .r-warn { color: var(--amber); }
.reasons .r-warn::before { content: "⚠ "; }
.reasons .r-bull, .reasons .r-bear { color: var(--ink2); }
details.more { font-size: 12px; }
details.more summary { cursor: pointer; color: var(--muted); user-select: none; }
details.more summary:hover { color: var(--ink2); }
details.more .reasons { margin-top: 4px; }
.tf-foot {
display: flex; align-items: center; gap: 10px; margin-top: auto;
padding-top: 8px; border-top: 1px solid var(--line);
font-size: 11.5px; color: var(--muted);
}
.tf-foot .cd { margin-left: auto; }
.tf-foot .cd .num { color: var(--ink2); }
.warming { color: var(--muted); font-size: 13px; padding: 18px 0; text-align: center; }
/* confirmations (collapsible extra) */
details.confirms summary {
cursor: pointer; font-size: 11.5px; color: var(--muted); user-select: none;
}
details.confirms summary:hover { color: var(--ink2); }
.confirm-line {
font-family: var(--mono); font-size: 12px; color: var(--ink2);
padding: 4px 0 0 2px;
}
/* ── News section ─────────────────────────────────────────────── */
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
.panel {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 14px 16px;
}
.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.panel-head h2 {
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink2); font-weight: 600;
}
.panel-head .note { font-size: 11px; color: var(--muted); }
.panel-head label {
margin-left: auto; font-size: 12px; color: var(--ink2);
display: inline-flex; gap: 6px; align-items: center; cursor: pointer;
}
.table-scroll { overflow-x: auto; }
#cal-table { border-collapse: collapse; width: 100%; min-width: 560px; }
#cal-table th {
text-align: left; font-size: 10.5px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); font-weight: 600;
padding: 4px 10px 6px 0; border-bottom: 1px solid var(--line-strong);
}
#cal-table td { padding: 5px 10px 5px 0; font-size: 12.5px; border-bottom: 1px solid var(--line); }
#cal-table tr.day-row td {
padding-top: 10px; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--gold); font-weight: 600; border-bottom: none;
}
#cal-table tr.past { opacity: 0.55; }
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--gold); padding-left: 7px; }
#cal-table .t { white-space: nowrap; color: var(--ink2); }
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
#cal-table .ev { color: var(--ink); }
.imp {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
padding: 1.5px 7px; border-radius: 999px; white-space: nowrap;
}
.imp.high { background: var(--bear-wash); color: var(--bear-text); border: 1px solid rgba(208,59,59,0.45); }
.imp.medium { background: var(--amber-wash); color: var(--amber); border: 1px solid rgba(250,178,25,0.4); }
.imp.low { background: var(--card2); color: var(--muted); border: 1px solid var(--line-strong); }
.surp { font-weight: 700; cursor: help; }
.surp.positive { color: var(--bull); }
.surp.negative { color: var(--bear-text); }
.surp.inline { color: var(--muted); }
/* newswire */
.wire { display: flex; flex-direction: column; }
.wire-item {
display: flex; gap: 10px; align-items: baseline;
padding: 7px 0; border-bottom: 1px solid var(--line);
}
.wire-item:last-child { border-bottom: none; }
.wire-score {
font-family: var(--mono); font-size: 11.5px; font-weight: 700;
padding: 1px 6px; border-radius: 5px; white-space: nowrap;
}
.wire-score.pos { background: var(--bull-wash); color: var(--bull); }
.wire-score.neg { background: var(--bear-wash); color: var(--bear-text); }
.wire-score.zero { background: var(--card2); color: var(--muted); }
.wire-h { font-size: 12.5px; color: var(--ink2); flex: 1; }
.wire-meta { font-size: 11px; color: var(--muted); white-space: nowrap; }
.empty { color: var(--muted); font-size: 12.5px; padding: 8px 0; }
footer {
margin-top: 30px; font-size: 11px; color: var(--muted);
border-top: 1px solid var(--line); padding-top: 12px;
}
:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
</style>
</head>
<body>
<header id="hero">
<div>
<div class="symbol">XAU/USD</div>
<div class="price num" id="hero-price">—</div>
</div>
<div class="rail" id="hero-rail" aria-label="Signal by timeframe"></div>
<div class="meta">
<div id="hero-status"><span class="live-dot"></span>…</div>
<div>M5 close in <span class="num" id="hero-cd">--:--</span></div>
<div id="hero-updated">updated —</div>
</div>
</header>
<div class="banner" id="banner-closed">Market closed (weekend) — showing Friday's last state. Calendar and news stay live.</div>
<div class="banner" id="banner-fail"></div>
<section id="context" hidden>
<div class="blurb" id="ctx-blurb"></div>
<div class="chips" id="ctx-chips"></div>
</section>
<div class="eyebrow">Timeframes</div>
<main id="tf-grid"></main>
<div class="eyebrow">Fundamentals</div>
<div id="news-wrap">
<section class="panel">
<div class="panel-head">
<h2>Economic calendar</h2>
<span class="note">times local · USD events</span>
<label><input type="checkbox" id="hi-only"> high impact only</label>
</div>
<div class="table-scroll">
<table id="cal-table">
<thead><tr><th>Time</th><th>Cur</th><th>Impact</th><th>Event</th>
<th class="num">Fcst</th><th class="num">Prev</th><th class="num">Actual</th></tr></thead>
<tbody id="cal-body"><tr><td colspan="7" class="empty">Loading calendar…</td></tr></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head"><h2>Newswire</h2><span class="note" id="wire-note"></span></div>
<div class="wire" id="wire"></div>
</section>
</div>
<footer>
Signals are generated on closed candles only and refresh at candle boundaries
(M5/M15/H1/H4). Not financial advice — educational and research use only.
</footer>
<script>
"use strict";
/* Mirrors telegram_bot._IND_SHORT / _IND_ARROW — kept as local constants
by design: this page must stay independent of the bot. */
const IND_ORDER = ["ema_trend","rsi","macd","bollinger","adx","roc","keltner","divergence","candlestick"];
const IND_SHORT = {ema_trend:"EMA", rsi:"RSI", macd:"MACD", bollinger:"BB",
adx:"ADX", roc:"ROC", keltner:"KC", divergence:"Div", candlestick:"Cndl"};
const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
const TF_ORDER = ["5min","15min","1h","4h"];
const POLL_MS = 20000;
let snapshot = null, news = null;
let clockOffset = 0; // serverEpoch - localEpoch
let lastPollOk = null; // local Date of last successful poll
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
{minimumFractionDigits: 2, maximumFractionDigits: 2});
const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
const nowServer = () => Date.now() / 1000 + clockOffset;
function fmtCountdown(secs) {
if (secs == null || !isFinite(secs)) return "--:--";
secs = Math.max(0, Math.floor(secs));
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
function fmtAge(iso) {
if (!iso) return "";
const secs = nowServer() - Date.parse(iso) / 1000;
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86400)}d ago`;
}
/* ── hero ─────────────────────────────────────────────────────── */
function renderHero() {
if (!snapshot) return;
let price = null;
for (const tf of ["5min","15min","1h","4h"]) {
const p = snapshot.timeframes[tf]?.report?.current_price;
if (p) { price = p; break; }
}
$("hero-price").textContent = price ? fmt$(price) : "—";
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
const r = snapshot.timeframes[tf]?.report;
const sig = r?.signal || null;
const cls = sigClass(sig);
const arrow = sig ? (ARROW[sig] || "→") : "·";
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
const score = r?.score != null ? r.score.toFixed(0) : "–";
return `<span class="rail-chip" title="${esc(sigLabel(sig))} (score ${esc(score)})">
<span class="tfname">${TF_LABEL[tf]}</span>
<span style="color:${color};font-weight:700">${arrow}</span>
<span>${esc(score)}</span></span>`;
}).join("");
const closed = snapshot.market_closed;
$("hero-status").innerHTML =
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
$("banner-closed").style.display = closed ? "block" : "none";
}
/* ── market context strip ─────────────────────────────────────── */
function renderContext() {
if (!news) return;
$("context").hidden = false;
$("ctx-blurb").innerHTML = news.llm_summary
? `<b>Market context:</b> ${esc(news.llm_summary)}`
: `<b>Market context:</b> <span style="color:var(--muted)">no summary available</span>`;
let chips = "";
for (const cur of ["XAU", "USD"]) {
const v = news.sentiment?.[cur];
if (v == null) continue;
const cls = v > 0.05 ? "pos" : v < -0.05 ? "neg" : "";
chips += `<span class="chip ${cls}" title="Mean headline sentiment for ${cur} (−1…+1)">
${cur} news <span class="num">${v >= 0 ? "+" : ""}${v.toFixed(2)}</span></span>`;
}
let risk = "clear", riskDesc = "No significant events";
if (snapshot) {
for (const tf of TF_ORDER) {
const r = snapshot.timeframes[tf]?.report;
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
if (r?.event_description) riskDesc = r.event_description;
}
}
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
chips += `<span class="chip risk-${risk}" title="${esc(riskDesc)}">event risk: ${risk.toUpperCase()}</span>`;
$("ctx-chips").innerHTML = chips;
}
/* ── timeframe cards ──────────────────────────────────────────── */
function scoreBar(label, score) {
const v = Math.max(-100, Math.min(100, score ?? 0));
const pct = Math.abs(v) / 2; // half-track = 100
const cls = v >= 0 ? "pos" : "neg";
return `<div class="scorebar">
<span class="lbl">${label}</span>
<div class="track" role="img" aria-label="${label} score ${v.toFixed(1)} of ±100">
<div class="fill ${cls}" style="width:${pct}%"></div></div>
<span class="val num">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span></div>`;
}
function indGrid(pa) {
return `<div class="ind-grid">` + IND_ORDER.map(name => {
const ind = pa?.indicators?.[name];
const short = IND_SHORT[name];
if (!ind) {
return `<div class="ind-cell absent" tabindex="0"
title="${esc(short)}: no signal this scan"><span class="k">${short}</span><span class="arrow">—</span></div>`;
}
const cls = sigClass(ind.signal);
const tip = `${short} — ${sigLabel(ind.signal)}\n${ind.description || ""}` +
(ind.value != null ? `\nvalue ${(+ind.value).toFixed(2)}` : "") +
` conf ${Math.round(ind.confidence ?? 0)} w ${ind.weight}`;
return `<div class="ind-cell ${cls}" tabindex="0" title="${esc(tip)}">
<span class="k">${short}</span><span class="arrow">${ARROW[ind.signal] || "→"}</span></div>`;
}).join("") + `</div>`;
}
function reasonsBlock(r) {
const items = [
...(r.warnings || []).map(t => ({cls: "r-warn", t})),
...(r.bullish_reasons || []).map(t => ({cls: "r-bull", t})),
...(r.bearish_reasons || []).map(t => ({cls: "r-bear", t})),
];
if (!items.length) return "";
const line = i => `<div class="${i.cls}">${esc(i.t)}</div>`;
const head = items.slice(0, 3).map(line).join("");
if (items.length <= 3) return `<div class="reasons">${head}</div>`;
return `<div class="reasons">${head}</div>
<details class="more"><summary>${items.length - 3} more…</summary>
<div class="reasons">${items.slice(3).map(line).join("")}</div></details>`;
}
function setupCard(ts) {
if (!ts) return "";
const dir = ts.direction === "BUY" ? "buy" : "sell";
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
return `<div class="setup ${dir}">
<div class="s-head"><span class="dir ${dir}">${ts.direction}</span>
<span class="strat">${esc(ts.strategy || "")}</span>
<span class="strat" style="margin-left:auto">R:R ${(+ts.risk_reward).toFixed(1)}</span></div>
<table>
<tr><td>Entry</td><td class="num">${fmt$(ts.entry)}</td>
<td style="color:var(--muted);padding-left:16px">TP1</td><td class="num">${fmt$(ts.take_profit_1)}</td></tr>
<tr><td>Stop</td><td class="num">${fmt$(ts.stop_loss)}</td>
<td style="color:var(--muted);padding-left:16px">TP2</td><td class="num">${fmt$(ts.take_profit_2)}</td></tr>
<tr><td>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
</table></div>`;
}
function mtfRow(r) {
const entries = Object.entries(r.mtf_signals || {});
if (!entries.length) return "";
const chips = entries.map(([tf, sig]) => {
const score = r.mtf_scores?.[tf];
const cls = sigClass(sig);
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
return `<span class="mtf-chip">${esc(TF_LABEL[tf] || tf)}
<span style="color:${color};font-weight:700">${ARROW[sig] || "→"}</span>
<span class="num">${score != null ? (score >= 0 ? "+" : "") + score.toFixed(0) : ""}</span></span>`;
}).join("");
const agree = r.mtf_agreement;
return `<div class="mtf-row"><span>MTF</span>${chips}
<span class="agree ${agree ? "yes" : "no"}">${agree ? "✓ aligned" : "✗ mixed"}</span></div>`;
}
function confirmsBlock(r) {
const confs = (r.confirmation_analyses || []).filter(Boolean);
if (!confs.length) return "";
const lines = confs.map(a => {
const cls = sigClass(a.composite_signal);
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
return `<div class="confirm-line">${esc(TF_LABEL[a.timeframe] || a.timeframe)}
<span style="color:${color};font-weight:700">${ARROW[a.composite_signal] || "→"}</span>
${esc(sigLabel(a.composite_signal))} ${a.composite_score != null ?
`<span style="color:var(--muted)">(${a.composite_score >= 0 ? "+" : ""}${a.composite_score.toFixed(1)}, ${esc(a.market_regime || "")})</span>` : ""}</div>`;
}).join("");
return `<details class="confirms"><summary>confirmation detail</summary>${lines}</details>`;
}
function renderCards() {
if (!snapshot) return;
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const head = `<div class="tf-head">
<span class="tf-name">${TF_LABEL[tf]}</span>`;
if (!r) {
return `<article class="tf-card">${head}</div>
<div class="warming">${entry.status === "error"
? `<span class="err-badge" title="${esc(entry.error || "")}">SCAN ERROR</span>`
: "warming up — first scan pending…"}</div>
<div class="tf-foot"><span class="cd">closes in
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span></div>
</article>`;
}
const pa = r.primary_analysis;
const qHi = r.quality === "A+" || r.quality === "A";
const regime = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol"]
.filter(Boolean).join(" · ");
return `<article class="tf-card">
${head}
<span class="sig-badge ${sigClass(r.signal)}">${sigLabel(r.signal)}</span>
${r.quality ? `<span class="q-chip${qHi ? " hi" : ""}" title="Signal quality grade">${r.quality}</span>` : ""}
<span class="regime">${esc(regime)}</span>
<span class="spacer"></span>
${entry.status === "error" ? `<span class="err-badge" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
</div>
${scoreBar("score", pa ? pa.composite_score : r.score)}
${pa ? indGrid(pa) : ""}
${pa?.atr_context ? `<div class="atr-line">${esc(pa.atr_context.description ||
"ATR " + (+pa.atr_context.value).toFixed(2))}</div>` : ""}
${pa?.pattern_signals?.length ? `<div class="patterns">${pa.pattern_signals.map(p =>
`<span class="pat-tag">${esc(p)}</span>`).join("")}</div>` : ""}
${mtfRow(r)}
${setupCard(r.trade_setup)}
${reasonsBlock(r)}
${confirmsBlock(r)}
<div class="tf-foot">
<span>scanned ${fmtAge(entry.scanned_at)}${entry.scan_seconds ? ` (${entry.scan_seconds}s)` : ""}</span>
<span class="cd">closes in <span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span>
</div>
</article>`;
}).join("");
}
/* ── calendar ─────────────────────────────────────────────────── */
function renderCalendar() {
const body = $("cal-body");
if (!news) return;
const hiOnly = $("hi-only").checked;
let events = (news.events || []);
if (hiOnly) events = events.filter(e => e.impact === "high");
if (!events.length) {
body.innerHTML = `<tr><td colspan="7" class="empty">${
news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}</td></tr>`;
return;
}
const now = nowServer();
let nextUpMarked = false, lastDay = "", rows = "";
for (const e of events) {
const t = Date.parse(e.time) / 1000;
const d = new Date(t * 1000);
const day = d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"});
if (day !== lastDay) {
rows += `<tr class="day-row"><td colspan="7">${esc(day)}</td></tr>`;
lastDay = day;
}
const past = t < now;
const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
const time = d.toLocaleTimeString(undefined, {hour: "2-digit", minute: "2-digit"});
const surp = e.surprise
? `<span class="surp ${e.surprise}" title="actual ${e.surprise === "positive" ? "above" :
e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${
e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
rows += `<tr class="${past ? "past" : ""}${nextUp ? " next-up" : ""}">
<td class="t num">${esc(time)}</td>
<td class="cur">${esc(e.currency)}</td>
<td><span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med" :
e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
<td class="ev">${esc(e.event)}</td>
<td class="num">${esc(e.forecast ?? "—")}</td>
<td class="num">${esc(e.previous ?? "—")}</td>
<td class="num">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
}
body.innerHTML = rows;
}
/* ── newswire ─────────────────────────────────────────────────── */
function renderWire() {
if (!news) return;
const items = (news.headlines || []).slice(0, 40);
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
if (!items.length) {
$("wire").innerHTML = `<div class="empty">No recent gold/dollar headlines.</div>`;
return;
}
const item = h => {
const s = h.score;
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
return `<div class="wire-item">
<span class="wire-score ${cls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${
h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="wire-h">${esc(h.headline)}</span>
<span class="wire-meta">${esc(host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
$("wire").innerHTML = head + (rest.length
? `<details class="more" style="padding-top:6px"><summary>${rest.length} more headlines…</summary>
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
}
/* ── polling ──────────────────────────────────────────────────── */
async function fetchJSON(name) {
const res = await fetch(`${name}?t=${Date.now()}`, {cache: "no-store"});
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
is stamped at publish time, minutes stale between candle closes. */
const dateHdr = res.headers.get("Date");
const data = await res.json();
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
}
let refetchTimer = null;
async function poll() {
try {
const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
]);
snapshot = snap.data;
news = (nw && nw.data) || news;
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000;
lastPollOk = new Date();
$("banner-fail").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
tick(); // countdowns appear immediately, not after the first 1 s tick
/* one-shot refetch shortly after the next expected publish, so a
fresh scan appears within seconds of the candle close */
const nextScan = Math.min(...TF_ORDER.map(tf =>
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
if (isFinite(nextScan)) {
clearTimeout(refetchTimer);
const delay = (nextScan - nowServer() + 6) * 1000;
if (delay > 0 && delay < POLL_MS) refetchTimer = setTimeout(poll, delay);
}
} catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
const b = $("banner-fail");
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
b.style.display = "block";
}
}
/* 1 s countdown ticker (server-clock anchored) */
function tick() {
if (!snapshot) return;
const now = nowServer();
document.querySelectorAll("[data-close]").forEach(el => {
let t = parseFloat(el.dataset.close);
if (!t) return;
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
let secs = t - now;
if (secs < 0) { // boundary passed while waiting for next publish
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300;
secs = ((secs % interval) + interval) % interval;
}
el.textContent = fmtCountdown(secs);
});
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
if (m5) {
let secs = m5 - now;
if (secs < 0) secs = ((secs % 300) + 300) % 300;
$("hero-cd").textContent = fmtCountdown(secs);
}
if (lastPollOk) $("hero-updated").textContent = `updated ${fmtAge(lastPollOk.toISOString())}`;
}
setInterval(tick, 1000);
$("hi-only").addEventListener("change", renderCalendar);
poll();
setInterval(poll, POLL_MS);
</script>
</body>
</html>
+246
View File
@@ -1102,6 +1102,40 @@ def _():
NewsEngine._calendar_cache = (0.0, [])
@test("NewsEngine.get_week_events filters, sorts, and rides the cache")
def _():
NewsEngine._calendar_cache = (0.0, [])
try:
ne = NewsEngine(NewsConfig())
calls = {"n": 0}
now = datetime.now(timezone.utc)
def fake_fetch():
calls["n"] += 1
return [
EconomicEvent(time=now + timedelta(hours=5), currency="USD",
impact="high", event="NFP"),
EconomicEvent(time=now - timedelta(hours=2), currency="USD",
impact="low", event="Past Event"),
EconomicEvent(time=now + timedelta(hours=1), currency="EUR",
impact="high", event="ECB Rate"),
]
ne._try_fetch_calendar_api = fake_fetch
ne._try_fetch_myfxbook_rss = lambda: []
events = ne.get_week_events() # defaults to tracked ["USD", "XAU"]
assert [e.event for e in events] == ["Past Event", "NFP"], \
f"Expected USD-only sorted by time, got {[e.event for e in events]}"
# Second call (and explicit currency widening) must not refetch.
wide = ne.get_week_events(currencies=["USD", "EUR"])
assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}"
assert [e.event for e in wide] == ["Past Event", "ECB Rate", "NFP"]
finally:
NewsEngine._calendar_cache = (0.0, [])
@test("FundamentalContext defaults")
def _():
ctx = FundamentalContext()
@@ -4965,6 +4999,218 @@ def _():
assert cfg.sentiment_affects_signals is False
# ══════════════════════════════════════════════════════════════════════
# MODULE 20: dashboard_publisher.py (static web dashboard)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 20: dashboard_publisher.py ═══")
import tempfile
import dashboard_publisher as dpub
from engine import TechnicalAnalysis, IndicatorSignal
def make_fake_dashboard_report():
"""SignalReport with every serialization hazard: NaN value, inf key
level, weight-0 ATR, a poisoned '_secret' indicator, a dynamic
pandas attr, and a full TradeSetup."""
analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe="1h",
timestamp="2026-08-03 10:00:00+00:00")
names = ["ema_trend", "rsi", "macd", "bollinger", "adx",
"roc", "keltner", "divergence", "candlestick"]
for i, name in enumerate(names):
analysis.indicators[name] = IndicatorSignal(
name=name, value=float(i),
signal=SignalStrength.BUY if i % 2 == 0 else SignalStrength.SELL,
confidence=60.0, description=f"{name} desc", weight=1.0)
analysis.indicators["rsi"].value = float("nan")
analysis.indicators["atr"] = IndicatorSignal(
name="atr", value=12.5, signal=SignalStrength.NEUTRAL,
confidence=0.0, description="ATR context", weight=0.0)
analysis.indicators["_secret"] = IndicatorSignal(
name="_secret", value=1.0, signal=SignalStrength.NEUTRAL,
confidence=0.0, description="must not serialize", weight=1.0)
analysis._rsi_series = pd.Series([1.0, 2.0]) # dynamic attr, must not leak
analysis.key_levels = {"atr": 12.5, "support": 3300.0,
"broken": float("inf")}
analysis.composite_signal = SignalStrength.BUY
analysis.composite_score = 42.0
analysis.confidence = 61.5
analysis.pattern_signals = ["BULLISH_ENGULFING"]
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-08-03 10:00:00+00:00")
report.final_signal = SignalStrength.BUY
report.final_score = 55.0
report.final_confidence = 70.0
report.signal_quality = "B"
report.primary_signal = SignalStrength.BUY
report.primary_score = 50.0
report.primary_analysis = analysis
report.confirmation_analyses = [analysis]
report.mtf_signals = {"1h": SignalStrength.BUY,
"4h": SignalStrength.NEUTRAL}
report.mtf_scores = {"1h": 50.0, "4h": 10.0}
report.exhaustion_count = 1
report.current_price = 3350.0
report.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=3350.0, stop_loss=3340.0, take_profit_1=3360.0,
take_profit_2=3370.0, risk_reward=2.0, risk_pips=100.0,
reward_pips=200.0, pip_value=0.1, strategy="XAU_ATR",
trail_arm_r=1.0, trail_dist_r=0.85)
return report
@test("dashboard: report_to_webapp_dict round-trips allow_nan=False")
def _():
d = dpub.report_to_webapp_dict(make_fake_dashboard_report())
json.dumps(d, allow_nan=False) # raises if NaN/inf leaked
assert d["signal"] == "BUY" and d["primary_signal"] == "BUY"
assert d["mtf_scores"] == {"1h": 50.0, "4h": 10.0}
assert d["exhaustion_count"] == 1
assert d["trade_setup"]["strategy"] == "XAU_ATR"
pa = d["primary_analysis"]
assert pa["composite_signal"] == "BUY"
assert pa["indicators"]["rsi"]["value"] is None # NaN → None
assert pa["key_levels"]["broken"] is None # inf → None
assert len(d["confirmation_analyses"]) == 1
@test("dashboard: ATR is context not grid; _-keys excluded")
def _():
pa = dpub.report_to_webapp_dict(make_fake_dashboard_report())["primary_analysis"]
assert "atr" not in pa["indicators"]
assert "_secret" not in pa["indicators"]
assert "_rsi_series" not in pa
assert pa["atr_context"]["value"] == 12.5
assert len(pa["indicators"]) == 9
@test("dashboard: missing divergence and weekend report tolerated")
def _():
report = make_fake_dashboard_report()
del report.primary_analysis.indicators["divergence"]
pa = dpub.report_to_webapp_dict(report)["primary_analysis"]
assert len(pa["indicators"]) == 8 and "divergence" not in pa["indicators"]
weekend = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp="now") # primary_analysis=None
d = dpub.report_to_webapp_dict(weekend)
json.dumps(d, allow_nan=False)
assert d["primary_analysis"] is None
assert d["confirmation_analyses"] == []
@test("dashboard: due_timeframes uses RAW wake time")
def _():
def due(h, m):
wake = datetime(2026, 8, 3, h, m, 10, tzinfo=timezone.utc)
return [tf.value for tf in dpub.due_timeframes(wake)]
# The v1 regression: 12:00:10 must classify as the 12:00 boundary
# (all four due), not 11:55.
assert due(12, 0) == ["5min", "15min", "1h", "4h"]
assert due(13, 0) == ["5min", "15min", "1h"]
assert due(12, 15) == ["5min", "15min"]
assert due(12, 5) == ["5min"]
assert due(0, 0) == ["5min", "15min", "1h", "4h"]
@test("dashboard: build_snapshot_payload warming/error/next_close")
def _():
now = datetime(2026, 8, 3, 12, 2, 30, tzinfo=timezone.utc) # Monday
with patch("providers.base._is_forex_weekend", return_value=False):
payload = dpub.build_snapshot_payload({}, now)
assert payload["symbol"] == "XAU/USD"
assert payload["market_closed"] is False
assert set(payload["timeframes"]) == {"5min", "15min", "1h", "4h"}
for entry in payload["timeframes"].values():
assert entry["status"] == "warming" and entry["report"] is None
assert entry["next_close_epoch"] > payload["server_time_epoch"]
assert entry["next_scan_epoch"] == entry["next_close_epoch"] + 10
# M5 closes at 12:05 → 150 s away from 12:02:30.
assert abs(payload["timeframes"]["5min"]["next_close_epoch"]
- (now.timestamp() + 150)) < 1e-6
# An errored TF keeps its stale report + error badge.
stale = {"status": "error", "scanned_at": "earlier",
"error": "ValueError: boom", "error_at": "now",
"report": {"signal": "BUY"}}
with patch("providers.base._is_forex_weekend", return_value=True):
payload = dpub.build_snapshot_payload({"5min": stale}, now)
assert payload["market_closed"] is True
m5 = payload["timeframes"]["5min"]
assert m5["status"] == "error" and m5["report"] == {"signal": "BUY"}
assert m5["error"] == "ValueError: boom"
json.dumps(payload, allow_nan=False)
@test("dashboard: event_to_dict includes computed properties")
def _():
ev = EconomicEvent(
time=datetime(2026, 8, 7, 12, 30, tzinfo=timezone.utc),
currency="USD", impact="high", event="Non-Farm Payrolls",
forecast="180K", previous="147K", actual="228K")
d = dpub.event_to_dict(ev)
assert d["event"] == "Non-Farm Payrolls"
assert d["actual"] == "228K" # strings stay strings
assert d["surprise"] == "positive" # property, added explicitly
assert d["is_high_impact"] is True
assert isinstance(d["minutes_until"], float)
assert d["time"] == "2026-08-07T12:30:00+00:00"
json.dumps(d, allow_nan=False)
@test("dashboard: build_news_payload sorts headlines, survives NaN")
def _():
now = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
old = NewsSentiment(
headline="Old story", source="http://feed/a",
timestamp=now - timedelta(hours=5),
currencies_mentioned=["USD"], sentiment_score=float("nan"),
keywords=[], sentiment_by_currency={})
new = NewsSentiment(
headline="Gold surges", source="http://feed/b",
timestamp=now - timedelta(minutes=10),
currencies_mentioned=["XAU"], sentiment_score=0.6,
keywords=["surge"], sentiment_by_currency={"XAU": 0.6})
ctx = FundamentalContext(
news_headlines=[old, new],
news_sentiment={"XAU": 0.6, "USD": -0.1},
llm_summary="Macro blurb.", calendar_available=True)
events = [EconomicEvent(time=now + timedelta(hours=3),
currency="USD", impact="medium", event="ISM")]
payload = dpub.build_news_payload(ctx, events, now)
json.dumps(payload, allow_nan=False)
assert payload["llm_summary"] == "Macro blurb."
assert [h["headline"] for h in payload["headlines"]] == \
["Gold surges", "Old story"] # newest first
assert payload["headlines"][1]["score"] is None # NaN → None
assert payload["events"][0]["event"] == "ISM"
assert payload["sentiment"] == {"XAU": 0.6, "USD": -0.1}
@test("dashboard: publish is atomic and leaves no temp file")
def _():
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "snapshot.json")
dpub.publish({"a": 1, "ok": True}, path)
with open(path, encoding="utf-8") as fh:
assert json.load(fh) == {"a": 1, "ok": True}
assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left
@test("dashboard: seconds_until_boundary bounds and constants")
def _():
for interval in (5, 15, 60, 240):
s = dpub.seconds_until_boundary(interval, buffer=10)
assert 10 < s <= interval * 60 + 10, f"{interval}min → {s}"
# Confirmation map mirrors the bot's (kept local by design).
assert dpub.CONFIRMATIONS[Timeframe.M5] == [Timeframe.M15, Timeframe.H1]
assert dpub.CONFIRMATIONS[Timeframe.H4] == [Timeframe.H1, Timeframe.D1]
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════