dashboard: redesign signal workspace and refresh flow
This commit is contained in:
+34
-15
@@ -82,8 +82,7 @@ SUMMARY_PHP_FILE = "news_summary.php"
|
|||||||
# Repo files copied into the out dir at daemon start (git pull + restart
|
# Repo files copied into the out dir at daemon start (git pull + restart
|
||||||
# updates them). The PHP endpoint reads news.json in place, so it lives
|
# updates them). The PHP endpoint reads news.json in place, so it lives
|
||||||
# next to the page.
|
# next to the page.
|
||||||
STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", "gauge.js",
|
STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE]
|
||||||
SUMMARY_PHP_FILE]
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
@@ -323,6 +322,7 @@ class DashboardPublisher:
|
|||||||
def __init__(self, out_dir: str):
|
def __init__(self, out_dir: str):
|
||||||
self.out_dir = out_dir
|
self.out_dir = out_dir
|
||||||
self.snapshots: Dict[str, dict] = {}
|
self.snapshots: Dict[str, dict] = {}
|
||||||
|
self._reports = {}
|
||||||
self.news_payload: Optional[dict] = None
|
self.news_payload: Optional[dict] = None
|
||||||
self._dp = None
|
self._dp = None
|
||||||
self._news_engine: Optional[NewsEngine] = None
|
self._news_engine: Optional[NewsEngine] = None
|
||||||
@@ -359,13 +359,7 @@ class DashboardPublisher:
|
|||||||
try:
|
try:
|
||||||
report = produce_signal(XAUUSD, tf, self._provider(),
|
report = produce_signal(XAUUSD, tf, self._provider(),
|
||||||
CONFIRMATIONS[tf])
|
CONFIRMATIONS[tf])
|
||||||
# Advisory AI panel, post-hoc and display-only. Off unless
|
self._reports[tf.value] = report
|
||||||
# config.json agent_panel.dashboard is true. The reuse-TTL
|
|
||||||
# cache + max_panels_per_day budget bound the spend against
|
|
||||||
# this process's own DeepSeek cap (a persisting M5 signal
|
|
||||||
# re-serves its cached verdict instead of re-running every
|
|
||||||
# bar); a fresh panel adds ≤~25 s before this TF publishes.
|
|
||||||
attach_panel(report, surface="dashboard")
|
|
||||||
self.snapshots[tf.value] = {
|
self.snapshots[tf.value] = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -377,16 +371,41 @@ class DashboardPublisher:
|
|||||||
logger.info(f"{tf.value}: {report.final_signal.name} "
|
logger.info(f"{tf.value}: {report.final_signal.name} "
|
||||||
f"q={report.signal_quality or '-'} "
|
f"q={report.signal_quality or '-'} "
|
||||||
f"({time.time() - started:.1f}s)")
|
f"({time.time() - started:.1f}s)")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Keep the last good report; the page shows it with an
|
# Keep the last good report; the page shows it with an
|
||||||
# error badge instead of going blank.
|
# error badge instead of going blank.
|
||||||
logger.exception(f"{tf.value} scan failed")
|
logger.exception(f"{tf.value} scan failed")
|
||||||
|
self._reports.pop(tf.value, None)
|
||||||
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
|
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
|
||||||
entry["status"] = "error"
|
entry["status"] = "error"
|
||||||
entry["error"] = f"{type(e).__name__}: {e}"
|
# The public snapshot carries a stable message; exception
|
||||||
|
# detail remains in dashboard.log for the operator.
|
||||||
|
entry["error"] = "Scan unavailable"
|
||||||
entry["error_at"] = datetime.now(timezone.utc).isoformat()
|
entry["error_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
self.snapshots[tf.value] = entry
|
self.snapshots[tf.value] = entry
|
||||||
|
|
||||||
|
def attach_panels(self, tfs: List[Timeframe]) -> bool:
|
||||||
|
"""Add advisory panels after publishing the technical snapshot.
|
||||||
|
|
||||||
|
A panel is display-only and can take tens of seconds on a cold LLM
|
||||||
|
request. Keeping its report object locally lets the page receive the
|
||||||
|
candle-close signal first, then a second atomic snapshot only when a
|
||||||
|
panel produced a result.
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
for tf in tfs:
|
||||||
|
report = self._reports.get(tf.value)
|
||||||
|
if report is None:
|
||||||
|
continue
|
||||||
|
if not attach_panel(report, surface="dashboard"):
|
||||||
|
continue
|
||||||
|
entry = self.snapshots.get(tf.value)
|
||||||
|
if entry is None or entry.get("status") != "ok":
|
||||||
|
continue
|
||||||
|
entry["report"] = report_to_webapp_dict(report)
|
||||||
|
updated = True
|
||||||
|
return updated
|
||||||
|
|
||||||
def refresh_news(self) -> None:
|
def refresh_news(self) -> None:
|
||||||
try:
|
try:
|
||||||
ne = self._news()
|
ne = self._news()
|
||||||
@@ -413,11 +432,11 @@ class DashboardPublisher:
|
|||||||
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
||||||
for tf in tfs: # sequential — see module docstring (cache dedup)
|
for tf in tfs: # sequential — see module docstring (cache dedup)
|
||||||
self.scan_tf(tf)
|
self.scan_tf(tf)
|
||||||
# Snapshot lands BEFORE the news refresh: when the news TTL has
|
# Technical signals land before any optional slow work. News and the
|
||||||
# expired, refresh_news does FF + 4 RSS fetches (and possibly
|
# advisory panel are display-only and may take 20-30 seconds cold.
|
||||||
# DeepSeek calls) — 20-30 s that must not keep fresh signals off
|
|
||||||
# the page.
|
|
||||||
self.publish_snapshot()
|
self.publish_snapshot()
|
||||||
|
if self.attach_panels(tfs):
|
||||||
|
self.publish_snapshot()
|
||||||
self.refresh_news()
|
self.refresh_news()
|
||||||
self.publish_news()
|
self.publish_news()
|
||||||
|
|
||||||
|
|||||||
+113
-27
@@ -96,17 +96,6 @@ html, body {
|
|||||||
color: var(--xau-signal, var(--bs-secondary)) !important;
|
color: var(--xau-signal, var(--bs-secondary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* timeframe cards */
|
|
||||||
.tf-card {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-card:hover, .tf-card:focus-visible {
|
|
||||||
border-color: var(--bs-primary);
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-name {
|
.tf-name {
|
||||||
font-family: var(--xau-font-mono);
|
font-family: var(--xau-font-mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -115,6 +104,58 @@ html, body {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tf-selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-select {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: var(--bs-border-radius);
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-select:hover {
|
||||||
|
border-color: rgba(var(--bs-primary-rgb), 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-select[aria-pressed="true"] {
|
||||||
|
border-color: var(--xau-gold);
|
||||||
|
background: rgba(245, 179, 1, 0.09);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(245, 179, 1, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-select .tf-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-signal {
|
||||||
|
font-family: var(--xau-font-mono);
|
||||||
|
font-size: clamp(1.55rem, 3vw, 2.35rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-reasons > div + div {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* score bar: zero-centered diverging with midpoint tick */
|
/* score bar: zero-centered diverging with midpoint tick */
|
||||||
.scorebar {
|
.scorebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -122,24 +163,17 @@ html, body {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scorebar .track {
|
.scorebar .gauge-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 9px;
|
min-width: 60px;
|
||||||
|
height: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: var(--bs-secondary-bg);
|
background: var(--bs-secondary-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chart.js gauge canvas host (see gauge.js) — thin bar in the row */
|
.scorebar .gauge-container::after {
|
||||||
.scorebar .gauge-container {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 60px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .track::after {
|
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -155,7 +189,8 @@ html, body {
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: width 0.5s ease;
|
width: var(--score-width);
|
||||||
|
transition: width 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scorebar .fill.pos {
|
.scorebar .fill.pos {
|
||||||
@@ -183,13 +218,24 @@ html, body {
|
|||||||
|
|
||||||
/* rail chips in hero */
|
/* rail chips in hero */
|
||||||
.rail-chip {
|
.rail-chip {
|
||||||
cursor: pointer;
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: var(--bs-border-radius);
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
font-family: var(--xau-font-mono);
|
font-family: var(--xau-font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-chip:hover {
|
.rail-chip:hover, .rail-chip[aria-current="true"] {
|
||||||
border-color: var(--bs-primary) !important;
|
border-color: var(--xau-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-chip:focus-visible, .tf-select:focus-visible, .wire-item:focus-visible,
|
||||||
|
.cal-event:focus-visible {
|
||||||
|
outline: 2px solid var(--xau-gold);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* detail dialog sections */
|
/* detail dialog sections */
|
||||||
@@ -211,6 +257,10 @@ html, body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.d-table .ind-k {
|
.d-table .ind-k {
|
||||||
font-family: var(--xau-font-mono);
|
font-family: var(--xau-font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -234,14 +284,36 @@ html, body {
|
|||||||
|
|
||||||
/* news */
|
/* news */
|
||||||
.wire-item {
|
.wire-item {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
border-radius: var(--bs-border-radius);
|
border-radius: var(--bs-border-radius);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wire-item:hover {
|
.wire-item:hover {
|
||||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cal-event {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.wire-score {
|
.wire-score {
|
||||||
font-family: var(--xau-font-mono);
|
font-family: var(--xau-font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -263,3 +335,17 @@ html, body {
|
|||||||
.scorebar .fill { transition: none; }
|
.scorebar .fill { transition: none; }
|
||||||
* { transition: none !important; }
|
* { transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.tf-selector {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card .card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-chip {
|
||||||
|
flex: 1 1 calc(50% - 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+66
-61
@@ -11,7 +11,6 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||||
<link rel="stylesheet" href="dashboard.css">
|
<link rel="stylesheet" href="dashboard.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -20,12 +19,12 @@
|
|||||||
<header class="card mb-3" id="hero">
|
<header class="card mb-3" id="hero">
|
||||||
<div class="card-body d-flex align-items-center flex-wrap gap-3">
|
<div class="card-body d-flex align-items-center flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="xau-symbol"><i class="bi bi-coin me-1"></i>XAU/USD</div>
|
<h1 class="xau-symbol mb-1"><i class="bi bi-coin me-1"></i>XAU/USD</h1>
|
||||||
<div class="display-5 fw-semibold xau-price" id="hero-price">—</div>
|
<div class="display-5 fw-semibold xau-price" id="hero-price">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2" id="hero-rail" aria-label="Signal by timeframe"></div>
|
<div class="d-flex flex-wrap gap-2" id="hero-rail" aria-label="Select timeframe"></div>
|
||||||
<div class="ms-auto text-end">
|
<div class="ms-auto text-end">
|
||||||
<div id="hero-status"><span class="live-dot"></span>…</div>
|
<div id="hero-status" role="status"><span class="live-dot"></span>…</div>
|
||||||
<div class="small text-secondary"><i class="bi bi-hourglass-split me-1"></i>M5 close in <span class="num" id="hero-cd">--:--</span></div>
|
<div class="small text-secondary"><i class="bi bi-hourglass-split me-1"></i>M5 close in <span class="num" id="hero-cd">--:--</span></div>
|
||||||
<div class="small text-secondary" id="hero-updated">updated —</div>
|
<div class="small text-secondary" id="hero-updated">updated —</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,68 +35,75 @@
|
|||||||
<i class="bi bi-moon-fill me-2"></i>Market closed (weekend) — showing Friday's last state. Calendar and news stay live.
|
<i class="bi bi-moon-fill me-2"></i>Market closed (weekend) — showing Friday's last state. Calendar and news stay live.
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-danger d-none" id="banner-fail" role="alert"></div>
|
<div class="alert alert-danger d-none" id="banner-fail" role="alert"></div>
|
||||||
|
<div class="visually-hidden" id="dashboard-live" aria-live="polite"></div>
|
||||||
|
|
||||||
<section class="card mb-4 d-none" id="context">
|
<main>
|
||||||
<div class="card-body d-flex flex-wrap gap-3 align-items-start">
|
<section class="card mb-4 d-none" id="context" aria-label="Market context">
|
||||||
<div class="flex-grow-1" id="ctx-blurb" style="min-width:300px"></div>
|
<div class="card-body d-flex flex-wrap gap-3 align-items-start">
|
||||||
<div class="d-flex flex-wrap gap-2" id="ctx-chips"></div>
|
<div class="flex-grow-1" id="ctx-blurb"><strong>Market context</strong></div>
|
||||||
</div>
|
<div class="d-flex flex-wrap gap-2" id="ctx-chips"></div>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h6 class="text-uppercase text-secondary small fw-bold mb-3 d-flex align-items-center gap-2">
|
<section class="mb-4" aria-labelledby="workspace-heading">
|
||||||
<i class="bi bi-clock-history text-primary"></i>
|
<h2 class="text-uppercase text-secondary small fw-bold mb-3 d-flex align-items-center gap-2" id="workspace-heading">
|
||||||
Timeframes
|
<i class="bi bi-crosshair text-primary"></i>
|
||||||
<span class="fw-normal text-body-secondary" style="text-transform:none">— select one for full detail</span>
|
Signal workspace
|
||||||
</h6>
|
<span class="fw-normal text-body-secondary" style="text-transform:none">— select a timeframe</span>
|
||||||
<div class="row g-3" id="tf-grid"></div>
|
</h2>
|
||||||
|
<div id="tf-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h6 class="text-uppercase text-secondary small fw-bold mb-3 mt-4 d-flex align-items-center gap-2">
|
<section aria-labelledby="fundamentals-heading">
|
||||||
<i class="bi bi-globe2 text-primary"></i>Fundamentals
|
<h2 class="text-uppercase text-secondary small fw-bold mb-3 mt-4 d-flex align-items-center gap-2" id="fundamentals-heading">
|
||||||
</h6>
|
<i class="bi bi-globe2 text-primary"></i>Fundamentals
|
||||||
<div class="row g-3">
|
</h2>
|
||||||
<div class="col-lg-8">
|
<div class="row g-3">
|
||||||
<div class="card h-100">
|
<div class="col-lg-8">
|
||||||
<div class="card-body">
|
<div class="card h-100">
|
||||||
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-0"><i class="bi bi-calendar-event me-2 text-primary"></i>Economic calendar</h5>
|
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
|
||||||
<span class="text-secondary small">times local · USD events</span>
|
<h3 class="h5 card-title mb-0"><i class="bi bi-calendar-event me-2 text-primary"></i>Economic calendar</h3>
|
||||||
<div class="form-check form-switch ms-auto">
|
<span class="text-secondary small" id="calendar-note">times local · USD events</span>
|
||||||
<input class="form-check-input" type="checkbox" id="hi-only">
|
<div class="form-check form-switch ms-auto">
|
||||||
<label class="form-check-label small" for="hi-only">high impact only</label>
|
<input class="form-check-input" type="checkbox" id="hi-only">
|
||||||
|
<label class="form-check-label small" for="hi-only">high impact only</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm" id="cal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Cur</th>
|
||||||
|
<th>Impact</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th class="text-end">Fcst</th>
|
||||||
|
<th class="text-end">Prev</th>
|
||||||
|
<th class="text-end">Actual</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cal-body"><tr><td colspan="7" class="text-secondary text-center py-3">Loading calendar…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
</div>
|
||||||
<table class="table table-sm" id="cal-table">
|
<div class="col-lg-4">
|
||||||
<thead>
|
<div class="card h-100">
|
||||||
<tr>
|
<div class="card-body">
|
||||||
<th>Time</th>
|
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
|
||||||
<th>Cur</th>
|
<h3 class="h5 card-title mb-0"><i class="bi bi-newspaper me-2 text-primary"></i>Newswire</h3>
|
||||||
<th>Impact</th>
|
<span class="text-secondary small" id="wire-note"></span>
|
||||||
<th>Event</th>
|
</div>
|
||||||
<th class="text-end">Fcst</th>
|
<div id="wire-total" class="d-none"></div>
|
||||||
<th class="text-end">Prev</th>
|
<div id="wire"></div>
|
||||||
<th class="text-end">Actual</th>
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="cal-body"><tr><td colspan="7" class="text-secondary text-center py-3">Loading calendar…</td></tr></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div class="col-lg-4">
|
</main>
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
|
|
||||||
<h5 class="card-title mb-0"><i class="bi bi-newspaper me-2 text-primary"></i>Newswire</h5>
|
|
||||||
<span class="text-secondary small" id="wire-note"></span>
|
|
||||||
</div>
|
|
||||||
<div id="wire-total" class="d-none"></div>
|
|
||||||
<div id="wire"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="mt-4 pt-3 border-top text-secondary small">
|
<footer class="mt-4 pt-3 border-top text-secondary small">
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
@@ -107,7 +113,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="tf-detail" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="tf-detail" tabindex="-1" aria-labelledby="detail-title" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -125,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="news-detail" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="news-detail" tabindex="-1" aria-labelledby="nd-title" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -138,7 +144,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
|
||||||
<script src="gauge.js"></script>
|
|
||||||
<script src="dashboard.js"></script>
|
<script src="dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+245
-121
@@ -19,7 +19,10 @@ const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRO
|
|||||||
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
|
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
|
||||||
const TF_ORDER = ["5min", "15min", "1h", "4h"];
|
const TF_ORDER = ["5min", "15min", "1h", "4h"];
|
||||||
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
|
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
|
||||||
const POLL_MS = 5000;
|
const FALLBACK_POLL_MS = 60000;
|
||||||
|
const RETRY_MS = 15000;
|
||||||
|
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
|
||||||
|
const TIMEFRAME_PREF = "goldDeskSelectedTimeframe";
|
||||||
|
|
||||||
const LEVEL_LABEL = {
|
const LEVEL_LABEL = {
|
||||||
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
|
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
|
||||||
@@ -34,6 +37,10 @@ let clockOffset = 0;
|
|||||||
let lastPollOk = null;
|
let lastPollOk = null;
|
||||||
let lastSnapText = null, lastNewsText = null;
|
let lastSnapText = null, lastNewsText = null;
|
||||||
let refetchTimer = null;
|
let refetchTimer = null;
|
||||||
|
let fallbackTimer = null;
|
||||||
|
let pollInFlight = false;
|
||||||
|
let pollQueued = false;
|
||||||
|
let selectedTf = "5min";
|
||||||
|
|
||||||
let detailTf = null;
|
let detailTf = null;
|
||||||
let ndState = null;
|
let ndState = null;
|
||||||
@@ -43,6 +50,8 @@ const aiPending = new Set();
|
|||||||
|
|
||||||
let tfModal = null;
|
let tfModal = null;
|
||||||
let newsModal = null;
|
let newsModal = null;
|
||||||
|
let tfModalOpen = false;
|
||||||
|
let newsModalOpen = false;
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
@@ -57,6 +66,19 @@ const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell"
|
|||||||
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
|
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
|
||||||
const nowServer = () => Date.now() / 1000 + clockOffset;
|
const nowServer = () => Date.now() / 1000 + clockOffset;
|
||||||
|
|
||||||
|
function localGet(key) {
|
||||||
|
try { return window.localStorage.getItem(key); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSet(key, value) {
|
||||||
|
try { window.localStorage.setItem(key, value); } catch { /* storage is optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(message) {
|
||||||
|
const live = $("dashboard-live");
|
||||||
|
if (live) live.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtCountdown(secs) {
|
function fmtCountdown(secs) {
|
||||||
if (secs == null || !isFinite(secs)) return "--:--";
|
if (secs == null || !isFinite(secs)) return "--:--";
|
||||||
secs = Math.max(0, Math.floor(secs));
|
secs = Math.max(0, Math.floor(secs));
|
||||||
@@ -101,13 +123,13 @@ function renderHero() {
|
|||||||
const arrow = sig ? (ARROW[sig] || "→") : "·";
|
const arrow = sig ? (ARROW[sig] || "→") : "·";
|
||||||
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
||||||
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||||
return `<span class="rail-chip badge border ${badgeCls(cls)}" data-tf="${tf}" tabindex="0"
|
return `<button type="button" class="rail-chip" data-select-tf="${tf}"
|
||||||
role="button" aria-haspopup="dialog"
|
aria-current="${tf === selectedTf ? "true" : "false"}"
|
||||||
title="${esc(sigLabel(sig))} (score ${esc(score)}) — click for full ${TF_LABEL[tf]} detail">
|
title="${esc(sigLabel(sig))} (score ${esc(score)}) — select ${TF_LABEL[tf]} workspace">
|
||||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||||
<span class="${textColor} fw-bold">${arrow}</span>
|
<span class="${textColor} fw-bold">${arrow}</span>
|
||||||
<span>${esc(score)}</span>
|
<span>${esc(score)}</span>
|
||||||
</span>`;
|
</button>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
const closed = snapshot.market_closed;
|
const closed = snapshot.market_closed;
|
||||||
@@ -151,9 +173,12 @@ function renderContext() {
|
|||||||
/* ── timeframe cards ──────────────────────────────────────────── */
|
/* ── timeframe cards ──────────────────────────────────────────── */
|
||||||
function scoreBar(label, score) {
|
function scoreBar(label, score) {
|
||||||
const v = Math.max(-100, Math.min(100, score ?? 0));
|
const v = Math.max(-100, Math.min(100, score ?? 0));
|
||||||
|
const width = (Math.abs(v) / 2).toFixed(1);
|
||||||
return `<div class="scorebar">
|
return `<div class="scorebar">
|
||||||
<span class="lbl">${label}</span>
|
<span class="lbl">${label}</span>
|
||||||
<div class="gauge-container" data-label="${label}" data-score="${v}"></div>
|
<div class="gauge-container" aria-hidden="true">
|
||||||
|
<i class="fill ${v >= 0 ? "pos" : "neg"}" style="--score-width:${width}%"></i>
|
||||||
|
</div>
|
||||||
<span class="val num ${v > 0 ? "text-success" : v < 0 ? "text-danger" : ""}">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
|
<span class="val num ${v > 0 ? "text-success" : v < 0 ? "text-danger" : ""}">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -171,61 +196,105 @@ function setupCard(ts) {
|
|||||||
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
|
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
|
||||||
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
|
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-sm table-borderless mb-0">
|
<div class="table-responsive"><table class="table table-sm table-borderless mb-0">
|
||||||
<tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
<tbody><tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
||||||
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP1</td><td class="num text-success">${fmt$(ts.take_profit_1)}</td></tr>
|
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP1</td><td class="num text-success">${fmt$(ts.take_profit_1)}</td></tr>
|
||||||
<tr><td class="text-secondary"><i class="bi bi-shield-x me-1 text-danger"></i>Stop</td><td class="num text-danger">${fmt$(ts.stop_loss)}</td>
|
<tr><td class="text-secondary"><i class="bi bi-shield-x me-1 text-danger"></i>Stop</td><td class="num text-danger">${fmt$(ts.stop_loss)}</td>
|
||||||
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP2</td><td class="num text-success">${fmt$(ts.take_profit_2)}</td></tr>
|
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP2</td><td class="num text-success">${fmt$(ts.take_profit_2)}</td></tr>
|
||||||
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
|
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr></tbody>
|
||||||
</table>
|
</table></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspacePanel(tf) {
|
||||||
|
const entry = snapshot?.timeframes?.[tf] || {};
|
||||||
|
const r = entry.report;
|
||||||
|
if (!r) {
|
||||||
|
return `<article class="card workspace-card border-signal sig-neutral" id="workspace-panel" aria-labelledby="workspace-panel-heading">
|
||||||
|
<div class="card-body py-4 text-center text-secondary">
|
||||||
|
<h3 class="tf-name mb-2" id="workspace-panel-heading">${TF_LABEL[tf]}</h3>
|
||||||
|
${entry.status === "error"
|
||||||
|
? `<span class="badge text-bg-danger">SCAN UNAVAILABLE</span><div class="small mt-2">Showing again when the next scan succeeds.</div>`
|
||||||
|
: "Warming up — no completed scan for this timeframe yet."}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = sigClass(r.signal);
|
||||||
|
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||||
|
const qHi = r.quality === "A+" || r.quality === "A";
|
||||||
|
const pa = r.primary_analysis;
|
||||||
|
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " volatility",
|
||||||
|
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
|
||||||
|
const facts = `<div class="row row-cols-2 row-cols-md-4 g-2 mt-1">
|
||||||
|
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
||||||
|
${tile("Quality", esc(r.quality || "—"), qHi ? "text-primary" : "")}
|
||||||
|
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()), r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
|
||||||
|
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
|
||||||
|
</div>`;
|
||||||
|
const setup = r.trade_setup
|
||||||
|
? setupCard(r.trade_setup)
|
||||||
|
: `<div class="text-secondary small">No setup published. The signal has not cleared its setup gate.</div>`;
|
||||||
|
return `<article class="card workspace-card border-signal sig-${cls}" id="workspace-panel" aria-labelledby="workspace-panel-heading">
|
||||||
|
<div class="card-body p-3 p-lg-4">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7 workspace-meta">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
|
||||||
|
<h3 class="tf-name mb-0" id="workspace-panel-heading">${TF_LABEL[tf]} signal</h3>
|
||||||
|
<span class="badge ${badgeCls(cls)}">${esc(sigLabel(r.signal))}</span>
|
||||||
|
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}">quality ${esc(r.quality)}</span>` : ""}
|
||||||
|
${entry.status === "error" ? `<span class="badge text-bg-danger">STALE</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="workspace-signal ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" data-open-detail="${tf}">
|
||||||
|
Full analysis <i class="bi bi-arrow-up-right ms-1"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
||||||
|
<div class="small text-secondary mt-2">${esc(meta)}</div>
|
||||||
|
${facts}
|
||||||
|
<div class="workspace-reasons mt-3 pt-3 border-top">${listBlock(r) || "<div class=\"small text-secondary\">No supporting reasons recorded on this scan.</div>"}</div>
|
||||||
|
<div class="small text-secondary mt-3">${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} · closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="small text-uppercase text-secondary fw-bold mb-2">Trade plan</div>
|
||||||
|
${setup}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCards() {
|
function renderCards() {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
|
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
|
||||||
|
const selector = TF_ORDER.map(tf => {
|
||||||
const entry = snapshot.timeframes[tf] || {};
|
const entry = snapshot.timeframes[tf] || {};
|
||||||
const r = entry.report;
|
const r = entry.report;
|
||||||
const scls = r ? sigClass(r.signal) : "neutral";
|
const cls = r ? sigClass(r.signal) : "neutral";
|
||||||
const open = `<article class="col-12 col-md-6 col-xl-3"><div class="card tf-card h-100 sig-${scls} border-signal" data-tf="${tf}" tabindex="0" role="button" aria-haspopup="dialog" aria-label="${TF_LABEL[tf]} timeframe — open full detail"><div class="card-body d-flex flex-column gap-2">`;
|
const arrow = r ? (ARROW[r.signal] || "→") : "·";
|
||||||
const foot = `<div class="mt-auto pt-2 border-top d-flex justify-content-between small text-secondary">
|
const score = r?.score != null ? signed(r.score, 0) : "—";
|
||||||
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
|
const signal = r ? sigLabel(r.signal) : entry.status === "error" ? "UNAVAILABLE" : "WARMING";
|
||||||
<span>closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></span>
|
return `<button type="button" class="tf-select" data-select-tf="${tf}" aria-pressed="${tf === selectedTf}">
|
||||||
</div>`;
|
<span class="d-flex align-items-center justify-content-between gap-2">
|
||||||
if (!r) {
|
|
||||||
return `${open}
|
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
||||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
|
||||||
<span class="ms-auto small text-primary fw-semibold">details ›</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-center text-secondary py-3">${entry.status === "error"
|
|
||||||
? `<span class="badge text-bg-danger" title="${esc(entry.error || "")}">SCAN ERROR</span>`
|
|
||||||
: "warming up…"}</div>
|
|
||||||
</div></div></article>`;
|
|
||||||
}
|
|
||||||
const pa = r.primary_analysis;
|
|
||||||
const qHi = r.quality === "A+" || r.quality === "A";
|
|
||||||
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol",
|
|
||||||
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
|
|
||||||
return `${open}
|
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
||||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||||
<span class="badge ${badgeCls(scls)}">${sigLabel(r.signal)}</span>
|
<span class="${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"} fw-bold">${arrow}</span>
|
||||||
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}" title="Signal quality grade">${r.quality}</span>` : ""}
|
</span>
|
||||||
<span class="ms-auto small text-primary fw-semibold">details ›</span>
|
<span class="small ${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"}">${esc(signal)}</span>
|
||||||
</div>
|
<span class="small num float-end">${esc(score)}</span>
|
||||||
${entry.status === "error" ? `<span class="badge text-bg-danger" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
|
</button>`;
|
||||||
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
|
||||||
<div class="small text-secondary">${esc(meta)}</div>
|
|
||||||
${foot}
|
|
||||||
</div></div></article>`;
|
|
||||||
}).join("");
|
}).join("");
|
||||||
|
$("tf-grid").innerHTML = `<div class="tf-selector">${selector}</div>${workspacePanel(selectedTf)}`;
|
||||||
|
tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── timeframe detail dialog ──────────────────────────────────── */
|
/* ── timeframe detail dialog ──────────────────────────────────── */
|
||||||
const tile = (k, v, cls = "") =>
|
const tile = (k, v, cls = "") =>
|
||||||
`<div class="col"><div class="card bg-body-tertiary h-100"><div class="card-body p-2">
|
`<div class="col"><div class="card d-stat bg-body-tertiary h-100"><div class="card-body p-2">
|
||||||
<span class="k">${esc(k)}</span>
|
<span class="k">${esc(k)}</span>
|
||||||
<span class="v ${cls}">${v}</span>
|
<span class="v ${cls}">${v}</span>
|
||||||
</div></div></div>`;
|
</div></div></div>`;
|
||||||
@@ -266,9 +335,9 @@ function indTable(src) {
|
|||||||
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
|
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
|
||||||
<td class="text-secondary small">${esc(i.description || "")}</td></tr>`;
|
<td class="text-secondary small">${esc(i.description || "")}</td></tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
return `<table class="table table-sm d-table"><thead><tr>
|
return `<div class="detail-table"><table class="table table-sm d-table"><thead><tr>
|
||||||
<th>Indicator</th><th>Signal</th><th class="text-end">Value</th><th class="text-end">Conf</th>
|
<th>Indicator</th><th>Signal</th><th class="text-end">Value</th><th class="text-end">Conf</th>
|
||||||
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table>`;
|
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ladder(levels, price) {
|
function ladder(levels, price) {
|
||||||
@@ -292,8 +361,8 @@ function ladder(levels, price) {
|
|||||||
<td class="num text-end ${cls}">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
|
<td class="num text-end ${cls}">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
|
||||||
<td class="small ${cls}">${d == null ? "" : `<i class="bi ${icon}"></i>${d > 0 ? "resistance" : "support"}`}</td></tr>`;
|
<td class="small ${cls}">${d == null ? "" : `<i class="bi ${icon}"></i>${d > 0 ? "resistance" : "support"}`}</td></tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
return `<table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
|
return `<div class="detail-table"><table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
|
||||||
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table>`;
|
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function analysisBlock(a, price) {
|
function analysisBlock(a, price) {
|
||||||
@@ -408,10 +477,10 @@ function detailHTML(tf) {
|
|||||||
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
|
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
|
||||||
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
||||||
${esc(r.panel_rationale || "")}</div>
|
${esc(r.panel_rationale || "")}</div>
|
||||||
${takes ? `<table class="table table-sm d-table"><tbody>${takes}</tbody></table>` : ""}`);
|
${takes ? `<div class="detail-table"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
out += sec("Scan detail", `<table class="table table-sm d-table"><tbody>
|
out += sec("Scan detail", `<div class="detail-table"><table class="table table-sm d-table"><tbody>
|
||||||
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
||||||
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
|
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
|
||||||
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
|
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
|
||||||
@@ -419,12 +488,12 @@ function detailHTML(tf) {
|
|||||||
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
|
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
|
||||||
? ` — <span class="text-danger">${esc(entry.error)}</span>
|
? ` — <span class="text-danger">${esc(entry.error)}</span>
|
||||||
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
||||||
</tbody></table>`);
|
</tbody></table></div>`);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail() {
|
function renderDetail() {
|
||||||
if (!detailTf || !tfModal._isShown) return;
|
if (!detailTf || !tfModalOpen) return;
|
||||||
const entry = snapshot?.timeframes?.[detailTf] || {};
|
const entry = snapshot?.timeframes?.[detailTf] || {};
|
||||||
const r = entry.report;
|
const r = entry.report;
|
||||||
const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
|
const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
|
||||||
@@ -454,27 +523,13 @@ function openDetail(tf) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$("detail-close")?.addEventListener("click", () => tfModal.hide());
|
$("detail-close")?.addEventListener("click", () => tfModal.hide());
|
||||||
document.getElementById("tf-detail")?.addEventListener("hidden.bs.modal", () => { detailTf = null; });
|
|
||||||
|
|
||||||
function wireOpeners(host) {
|
|
||||||
host.addEventListener("click", e => {
|
|
||||||
if (e.target.closest("details, summary, a, input, button")) return;
|
|
||||||
const el = e.target.closest("[data-tf]");
|
|
||||||
if (el) openDetail(el.dataset.tf);
|
|
||||||
});
|
|
||||||
host.addEventListener("keydown", e => {
|
|
||||||
if (e.key !== "Enter" && e.key !== " ") return;
|
|
||||||
const el = e.target.closest?.("[data-tf]");
|
|
||||||
if (!el || e.target !== el) return;
|
|
||||||
e.preventDefault();
|
|
||||||
openDetail(el.dataset.tf);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── calendar ─────────────────────────────────────────────────── */
|
/* ── calendar ─────────────────────────────────────────────────── */
|
||||||
function renderCalendar() {
|
function renderCalendar() {
|
||||||
const body = $("cal-body");
|
const body = $("cal-body");
|
||||||
if (!news) return;
|
if (!news) return;
|
||||||
|
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
|
||||||
|
$("calendar-note").textContent = `times local (${zone}) · USD events${news.generated_at ? " · updated " + fmtAge(news.generated_at) : ""}`;
|
||||||
const hiOnly = $("hi-only").checked;
|
const hiOnly = $("hi-only").checked;
|
||||||
let events = (news.events || []);
|
let events = (news.events || []);
|
||||||
if (hiOnly) events = events.filter(e => e.impact === "high");
|
if (hiOnly) events = events.filter(e => e.impact === "high");
|
||||||
@@ -498,12 +553,11 @@ function renderCalendar() {
|
|||||||
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||||
const surp = e.surprise
|
const surp = e.surprise
|
||||||
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" title="actual ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
|
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" 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 ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}"
|
rows += `<tr class="${past ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}">
|
||||||
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
|
|
||||||
<td class="num text-nowrap">${esc(time)}</td>
|
<td class="num text-nowrap">${esc(time)}</td>
|
||||||
<td class="text-secondary">${esc(e.currency)}</td>
|
<td class="text-secondary">${esc(e.currency)}</td>
|
||||||
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
||||||
<td>${esc(e.event)}</td>
|
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
|
||||||
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
|
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
|
||||||
<td class="num text-end">${esc(e.previous ?? "—")}</td>
|
<td class="num text-end">${esc(e.previous ?? "—")}</td>
|
||||||
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
||||||
@@ -559,12 +613,13 @@ function renderWire() {
|
|||||||
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
||||||
let host = "";
|
let host = "";
|
||||||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
||||||
const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
|
const content = `<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
||||||
return `<div class="wire-item d-flex gap-2 align-items-baseline py-1 px-1"${open}>
|
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
|
||||||
<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>`;
|
||||||
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
|
if (!h.id) return `<div class="wire-item gap-2 align-items-baseline py-1 px-1">${content}</div>`;
|
||||||
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>
|
return `<button type="button" class="wire-item gap-2 align-items-baseline py-1 px-1" data-news-id="${h.id}" aria-haspopup="dialog">
|
||||||
</div>`;
|
${content}
|
||||||
|
</button>`;
|
||||||
};
|
};
|
||||||
const head = items.slice(0, 12).map(item).join("");
|
const head = items.slice(0, 12).map(item).join("");
|
||||||
const rest = items.slice(12);
|
const rest = items.slice(12);
|
||||||
@@ -673,7 +728,7 @@ function calDlgHTML() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNewsDlg() {
|
function renderNewsDlg() {
|
||||||
if (!ndState || !newsModal._isShown) return;
|
if (!ndState || !newsModalOpen) return;
|
||||||
const scroll = $("nd-body").scrollTop;
|
const scroll = $("nd-body").scrollTop;
|
||||||
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
|
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
|
||||||
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
|
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
|
||||||
@@ -704,21 +759,6 @@ function openCalDetail(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$("nd-close")?.addEventListener("click", () => newsModal.hide());
|
$("nd-close")?.addEventListener("click", () => newsModal.hide());
|
||||||
document.getElementById("news-detail")?.addEventListener("hidden.bs.modal", () => { ndState = null; });
|
|
||||||
|
|
||||||
function wireNewsOpeners(host, attr, open) {
|
|
||||||
host.addEventListener("click", e => {
|
|
||||||
const el = e.target.closest(`[${attr}]`);
|
|
||||||
if (el) open(el.getAttribute(attr));
|
|
||||||
});
|
|
||||||
host.addEventListener("keydown", e => {
|
|
||||||
if (e.key !== "Enter" && e.key !== " ") return;
|
|
||||||
const el = e.target.closest?.(`[${attr}]`);
|
|
||||||
if (!el || e.target !== el) return;
|
|
||||||
e.preventDefault();
|
|
||||||
open(el.getAttribute(attr));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── polling ──────────────────────────────────────────────────── */
|
/* ── polling ──────────────────────────────────────────────────── */
|
||||||
async function fetchJSON(name) {
|
async function fetchJSON(name) {
|
||||||
@@ -729,15 +769,71 @@ async function fetchJSON(name) {
|
|||||||
return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
|
return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tfFingerprint(entry) {
|
||||||
|
if (!entry) return "";
|
||||||
|
return JSON.stringify({
|
||||||
|
status: entry.status,
|
||||||
|
scanned_at: entry.scanned_at,
|
||||||
|
error: entry.error,
|
||||||
|
error_at: entry.error_at,
|
||||||
|
report: entry.report
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh(delay) {
|
||||||
|
clearTimeout(refetchTimer);
|
||||||
|
refetchTimer = setTimeout(() => {
|
||||||
|
refetchTimer = null;
|
||||||
|
poll();
|
||||||
|
}, Math.max(0, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFallbackRefresh() {
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
fallbackTimer = setTimeout(() => {
|
||||||
|
fallbackTimer = null;
|
||||||
|
poll();
|
||||||
|
scheduleFallbackRefresh();
|
||||||
|
}, FALLBACK_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBoundaryRefresh() {
|
||||||
|
if (!snapshot) return;
|
||||||
|
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||||
|
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||||
|
if (!isFinite(nextScan)) return;
|
||||||
|
const delay = (nextScan - nowServer() + 2) * 1000;
|
||||||
|
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
|
if (pollInFlight) {
|
||||||
|
pollQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const [snap, nw] = await Promise.all([
|
const [snap, nw] = await Promise.all([
|
||||||
fetchJSON("snapshot.json"),
|
fetchJSON("snapshot.json"),
|
||||||
fetchJSON("news.json").catch(() => null)
|
fetchJSON("news.json").catch(() => null)
|
||||||
]);
|
]);
|
||||||
const snapChanged = snap.text !== lastSnapText;
|
const priorSnapshot = snapshot;
|
||||||
|
let snapChanged = false;
|
||||||
|
let selectedChanged = false;
|
||||||
|
let detailChanged = false;
|
||||||
|
if (snap.text !== lastSnapText) {
|
||||||
|
const incoming = JSON.parse(snap.text);
|
||||||
|
if (!priorSnapshot || incoming.server_time_epoch >= priorSnapshot.server_time_epoch) {
|
||||||
|
snapChanged = true;
|
||||||
|
selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !==
|
||||||
|
tfFingerprint(incoming.timeframes?.[selectedTf]);
|
||||||
|
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
|
||||||
|
tfFingerprint(incoming.timeframes?.[detailTf]);
|
||||||
|
snapshot = incoming;
|
||||||
|
lastSnapText = snap.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
const newsChanged = nw != null && nw.text !== lastNewsText;
|
const newsChanged = nw != null && nw.text !== lastNewsText;
|
||||||
if (snapChanged) { snapshot = JSON.parse(snap.text); lastSnapText = snap.text; }
|
|
||||||
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
||||||
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
|
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
|
||||||
lastPollOk = new Date();
|
lastPollOk = new Date();
|
||||||
@@ -745,9 +841,12 @@ async function poll() {
|
|||||||
|
|
||||||
if (snapChanged) {
|
if (snapChanged) {
|
||||||
renderHero();
|
renderHero();
|
||||||
renderCards();
|
if (selectedChanged) renderCards();
|
||||||
if (window.renderGauges) window.renderGauges();
|
if (detailChanged) renderDetail();
|
||||||
renderDetail();
|
if (selectedChanged) {
|
||||||
|
const signal = snapshot.timeframes[selectedTf]?.report?.signal;
|
||||||
|
if (signal) announce(`${TF_LABEL[selectedTf]} signal updated: ${sigLabel(signal)}.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (newsChanged) {
|
if (newsChanged) {
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
@@ -756,21 +855,19 @@ async function poll() {
|
|||||||
}
|
}
|
||||||
if (snapChanged || newsChanged) renderContext();
|
if (snapChanged || newsChanged) renderContext();
|
||||||
tick();
|
tick();
|
||||||
|
scheduleBoundaryRefresh();
|
||||||
if (snapChanged) {
|
|
||||||
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
|
||||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
|
||||||
if (isFinite(nextScan)) {
|
|
||||||
clearTimeout(refetchTimer);
|
|
||||||
const delay = (nextScan - nowServer() + 2) * 1000;
|
|
||||||
if (delay > 0) refetchTimer = setTimeout(poll, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
||||||
const b = $("banner-fail");
|
const b = $("banner-fail");
|
||||||
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
|
b.textContent = `Dashboard data is temporarily unavailable.${last} Retrying…`;
|
||||||
b.classList.remove("d-none");
|
b.classList.remove("d-none");
|
||||||
|
scheduleRefresh(RETRY_MS);
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false;
|
||||||
|
if (pollQueued) {
|
||||||
|
pollQueued = false;
|
||||||
|
poll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,21 +906,48 @@ function tick() {
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
tfModal = new bootstrap.Modal($("tf-detail"));
|
tfModal = new bootstrap.Modal($("tf-detail"));
|
||||||
newsModal = new bootstrap.Modal($("news-detail"));
|
newsModal = new bootstrap.Modal($("news-detail"));
|
||||||
|
const storedTf = localGet(TIMEFRAME_PREF);
|
||||||
|
if (TF_LABEL[storedTf]) selectedTf = storedTf;
|
||||||
|
$("hi-only").checked = localGet(HIGH_IMPACT_PREF) === "true";
|
||||||
|
|
||||||
// track modal shown state for renderDetail / renderNewsDlg guards
|
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); });
|
||||||
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModal._isShown = true; });
|
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
|
||||||
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModal._isShown = false; detailTf = null; });
|
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); });
|
||||||
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModal._isShown = true; });
|
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
|
||||||
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModal._isShown = false; ndState = null; });
|
|
||||||
|
|
||||||
wireOpeners($("tf-grid"));
|
const selectTf = tf => {
|
||||||
wireOpeners($("hero-rail"));
|
if (!TF_LABEL[tf]) return;
|
||||||
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
|
const changed = selectedTf !== tf;
|
||||||
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
|
selectedTf = tf;
|
||||||
|
localSet(TIMEFRAME_PREF, tf);
|
||||||
$("hi-only")?.addEventListener("change", renderCalendar);
|
renderHero();
|
||||||
|
renderCards();
|
||||||
|
if (changed) announce(`${TF_LABEL[tf]} workspace selected.`);
|
||||||
|
};
|
||||||
|
$("hero-rail")?.addEventListener("click", e => {
|
||||||
|
const button = e.target.closest("[data-select-tf]");
|
||||||
|
if (button) selectTf(button.dataset.selectTf);
|
||||||
|
});
|
||||||
|
$("tf-grid")?.addEventListener("click", e => {
|
||||||
|
const detail = e.target.closest("[data-open-detail]");
|
||||||
|
if (detail) return openDetail(detail.dataset.openDetail);
|
||||||
|
const button = e.target.closest("[data-select-tf]");
|
||||||
|
if (button) selectTf(button.dataset.selectTf);
|
||||||
|
});
|
||||||
|
$("wire")?.addEventListener("click", e => {
|
||||||
|
const button = e.target.closest("[data-news-id]");
|
||||||
|
if (button) openNewsDetail(button.dataset.newsId);
|
||||||
|
});
|
||||||
|
$("cal-body")?.addEventListener("click", e => {
|
||||||
|
const button = e.target.closest("[data-ev-key]");
|
||||||
|
if (button) openCalDetail(button.dataset.evKey);
|
||||||
|
});
|
||||||
|
$("hi-only")?.addEventListener("change", () => {
|
||||||
|
localSet(HIGH_IMPACT_PREF, String($("hi-only").checked));
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
setInterval(poll, POLL_MS);
|
scheduleFallbackRefresh();
|
||||||
setInterval(tick, 1000);
|
setInterval(tick, 1000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
/* Gold Desk — score gauges.
|
|
||||||
*
|
|
||||||
* Draws a horizontal diverging Chart.js bar (−100…+100, zero-centered)
|
|
||||||
* into every `.gauge-container` element. The containers are created
|
|
||||||
* dynamically by dashboard.js after each snapshot poll, so this module
|
|
||||||
* exposes window.renderGauges() which dashboard.js calls after every
|
|
||||||
* card render (and on resize). Re-running it destroys and redraws each
|
|
||||||
* chart, so it is safe to call repeatedly.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
function renderGauges() {
|
|
||||||
if (typeof Chart === "undefined") return;
|
|
||||||
document.querySelectorAll(".gauge-container").forEach(function (container) {
|
|
||||||
var score = parseFloat(container.dataset.score);
|
|
||||||
if (!isFinite(score)) return;
|
|
||||||
|
|
||||||
var canvas = container.querySelector("canvas");
|
|
||||||
if (!canvas) {
|
|
||||||
container.innerHTML = "";
|
|
||||||
canvas = document.createElement("canvas");
|
|
||||||
container.appendChild(canvas);
|
|
||||||
} else {
|
|
||||||
var existing = Chart.getChart(canvas);
|
|
||||||
if (existing) existing.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
var pos = score >= 0;
|
|
||||||
new Chart(canvas.getContext("2d"), {
|
|
||||||
type: "bar",
|
|
||||||
data: {
|
|
||||||
labels: [container.dataset.label || ""],
|
|
||||||
datasets: [{
|
|
||||||
data: [score],
|
|
||||||
backgroundColor: pos ? "rgba(25,135,84,0.85)" : "rgba(220,53,69,0.85)",
|
|
||||||
borderColor: pos ? "#198754" : "#dc3545",
|
|
||||||
borderWidth: 1,
|
|
||||||
barThickness: 10,
|
|
||||||
maxBarThickness: 10,
|
|
||||||
borderRadius: 3
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
indexAxis: "y",
|
|
||||||
scales: {
|
|
||||||
x: { min: -100, max: 100, display: false, grid: { display: false }, ticks: { display: false } },
|
|
||||||
y: { display: false, grid: { display: false } }
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: { enabled: false },
|
|
||||||
title: { display: false }
|
|
||||||
},
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
responsive: true,
|
|
||||||
animation: { duration: 400, easing: "easeOutQuad" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.renderGauges = renderGauges;
|
|
||||||
|
|
||||||
var resizeTimer = null;
|
|
||||||
window.addEventListener("resize", function () {
|
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
resizeTimer = setTimeout(renderGauges, 150);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
+34
-6
@@ -5670,7 +5670,21 @@ def _():
|
|||||||
pub = dpub.DashboardPublisher(out_dir=tmp)
|
pub = dpub.DashboardPublisher(out_dir=tmp)
|
||||||
pub.install_static()
|
pub.install_static()
|
||||||
assert sorted(os.listdir(tmp)) == \
|
assert sorted(os.listdir(tmp)) == \
|
||||||
["dashboard.css", "dashboard.html", "dashboard.js", "gauge.js", "news_summary.php"] # all installed, no .tmp
|
["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp
|
||||||
|
|
||||||
|
|
||||||
|
@test("dashboard: static UI uses CSS gauges and a coordinated refresh loop")
|
||||||
|
def _():
|
||||||
|
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
with open(os.path.join(static_dir, "dashboard.html"), encoding="utf-8") as fh:
|
||||||
|
html = fh.read()
|
||||||
|
with open(os.path.join(static_dir, "dashboard.js"), encoding="utf-8") as fh:
|
||||||
|
js = fh.read()
|
||||||
|
assert "chart.js" not in html.lower()
|
||||||
|
assert "gauge.js" not in html
|
||||||
|
assert "setInterval(poll" not in js
|
||||||
|
assert "FALLBACK_POLL_MS" in js and "pollInFlight" in js
|
||||||
|
assert "data-select-tf" in js and "localStorage" in js
|
||||||
|
|
||||||
|
|
||||||
@test("dashboard: publish is atomic and leaves no temp file")
|
@test("dashboard: publish is atomic and leaves no temp file")
|
||||||
@@ -5683,19 +5697,33 @@ def _():
|
|||||||
assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left
|
assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left
|
||||||
|
|
||||||
|
|
||||||
@test("dashboard: run_cycle publishes snapshot BEFORE news refresh")
|
@test("dashboard: run_cycle publishes technical snapshot before panels/news")
|
||||||
def _():
|
def _():
|
||||||
# News I/O (FF + RSS + DeepSeek) can take 20-30 s — fresh signals
|
# News and advisory panels can take 20-30 s cold — fresh technical
|
||||||
# must reach the page first (2026-08-06 sync-latency fix).
|
# signals must reach the page first.
|
||||||
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
|
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
|
||||||
order = []
|
order = []
|
||||||
pub.scan_tf = lambda tf: order.append(f"scan:{tf.value}")
|
pub.scan_tf = lambda tf: order.append(f"scan:{tf.value}")
|
||||||
pub.publish_snapshot = lambda: order.append("snapshot")
|
pub.publish_snapshot = lambda: order.append("snapshot")
|
||||||
|
pub.attach_panels = lambda tfs: (order.append("panels") or True)
|
||||||
pub.refresh_news = lambda: order.append("news_refresh")
|
pub.refresh_news = lambda: order.append("news_refresh")
|
||||||
pub.publish_news = lambda: order.append("news_publish")
|
pub.publish_news = lambda: order.append("news_publish")
|
||||||
pub.run_cycle([Timeframe.M5])
|
pub.run_cycle([Timeframe.M5])
|
||||||
assert order == ["scan:5min", "snapshot", "news_refresh",
|
assert order == ["scan:5min", "snapshot", "panels", "snapshot", "news_refresh",
|
||||||
"news_publish"], order
|
"news_publish"], order
|
||||||
|
|
||||||
|
|
||||||
|
@test("dashboard: scan errors keep private exception detail out of snapshots")
|
||||||
|
def _():
|
||||||
|
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
|
||||||
|
pub._provider = lambda: object()
|
||||||
|
with patch("dashboard_publisher.produce_signal",
|
||||||
|
side_effect=RuntimeError("provider token leaked")):
|
||||||
|
pub.scan_tf(Timeframe.M5)
|
||||||
|
entry = pub.snapshots["5min"]
|
||||||
|
assert entry["status"] == "error"
|
||||||
|
assert entry["error"] == "Scan unavailable"
|
||||||
|
assert "token" not in entry["error"]
|
||||||
|
|
||||||
|
|
||||||
@test("dashboard: seconds_until_boundary bounds and constants")
|
@test("dashboard: seconds_until_boundary bounds and constants")
|
||||||
|
|||||||
Reference in New Issue
Block a user