From 1d38b2c57470f5e7c5bb91ffbd3b3b2700a5ae26 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 18 May 2026 11:05:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF=E6=BC=8F?= =?UTF-8?q?=E6=96=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- onchain_scout_gate/app/web.py | 45 ++++++++++++- onchain_scout_gate/static/app.js | 67 +++++++++++++++++-- onchain_scout_gate/templates/dashboard.html | 6 ++ .../tests/test_funnel_window.py | 13 ++++ 4 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 onchain_scout_gate/tests/test_funnel_window.py diff --git a/onchain_scout_gate/app/web.py b/onchain_scout_gate/app/web.py index 46cfd82..50a6cf8 100644 --- a/onchain_scout_gate/app/web.py +++ b/onchain_scout_gate/app/web.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import json import logging +from datetime import datetime, timedelta, timezone from pathlib import Path from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -32,6 +33,9 @@ from .storage import Storage LOGGER = logging.getLogger("onchain_scout.web") FIXED_BAR = "5m" DAILY_REPORT_JOB_ID = "daily_report_job" +FUNNEL_DISPLAY_HOURS_DEFAULT = 24.0 +FUNNEL_DISPLAY_HOURS_MIN = 1.0 +FUNNEL_DISPLAY_HOURS_MAX = 168.0 def _hash_password(plain: str) -> str: @@ -49,6 +53,35 @@ def _asset_version(root: Path) -> str: return str(mt or 1) +def _parse_alert_created_at_utc(raw: object) -> datetime | None: + if raw is None: + return None + try: + s = str(raw).strip() + if not s: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + except (TypeError, ValueError): + return None + + +def _filter_alerts_within_hours(alerts: list[dict], *, within_hours: float) -> list[dict]: + if within_hours <= 0: + return list(alerts) + cutoff = datetime.utcnow() - timedelta(hours=within_hours) + out: list[dict] = [] + for a in alerts: + created = _parse_alert_created_at_utc(a.get("created_at")) + if created is not None and created >= cutoff: + out.append(a) + return out + + def _dedupe_funnel_alerts_by_symbol(alerts: list[dict]) -> list[dict]: """同一币种只保留一条漏斗记录:优先保留 created_at 最新的(避免历史轮次堆叠)。""" by_time = sorted(alerts, key=lambda x: str(x.get("created_at") or ""), reverse=True) @@ -513,15 +546,23 @@ def create_app(settings: Settings) -> FastAPI: ) @app.get("/api/funnel") - async def api_funnel(_: None = Depends(require_login)) -> JSONResponse: + async def api_funnel( + window_hours: float = FUNNEL_DISPLAY_HOURS_DEFAULT, + _: None = Depends(require_login), + ) -> JSONResponse: + wh = max( + FUNNEL_DISPLAY_HOURS_MIN, + min(FUNNEL_DISPLAY_HOURS_MAX, float(window_hours)), + ) alerts = await storage.get_recent_alerts(limit=500) items = [a for a in alerts if (a.get("details") or {}).get("source") == "gemma_funnel"] + items = _filter_alerts_within_hours(items, within_hours=wh) items = _dedupe_funnel_alerts_by_symbol(items) items.sort( key=lambda x: float((x.get("details") or {}).get("composite_score") or 0.0), reverse=True, ) - return JSONResponse({"items": items[:100]}) + return JSONResponse({"items": items[:100], "window_hours": wh}) @app.get("/api/daily-report") async def api_daily_report(_: None = Depends(require_login)) -> JSONResponse: diff --git a/onchain_scout_gate/static/app.js b/onchain_scout_gate/static/app.js index 1da3b78..ebaacaa 100644 --- a/onchain_scout_gate/static/app.js +++ b/onchain_scout_gate/static/app.js @@ -77,6 +77,53 @@ function formatIsoToBeijing(iso) { return s.replace("T", " "); } +const FUNNEL_WINDOW_LS_KEY = "funnel_display_hours"; +const FUNNEL_WINDOW_DEFAULT = 24; +const FUNNEL_WINDOW_MIN = 1; +const FUNNEL_WINDOW_MAX = 168; + +function getFunnelWindowHours() { + try { + const n = Number(localStorage.getItem(FUNNEL_WINDOW_LS_KEY)); + if (Number.isFinite(n) && n >= FUNNEL_WINDOW_MIN && n <= FUNNEL_WINDOW_MAX) { + return Math.round(n); + } + } catch (_) { + /* ignore */ + } + return FUNNEL_WINDOW_DEFAULT; +} + +function setFunnelWindowHours(raw) { + const n = Math.round(Number(raw)); + const h = + Number.isFinite(n) && n >= FUNNEL_WINDOW_MIN && n <= FUNNEL_WINDOW_MAX + ? n + : FUNNEL_WINDOW_DEFAULT; + try { + localStorage.setItem(FUNNEL_WINDOW_LS_KEY, String(h)); + } catch (_) { + /* ignore */ + } + const inp = document.getElementById("funnelWindowHoursInput"); + if (inp) inp.value = String(h); + return h; +} + +function initFunnelWindowControls() { + const inp = document.getElementById("funnelWindowHoursInput"); + if (inp) inp.value = String(getFunnelWindowHours()); + const btn = document.getElementById("applyFunnelWindowBtn"); + if (btn) { + btn.addEventListener("click", () => { + const h = setFunnelWindowHours(getInputNumber("funnelWindowHoursInput")); + const msg = document.getElementById("funnelWindowMsg"); + if (msg) msg.textContent = `// 展示窗口已设为最近 ${h} 小时(本浏览器记忆)`; + refresh().catch(console.error); + }); + } +} + function tickClock() { const el = document.getElementById("liveClock"); if (!el) return; @@ -132,8 +179,9 @@ function renderFunnel(items, funnelCtx) { if (!items.length) { const empty = document.createElement("div"); empty.className = "matrix-hint matrix-hint-empty"; + const winH = Number(ctx.windowHours) || FUNNEL_WINDOW_DEFAULT; let why = - "// 暂无漏斗记录:本面板只展示 source=gemma_funnel 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。"; + `// 暂无漏斗记录:本面板只展示最近 ${winH}hsource=gemma_funnel 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。`; if (!gemmaOn) { why += " 当前 gemma.enabled=false,漏斗未运行。"; } else if (cycleMsg === "funnel_pending") { @@ -162,8 +210,10 @@ function renderFunnel(items, funnelCtx) { const card = document.createElement("article"); card.className = "matrix-card" + (pushed ? " hot" : ""); const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—"; + const updatedCn = formatIsoToBeijing(a.created_at); card.innerHTML = ` -
${a.symbol}
+
${escapeHtml(a.symbol || "—")}
+
更新 ${escapeHtml(updatedCn)}(北京时间)
COMPOSITE ${comp.toFixed(1)} · P${g.priority || "?"} · 结构 ${g.daily_structure || "?"} · 量 ${g.volume_view || "?"} · @@ -324,12 +374,13 @@ async function saveDailyReportSettings() { async function refresh() { try { + const funnelWindowH = getFunnelWindowHours(); const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([ fetchJson("/api/status"), fetchJson("/api/alerts"), fetchJson("/api/logs"), fetchJson("/api/config"), - fetchJson("/api/funnel"), + fetchJson(`/api/funnel?window_hours=${encodeURIComponent(funnelWindowH)}`), fetchJson("/api/daily-report"), ]); updateHud(status); @@ -340,10 +391,13 @@ async function refresh() { if (cf) cf.textContent = pretty(config); const runState = (status && status.state) || {}; + const funnelWindowApplied = + funnel && funnel.window_hours != null ? Number(funnel.window_hours) : funnelWindowH; renderFunnel(funnel.items || [], { gemmaEnabled: !!(config.gemma && config.gemma.enabled), cycleMsg: runState.gemma_cycle_msg || "", lastFunnelAt: runState.last_funnel_at || "", + windowHours: funnelWindowApplied, }); renderDailyReport(dailyReport); try { @@ -370,12 +424,12 @@ async function refresh() { if (fm) { let line = `// 浏览器刚拉完 API:${pullCn} | HUD 的 LAST:上一轮 Gate 扫描整轮结束(可与本行差约 ${poll}s)| ` + - `矩阵卡片 ${fc} 条:来自告警库「每币最新一条」| 记忆体 last_funnel 更新:${lfAt} | 后轮 gemma:${gmsg}`; + `矩阵卡片 ${fc} 条:最近 ${funnelWindowApplied}h 内 gemma_funnel(每币最新一条)| 记忆体 last_funnel 更新:${lfAt} | 后轮 gemma:${gmsg}`; if (String(gmsg).includes("funnel_ranked=0") && fc > 0) { line += - " | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过、或候选在取日线/Ollama 前被滤掉),卡片仍是历史结果,不是前端卡死。"; + ` | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过),仍显示 ${funnelWindowApplied}h 内已有卡片。`; } else { - line += " | 若文案长期不变=近期没有新的 gemma_funnel 入库。"; + line += ` | 超过 ${funnelWindowApplied}h 的漏斗记录不再显示(库内仍在)。`; } fm.textContent = line; } @@ -613,6 +667,7 @@ loadOrderExecutors().catch(console.error); tickClock(); setInterval(tickClock, 1000); initMatrixRain(); +initFunnelWindowControls(); refresh(); setInterval(refresh, 4000); document.addEventListener("visibilitychange", () => { diff --git a/onchain_scout_gate/templates/dashboard.html b/onchain_scout_gate/templates/dashboard.html index 2c9a6fc..65b938f 100644 --- a/onchain_scout_gate/templates/dashboard.html +++ b/onchain_scout_gate/templates/dashboard.html @@ -94,6 +94,12 @@ LIVE FEED

合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送

+
+ + + + +

// 数据同步中…

diff --git a/onchain_scout_gate/tests/test_funnel_window.py b/onchain_scout_gate/tests/test_funnel_window.py new file mode 100644 index 0000000..b61d40f --- /dev/null +++ b/onchain_scout_gate/tests/test_funnel_window.py @@ -0,0 +1,13 @@ +from datetime import datetime, timedelta + +from app.web import _filter_alerts_within_hours + + +def test_filter_alerts_within_hours_keeps_recent() -> None: + now = datetime.utcnow() + alerts = [ + {"symbol": "BTC", "created_at": (now - timedelta(hours=1)).isoformat()}, + {"symbol": "ETH", "created_at": (now - timedelta(hours=30)).isoformat()}, + ] + out = _filter_alerts_within_hours(alerts, within_hours=24.0) + assert [a["symbol"] for a in out] == ["BTC"]