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
+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
# ══════════════════════════════════════════════════════════════════════