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}h 内 source=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 = `
-
合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送
+// 数据同步中…
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"]