修改前端漏斗
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user