修改前端漏斗

This commit is contained in:
dekun
2026-05-18 11:05:32 +08:00
parent 82a7237063
commit 1d38b2c574
4 changed files with 123 additions and 8 deletions
+43 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -32,6 +33,9 @@ from .storage import Storage
LOGGER = logging.getLogger("onchain_scout.web") LOGGER = logging.getLogger("onchain_scout.web")
FIXED_BAR = "5m" FIXED_BAR = "5m"
DAILY_REPORT_JOB_ID = "daily_report_job" 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: def _hash_password(plain: str) -> str:
@@ -49,6 +53,35 @@ def _asset_version(root: Path) -> str:
return str(mt or 1) 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]: def _dedupe_funnel_alerts_by_symbol(alerts: list[dict]) -> list[dict]:
"""同一币种只保留一条漏斗记录:优先保留 created_at 最新的(避免历史轮次堆叠)。""" """同一币种只保留一条漏斗记录:优先保留 created_at 最新的(避免历史轮次堆叠)。"""
by_time = sorted(alerts, key=lambda x: str(x.get("created_at") or ""), reverse=True) 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") @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) alerts = await storage.get_recent_alerts(limit=500)
items = [a for a in alerts if (a.get("details") or {}).get("source") == "gemma_funnel"] 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 = _dedupe_funnel_alerts_by_symbol(items)
items.sort( items.sort(
key=lambda x: float((x.get("details") or {}).get("composite_score") or 0.0), key=lambda x: float((x.get("details") or {}).get("composite_score") or 0.0),
reverse=True, reverse=True,
) )
return JSONResponse({"items": items[:100]}) return JSONResponse({"items": items[:100], "window_hours": wh})
@app.get("/api/daily-report") @app.get("/api/daily-report")
async def api_daily_report(_: None = Depends(require_login)) -> JSONResponse: async def api_daily_report(_: None = Depends(require_login)) -> JSONResponse:
+61 -6
View File
@@ -77,6 +77,53 @@ function formatIsoToBeijing(iso) {
return s.replace("T", " "); 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() { function tickClock() {
const el = document.getElementById("liveClock"); const el = document.getElementById("liveClock");
if (!el) return; if (!el) return;
@@ -132,8 +179,9 @@ function renderFunnel(items, funnelCtx) {
if (!items.length) { if (!items.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "matrix-hint matrix-hint-empty"; empty.className = "matrix-hint matrix-hint-empty";
const winH = Number(ctx.windowHours) || FUNNEL_WINDOW_DEFAULT;
let why = let why =
"// 暂无漏斗记录:本面板只展示 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。"; `// 暂无漏斗记录:本面板只展示最近 <code>${winH}h</code> 内 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。`;
if (!gemmaOn) { if (!gemmaOn) {
why += " 当前 <code>gemma.enabled=false</code>,漏斗未运行。"; why += " 当前 <code>gemma.enabled=false</code>,漏斗未运行。";
} else if (cycleMsg === "funnel_pending") { } else if (cycleMsg === "funnel_pending") {
@@ -162,8 +210,10 @@ function renderFunnel(items, funnelCtx) {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "matrix-card" + (pushed ? " hot" : ""); card.className = "matrix-card" + (pushed ? " hot" : "");
const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—"; const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—";
const updatedCn = formatIsoToBeijing(a.created_at);
card.innerHTML = ` card.innerHTML = `
<div class="matrix-card-title">${a.symbol}</div> <div class="matrix-card-title">${escapeHtml(a.symbol || "—")}</div>
<div class="matrix-card-meta time">更新 ${escapeHtml(updatedCn)}(北京时间)</div>
<div class="matrix-card-meta"> <div class="matrix-card-meta">
COMPOSITE <strong>${comp.toFixed(1)}</strong> · P${g.priority || "?"} · COMPOSITE <strong>${comp.toFixed(1)}</strong> · P${g.priority || "?"} ·
结构 ${g.daily_structure || "?"} · 量 ${g.volume_view || "?"} · 结构 ${g.daily_structure || "?"} · 量 ${g.volume_view || "?"} ·
@@ -324,12 +374,13 @@ async function saveDailyReportSettings() {
async function refresh() { async function refresh() {
try { try {
const funnelWindowH = getFunnelWindowHours();
const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([ const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([
fetchJson("/api/status"), fetchJson("/api/status"),
fetchJson("/api/alerts"), fetchJson("/api/alerts"),
fetchJson("/api/logs"), fetchJson("/api/logs"),
fetchJson("/api/config"), fetchJson("/api/config"),
fetchJson("/api/funnel"), fetchJson(`/api/funnel?window_hours=${encodeURIComponent(funnelWindowH)}`),
fetchJson("/api/daily-report"), fetchJson("/api/daily-report"),
]); ]);
updateHud(status); updateHud(status);
@@ -340,10 +391,13 @@ async function refresh() {
if (cf) cf.textContent = pretty(config); if (cf) cf.textContent = pretty(config);
const runState = (status && status.state) || {}; const runState = (status && status.state) || {};
const funnelWindowApplied =
funnel && funnel.window_hours != null ? Number(funnel.window_hours) : funnelWindowH;
renderFunnel(funnel.items || [], { renderFunnel(funnel.items || [], {
gemmaEnabled: !!(config.gemma && config.gemma.enabled), gemmaEnabled: !!(config.gemma && config.gemma.enabled),
cycleMsg: runState.gemma_cycle_msg || "", cycleMsg: runState.gemma_cycle_msg || "",
lastFunnelAt: runState.last_funnel_at || "", lastFunnelAt: runState.last_funnel_at || "",
windowHours: funnelWindowApplied,
}); });
renderDailyReport(dailyReport); renderDailyReport(dailyReport);
try { try {
@@ -370,12 +424,12 @@ async function refresh() {
if (fm) { if (fm) {
let line = let line =
`// 浏览器刚拉完 API${pullCn} HUD 的 LAST:上一轮 Gate 扫描整轮结束(可与本行差约 ${poll}s)| ` + `// 浏览器刚拉完 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) { if (String(gmsg).includes("funnel_ranked=0") && fc > 0) {
line += line +=
" | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过、或候选在取日线/Ollama 前被滤掉),卡片仍是历史结果,不是前端卡死。"; ` | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过),仍显示 ${funnelWindowApplied}h 内已有卡片。`;
} else { } else {
line += " | 若文案长期不变=近期没有新的 gemma_funnel 入库。"; line += ` 超过 ${funnelWindowApplied}h 的漏斗记录不再显示(库内仍在)。`;
} }
fm.textContent = line; fm.textContent = line;
} }
@@ -613,6 +667,7 @@ loadOrderExecutors().catch(console.error);
tickClock(); tickClock();
setInterval(tickClock, 1000); setInterval(tickClock, 1000);
initMatrixRain(); initMatrixRain();
initFunnelWindowControls();
refresh(); refresh();
setInterval(refresh, 4000); setInterval(refresh, 4000);
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
@@ -94,6 +94,12 @@
<span class="matrix-chip matrix-chip-magenta">LIVE FEED</span> <span class="matrix-chip matrix-chip-magenta">LIVE FEED</span>
</div> </div>
<p class="matrix-hint">合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送</p> <p class="matrix-hint">合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送</p>
<div class="matrix-form-row matrix-form-row-tight">
<label for="funnelWindowHoursInput">展示窗口(h)</label>
<input id="funnelWindowHoursInput" type="number" step="1" min="1" max="168" value="24" title="仅显示该小时内写入的 gemma_funnel,保存在本浏览器" />
<button type="button" id="applyFunnelWindowBtn" class="matrix-btn">应用</button>
<span id="funnelWindowMsg" class="matrix-msg"></span>
</div>
<p id="funnelMeta" class="matrix-hint matrix-dim">// 数据同步中…</p> <p id="funnelMeta" class="matrix-hint matrix-dim">// 数据同步中…</p>
<div id="funnelMatrix" class="matrix-grid"></div> <div id="funnelMatrix" class="matrix-grid"></div>
</section> </section>
@@ -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"]