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:
+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