修改前端漏斗

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 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:
+61 -6
View File
@@ -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 =
"// 暂无漏斗记录:本面板只展示 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。";
`// 暂无漏斗记录:本面板只展示最近 <code>${winH}h</code> 内 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。`;
if (!gemmaOn) {
why += " 当前 <code>gemma.enabled=false</code>,漏斗未运行。";
} 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 = `
<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">
COMPOSITE <strong>${comp.toFixed(1)}</strong> · 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", () => {
@@ -94,6 +94,12 @@
<span class="matrix-chip matrix-chip-magenta">LIVE FEED</span>
</div>
<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>
<div id="funnelMatrix" class="matrix-grid"></div>
</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"]