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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wc9ZoqKLYBT2QJcpRWKoGm
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user