增加大模型

This commit is contained in:
dekun
2026-05-26 09:38:23 +08:00
parent e0ec3f87a9
commit 27031ab676
14 changed files with 797 additions and 69 deletions
+11 -1
View File
@@ -3,7 +3,8 @@ BINANCE_FAPI_BASE=https://fapi.binance.com
TOP_N=30
VOLUME_THRESHOLD=10000000
CHANGE_THRESHOLD=5
REFRESH_MINUTES=5
# 今日自动刷新间隔(分钟),240=每4小时;另支持页脚「立即刷新今日」手动
REFRESH_MINUTES=240
HOST=0.0.0.0
PORT=21450
@@ -24,3 +25,12 @@ CHART_KLINE_LIMIT=300
CHART_CACHE_MINUTES=60
FUNDING_HISTORY_LIMIT=90
FUNDING_CACHE_MINUTES=30
# 大模型解读(OpenAI 兼容,网关 http://op.bz121.com
LLM_BASE_URL=http://op.bz121.com
LLM_API_KEY=sk-your-key-here
LLM_MODEL=gemma4:e4b
# 每个币种间隔秒数(默认 180 = 3 分钟)
LLM_SYMBOL_INTERVAL_SEC=180
# 服务启动后若已配置 KEY,自动对三日交集解读一轮
LLM_AUTO_ON_STARTUP=true
+3 -1
View File
@@ -10,7 +10,9 @@
- 成交额排名 Top30USDT 计价)
- 高亮标记(不改变排名):成交额 ≥ 1000万 USDT、|涨跌幅| ≥ 5%
- 昨日周期:`[D-1 08:00, D 08:00)`
- 今日周期:`[D 08:00, 当前)`,每 5 分钟后台刷新,Web 每 60 秒拉取
- 今日周期:`[D 08:00, 当前)`,每 **4 小时**后台刷新 + 页脚手动刷新;K线/周期数据服务端 SQLite + 浏览器缓存
- 数据统计:连续三日 Top30 交集(涨跌幅不限)
- 大模型解读(`gemma4:e4b`):每日 **08:05** 对三日交集逐币解读(每币 3 分钟),启动自动一轮;需配置 `LLM_API_KEY`
## 环境要求
+66
View File
@@ -0,0 +1,66 @@
"""服务端生成日K+成交量 PNG,供大模型视觉解读。"""
import io
from datetime import datetime
from .kline_store import get_daily_candles
async def render_daily_chart_png_async(symbol: str, limit: int = 300) -> bytes:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
candles, _ = await get_daily_candles(symbol, limit)
if not candles:
raise ValueError(f"no klines for {symbol}")
times = [datetime.fromtimestamp(c["time"] / 1000) for c in candles]
opens = [c["open"] for c in candles]
highs = [c["high"] for c in candles]
lows = [c["low"] for c in candles]
closes = [c["close"] for c in candles]
vols = [c.get("quote_volume") or c.get("volume") or 0 for c in candles]
fig, (ax1, ax2) = plt.subplots(
2, 1, figsize=(12, 7), gridspec_kw={"height_ratios": [3, 1]}, facecolor="#0d1118"
)
fig.subplots_adjust(hspace=0.08)
for i in range(len(candles)):
t = times[i]
o, h, l, cl = opens[i], highs[i], lows[i], closes[i]
color = "#0ecb81" if cl >= o else "#f6465d"
ax1.plot([t, t], [l, h], color=color, linewidth=0.8)
ax1.add_patch(
plt.Rectangle(
(mdates.date2num(t) - 0.3, min(o, cl)),
0.6,
abs(cl - o) or 0.001,
facecolor=color,
edgecolor=color,
)
)
ax1.set_facecolor("#0d1118")
ax1.tick_params(colors="#8b9cb3")
ax1.set_title(f"{symbol} 日K + 成交量", color="#e7ecf3", fontsize=14)
ax1.grid(True, alpha=0.2)
colors_vol = ["#0ecb81" if closes[i] >= opens[i] else "#f6465d" for i in range(len(candles))]
ax2.bar(times, vols, color=colors_vol, alpha=0.7, width=0.8)
ax2.set_facecolor("#0d1118")
ax2.tick_params(colors="#8b9cb3")
ax2.set_ylabel("成交额", color="#8b9cb3")
ax2.grid(True, alpha=0.2)
for ax in (ax1, ax2):
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
fig.autofmt_xdate()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=120, facecolor="#0d1118")
plt.close(fig)
buf.seek(0)
return buf.read()
+6 -1
View File
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
top_n: int = 30
volume_threshold: float = 10_000_000
change_threshold: float = 5.0
refresh_minutes: int = 5
refresh_minutes: int = 240
host: str = "127.0.0.1"
port: int = 21450
db_path: str = str(ROOT_DIR / "data" / "monitor.db")
@@ -37,6 +37,11 @@ class Settings(BaseSettings):
proxy_enabled: bool = False
proxy_url: str = "socks5h://192.168.8.4:1081"
proxy_for: str = "binance" # binance | wecom | all
llm_base_url: str = "http://op.bz121.com"
llm_api_key: str = ""
llm_model: str = "gemma4:e4b"
llm_symbol_interval_sec: int = 180
llm_auto_on_startup: bool = True
settings = Settings()
+92
View File
@@ -96,6 +96,17 @@ def init_db() -> None:
mark_price REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS llm_interpretations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
batch_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_llm_symbol_batch
ON llm_interpretations(symbol, batch_id);
"""
)
@@ -353,6 +364,87 @@ def save_funding_current_bulk(data: dict[str, dict[str, Any]]) -> None:
)
def save_llm_interpretation(symbol: str, batch_id: str, content: str) -> None:
with get_conn() as conn:
conn.execute(
"""
INSERT INTO llm_interpretations (symbol, batch_id, content, created_at)
VALUES (?, ?, ?, ?)
""",
(symbol.upper(), batch_id, content, datetime.now().isoformat()),
)
def get_llm_interpretations(batch_id: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
with get_conn() as conn:
if batch_id:
rows = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE batch_id = ?
ORDER BY id DESC
LIMIT ?
""",
(batch_id, limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE batch_id = (
SELECT batch_id FROM llm_interpretations ORDER BY id DESC LIMIT 1
)
ORDER BY id ASC
LIMIT ?
""",
(limit,),
).fetchall()
return [
{
"symbol": r["symbol"],
"batch_id": r["batch_id"],
"content": r["content"],
"created_at": r["created_at"],
}
for r in rows
]
def get_llm_interpretation(symbol: str, batch_id: str | None = None) -> dict[str, Any] | None:
sym = symbol.upper()
with get_conn() as conn:
if batch_id:
row = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE symbol = ? AND batch_id = ?
ORDER BY id DESC LIMIT 1
""",
(sym, batch_id),
).fetchone()
else:
row = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE symbol = ?
ORDER BY id DESC LIMIT 1
""",
(sym,),
).fetchone()
if not row:
return None
return {
"symbol": row["symbol"],
"batch_id": row["batch_id"],
"content": row["content"],
"created_at": row["created_at"],
}
def was_pushed_today(period_start: str, period_end: str) -> bool:
with get_conn() as conn:
row = conn.execute(
+174
View File
@@ -0,0 +1,174 @@
"""大模型解读(OpenAI 兼容接口 + 图表图片)。"""
import asyncio
import base64
import logging
from datetime import datetime
import httpx
from .chart_image import render_daily_chart_png_async
from .config import settings
from .db import get_llm_interpretation, save_llm_interpretation
from .stats import compute_three_day_stats
logger = logging.getLogger(__name__)
_interpret_lock = asyncio.Lock()
_interpret_state: dict = {
"running": False,
"current_symbol": "",
"done": 0,
"total": 0,
"batch_id": "",
"last_error": "",
}
def get_interpret_state() -> dict:
return dict(_interpret_state)
def _api_url() -> str:
base = settings.llm_base_url.rstrip("/")
if base.endswith("/v1"):
return f"{base}/chat/completions"
return f"{base}/v1/chat/completions"
def _build_prompt(symbol: str, stats_row: dict | None) -> str:
lines = [
f"你是加密货币合约分析师。请根据附图({symbol} 近300日K+成交量)及数据给出中文简析。",
"关注:趋势、关键支撑阻力、成交量变化、资金费率含义、未来1-3日可能节奏。",
"控制在 200-350 字,条理清晰,不要废话。",
]
if stats_row:
t, y, b = stats_row.get("today", {}), stats_row.get("yesterday", {}), stats_row.get("daybefore", {})
lines.append(
f"\n三日均为成交额Top30交集:"
f"\n今日 排名{t.get('rank')} 涨跌{t.get('price_change_pct_fmt')}{t.get('quote_volume_fmt')}"
f"\n昨日 排名{y.get('rank')} 涨跌{y.get('price_change_pct_fmt')}{y.get('quote_volume_fmt')}"
f"\n前日 排名{b.get('rank')} 涨跌{b.get('price_change_pct_fmt')}{b.get('quote_volume_fmt')}"
f"\n资金费率(当前){t.get('funding_rate_fmt', '')}"
)
return "\n".join(lines)
async def interpret_symbol(
symbol: str,
stats_row: dict | None = None,
batch_id: str | None = None,
) -> str:
if not settings.llm_api_key.strip():
raise RuntimeError("LLM_API_KEY 未配置")
png = await render_daily_chart_png_async(symbol, settings.chart_kline_limit)
b64 = base64.standard_b64encode(png).decode("ascii")
prompt = _build_prompt(symbol, stats_row)
payload = {
"model": settings.llm_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
},
],
}
],
"max_tokens": 800,
"temperature": 0.4,
}
headers = {
"Authorization": f"Bearer {settings.llm_api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(_api_url(), json=payload, headers=headers)
if resp.status_code >= 400:
# 部分模型不支持 vision,降级纯文本
logger.warning("LLM vision failed %s, fallback text", resp.status_code)
payload["messages"] = [{"role": "user", "content": prompt + "\n(附图日K+成交量未能传入,请基于数据简析)"}]
resp = await client.post(_api_url(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
bid = batch_id or datetime.now().strftime("%Y-%m-%d-%H%M")
save_llm_interpretation(symbol, bid, content)
return content
async def run_interpretation_batch(
symbols: list[str] | None = None,
*,
batch_id: str | None = None,
) -> dict:
global _interpret_state
if _interpret_lock.locked():
return {"ok": False, "message": "解读任务进行中"}
stats = compute_three_day_stats()
if not stats.get("ok"):
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
sym_list = symbols or stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
if not sym_list:
return {"ok": False, "message": "三日交集为空"}
stats_map = {x["symbol"]: x for x in stats.get("items", [])}
bid = batch_id or datetime.now().strftime("%Y-%m-%d-%H%M")
interval = settings.llm_symbol_interval_sec
async with _interpret_lock:
_interpret_state.update(
{
"running": True,
"current_symbol": "",
"done": 0,
"total": len(sym_list),
"batch_id": bid,
"last_error": "",
}
)
for i, sym in enumerate(sym_list):
_interpret_state["current_symbol"] = sym
try:
await interpret_symbol(sym, stats_map.get(sym), bid)
logger.info("LLM interpreted %s (%d/%d)", sym, i + 1, len(sym_list))
except Exception as e:
_interpret_state["last_error"] = str(e)
logger.error("LLM %s failed: %s", sym, e)
save_llm_interpretation(sym, bid, f"[解读失败] {e}")
_interpret_state["done"] = i + 1
if i < len(sym_list) - 1:
await asyncio.sleep(interval)
_interpret_state["running"] = False
_interpret_state["current_symbol"] = ""
return {
"ok": True,
"batch_id": bid,
"count": len(sym_list),
"interval_sec": interval,
}
def schedule_interpret_background(symbols: list[str] | None = None) -> None:
"""后台启动解读,不阻塞请求。"""
async def _run():
try:
await run_interpretation_batch(symbols)
except Exception as e:
logger.error("Background LLM batch failed: %s", e)
asyncio.create_task(_run())
+48 -3
View File
@@ -2,17 +2,19 @@ import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from .config import ROOT_DIR, settings
from .funding_store import get_funding_bundle
from .kline_store import get_daily_candles, sync_daily_klines
from .db import get_latest_snapshot, init_db, log_push, save_snapshot
from .db import get_latest_snapshot, get_llm_interpretations, init_db, log_push, save_snapshot
from .exceptions import BinanceRateLimitedError
from .period_api import get_period_top30
from .periods import get_daybefore_period, get_today_period, get_yesterday_period
from .chart_image import render_daily_chart_png_async
from .llm_service import get_interpret_state, run_interpretation_batch
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
from .stats import compute_three_day_stats
from .aggregator import aggregate_period
@@ -165,6 +167,49 @@ async def api_funding_history(symbol: str, limit: int | None = None, refresh: bo
raise HTTPException(502, "资金费率获取失败") from e
@app.get("/api/chart/{symbol}/daily.png")
async def api_chart_daily_png(symbol: str, limit: int | None = None):
sym = symbol.upper().strip()
if not sym.endswith("USDT"):
raise HTTPException(400, "invalid symbol")
try:
png = await render_daily_chart_png_async(sym, limit or settings.chart_kline_limit)
return Response(content=png, media_type="image/png")
except ValueError as e:
raise HTTPException(404, str(e)) from e
except Exception as e:
logger.error("chart png %s failed: %s", sym, e)
raise HTTPException(502, "图表生成失败") from e
@app.get("/api/llm/status")
async def api_llm_status():
state = get_interpret_state()
return {
**state,
"enabled": bool(settings.llm_api_key.strip()),
"model": settings.llm_model,
"base_url": settings.llm_base_url,
"interval_sec": settings.llm_symbol_interval_sec,
}
@app.get("/api/llm/interpretations")
async def api_llm_interpretations(batch_id: str | None = None, limit: int = 50):
return {"items": get_llm_interpretations(batch_id, limit)}
@app.post("/api/llm/interpret/run")
async def api_llm_interpret_run(background_tasks: BackgroundTasks):
if not settings.llm_api_key.strip():
raise HTTPException(400, "LLM_API_KEY 未配置")
state = get_interpret_state()
if state.get("running"):
return {"ok": False, "message": "解读任务进行中", **state}
background_tasks.add_task(run_interpretation_batch)
return {"ok": True, "message": "已启动三日交集解读队列", **get_interpret_state()}
@app.post("/api/chart/{symbol}/daily/refresh")
async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
"""强制从币安同步日 K 到本地库。"""
+31 -5
View File
@@ -13,6 +13,8 @@ from .periods import get_daybefore_period, get_today_period, get_yesterday_perio
from .state import get_today_cache, set_today_cache
from .funding_store import prefetch_funding
from .kline_store import prefetch_symbols
from .llm_service import run_interpretation_batch, schedule_interpret_background
from .stats import compute_three_day_stats
from .wecom import build_markdown, send_wecom_markdown
logger = logging.getLogger(__name__)
@@ -147,6 +149,18 @@ async def job_refresh_today() -> None:
_restore_today_from_db()
async def job_llm_interpret() -> None:
"""08:05 对三日交集币种逐个大模型解读(每币间隔 3 分钟)。"""
logger.info("Job: LLM interpret three-day intersection")
if not settings.llm_api_key.strip():
logger.info("LLM_API_KEY not set, skip")
return
try:
await run_interpretation_batch()
except Exception as e:
logger.error("LLM job failed: %s", e)
async def startup_tasks() -> None:
init_db()
now = now_shanghai()
@@ -194,6 +208,12 @@ async def startup_tasks() -> None:
except Exception as e:
logger.error("Startup catch-up push failed: %s", e)
if settings.llm_api_key.strip() and settings.llm_auto_on_startup:
stats = compute_three_day_stats()
if stats.get("ok") and stats.get("symbols"):
logger.info("Startup: schedule one LLM interpret batch")
schedule_interpret_background()
def start_scheduler() -> None:
scheduler.add_job(
@@ -208,19 +228,25 @@ def start_scheduler() -> None:
id="push_wecom",
replace_existing=True,
)
refresh_hours = max(1, settings.refresh_minutes // 60)
scheduler.add_job(
job_refresh_today,
CronTrigger(minute=f"*/{settings.refresh_minutes}", timezone="Asia/Shanghai"),
CronTrigger(hour=f"*/{refresh_hours}", minute=0, timezone="Asia/Shanghai"),
id="refresh_today",
replace_existing=True,
)
scheduler.add_job(
job_llm_interpret,
CronTrigger(hour=8, minute=5, timezone="Asia/Shanghai"),
id="llm_interpret",
replace_existing=True,
)
if not scheduler.running:
scheduler.start()
logger.info(
"Scheduler started (today=%s, yesterday=%s, every %d min)",
settings.today_data_mode,
settings.yesterday_data_mode,
settings.refresh_minutes,
"Scheduler started (today every %dh, LLM 08:05, interval %ds)",
refresh_hours,
settings.llm_symbol_interval_sec,
)
+6 -15
View File
@@ -1,4 +1,4 @@
"""三日数据统计:连续三日 Top30 且 |涨跌|>=5%"""
"""三日数据统计:连续三日成交额 Top30 交集(不限制涨跌幅)"""
from typing import Any
@@ -15,7 +15,6 @@ def compute_three_day_stats() -> dict[str, Any]:
yesterday_snap = get_latest_snapshot("yesterday")
daybefore_snap = get_latest_snapshot("daybefore")
threshold = settings.change_threshold
top_n = settings.top_n
missing = []
@@ -31,7 +30,7 @@ def compute_three_day_stats() -> dict[str, Any]:
"ok": False,
"missing_periods": missing,
"message": f"缺少快照:{', '.join(missing)},请等待刷新或手动触发",
"criteria": _criteria_text(threshold, top_n),
"criteria": _criteria_text(top_n),
"count": 0,
"items": [],
"periods": _period_meta(today_snap, yesterday_snap, daybefore_snap),
@@ -46,12 +45,6 @@ def compute_three_day_stats() -> dict[str, Any]:
for sym in sorted(symbols):
t, y, b = today_map[sym], yesterday_map[sym], daybefore_map[sym]
if not (
abs(t.get("price_change_pct", 0)) >= threshold
and abs(y.get("price_change_pct", 0)) >= threshold
and abs(b.get("price_change_pct", 0)) >= threshold
):
continue
qualified.append(
{
"symbol": sym,
@@ -79,9 +72,10 @@ def compute_three_day_stats() -> dict[str, Any]:
return {
"ok": True,
"criteria": _criteria_text(threshold, top_n),
"criteria": _criteria_text(top_n),
"count": len(qualified),
"items": qualified,
"symbols": [q["symbol"] for q in qualified],
"periods": _period_meta(today_snap, yesterday_snap, daybefore_snap),
"summary": {
"today_top30": len(today_map),
@@ -92,11 +86,8 @@ def compute_three_day_stats() -> dict[str, Any]:
}
def _criteria_text(threshold: float, top_n: int) -> str:
return (
f"连续三日成交额 Top{top_n} 且每日 |涨跌幅| ≥ {threshold:g}%"
f"(今日/昨日/前日三个完整切日周期)"
)
def _criteria_text(top_n: int) -> str:
return f"连续三日成交额均位列 Top{top_n}(今日/昨日/前日交集,涨跌幅不限)"
def _pick_fields(row: dict) -> dict:
+1
View File
@@ -4,3 +4,4 @@ httpx[socks]>=0.27.0
apscheduler>=3.10.4
python-dotenv>=1.0.1
pydantic-settings>=2.6.0
matplotlib>=3.8.0
+151 -9
View File
@@ -1,5 +1,3 @@
const REFRESH_MS = 60_000;
const PERIOD_API = {
today: "/api/today/top30",
yesterday: "/api/yesterday/top30",
@@ -12,8 +10,13 @@ const tableState = {
daybefore: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" },
};
const PERIOD_LS_PREFIX = "ba_period_";
const PERIOD_TTL_MS = 4 * 60 * 60 * 1000;
let statsData = null;
let currentView = "today";
let llmPollTimer = null;
let llmInterpretMap = {};
const SORT_KEYS = {
rank: (r) => Number(r.rank) || 0,
@@ -171,15 +174,49 @@ function setPeriodData(periodId, data) {
renderPeriodTable(periodId);
}
async function loadPeriod(periodId) {
function loadPeriodFromLS(periodId) {
try {
const raw = localStorage.getItem(PERIOD_LS_PREFIX + periodId);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj?.data || Date.now() - (obj.ts || 0) > PERIOD_TTL_MS) return null;
return obj.data;
} catch {
return null;
}
}
function savePeriodToLS(periodId, data) {
try {
localStorage.setItem(
PERIOD_LS_PREFIX + periodId,
JSON.stringify({ ts: Date.now(), data })
);
} catch {
/* quota */
}
}
async function loadPeriod(periodId, force = false) {
const tbody = ensurePeriodTable(periodId);
if (!force) {
const cached = loadPeriodFromLS(periodId);
if (cached?.items?.length) {
setPeriodData(periodId, cached);
if (periodId === "today") {
document.getElementById("status").textContent = "今日数据(浏览器缓存)";
}
return;
}
}
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="loading">加载中…</td></tr>';
try {
const res = await fetch(PERIOD_API[periodId]);
const data = await res.json();
savePeriodToLS(periodId, data);
setPeriodData(periodId, data);
if (periodId === "today") {
document.getElementById("status").textContent = "今日数据已刷新";
document.getElementById("status").textContent = force ? "今日数据已手动刷新" : "今日数据已加载";
}
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" class="error">${e.message}</td></tr>`;
@@ -258,6 +295,7 @@ function renderStatsTable() {
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
<th>三日总成交额</th>
<th>AI解读</th>
</tr></thead>
<tbody id="stats-body"></tbody>
</table>`;
@@ -271,30 +309,128 @@ function renderStatsTable() {
const pct = x.price_change_pct ?? 0;
return `<td class="${f === "pct" ? pctClass(pct) : ""}">${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}</td>`;
};
const llm = llmInterpretMap[row.symbol];
const llmCell = llm
? `<details class="llm-inline"><summary>AI解读</summary><div class="llm-text">${escapeHtml(llm.content)}</div><small>${llm.created_at?.slice(0, 19) || ""}</small></details>`
: '<span class="muted">—</span>';
return `<tr class="row-highlight">
<td><strong>${row.symbol}</strong></td>
${cell("today", "rank")}${cell("today", "pct")}${cell("today", "vol")}
${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")}
${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")}
<td>${formatVol(row.total_quote_volume)}</td>
<td class="llm-col">${llmCell}</td>
</tr>`;
})
.join("");
}
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
}
function formatVol(v) {
if (v >= 1e8) return (v / 1e8).toFixed(2) + "亿";
if (v >= 1e4) return (v / 1e4).toFixed(2) + "万";
return String(Math.round(v));
}
async function loadLlmInterpretations() {
try {
const res = await fetch("/api/llm/interpretations");
const data = await res.json();
llmInterpretMap = {};
for (const item of data.items || []) {
llmInterpretMap[item.symbol] = item;
}
renderLlmList(data.items || []);
if (statsData?.ok) renderStatsTable();
} catch {
/* ignore */
}
}
function renderLlmList(items) {
const el = document.getElementById("llm-interpret-list");
if (!el) return;
if (!items.length) {
el.innerHTML = '<p class="loading">暂无解读记录</p>';
return;
}
el.innerHTML = items
.map(
(it) => `
<article class="llm-card">
<h4>${it.symbol} <small>${it.batch_id}</small></h4>
<div class="llm-text">${escapeHtml(it.content)}</div>
<time>${(it.created_at || "").replace("T", " ").slice(0, 19)}</time>
</article>`
)
.join("");
}
async function refreshLlmStatus() {
try {
const res = await fetch("/api/llm/status");
const st = await res.json();
const label = document.getElementById("llm-model-label");
const text = document.getElementById("llm-status-text");
if (label) label.textContent = st.enabled ? st.model : "未配置";
if (!text) return;
if (st.running) {
text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"} · 批次 ${st.batch_id}`;
if (!llmPollTimer) {
llmPollTimer = setInterval(async () => {
await refreshLlmStatus();
await loadLlmInterpretations();
if (!(await fetch("/api/llm/status").then((r) => r.json())).running) {
clearInterval(llmPollTimer);
llmPollTimer = null;
}
}, 5000);
}
} else {
text.textContent = st.enabled
? `就绪 · 每币 ${st.interval_sec}s · 最近批次 ${st.batch_id || "—"}`
: "请在 .env 配置 LLM_API_KEY";
if (llmPollTimer) {
clearInterval(llmPollTimer);
llmPollTimer = null;
}
}
} catch {
/* ignore */
}
}
async function runLlmInterpret() {
const btn = document.getElementById("btn-llm-run");
if (btn) btn.disabled = true;
try {
const res = await fetch("/api/llm/interpret/run", { method: "POST" });
const data = await res.json();
if (!data.ok) alert(data.message || "启动失败");
await refreshLlmStatus();
} catch (e) {
alert(e.message);
} finally {
if (btn) btn.disabled = false;
}
}
async function loadStats() {
document.getElementById("stats-table-wrap").innerHTML =
'<p class="loading">统计中…</p>';
try {
const res = await fetch("/api/stats/three-day");
statsData = await res.json();
await loadLlmInterpretations();
renderStatsTable();
await refreshLlmStatus();
} catch (e) {
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
}
@@ -330,6 +466,10 @@ function switchView(view) {
if (view === "stats") {
if (!statsData) loadStats();
else {
refreshLlmStatus();
loadLlmInterpretations();
}
return;
}
@@ -358,10 +498,16 @@ document.querySelectorAll("[data-reset]").forEach((btn) => {
document.getElementById("btn-refresh").addEventListener("click", async () => {
document.getElementById("status").textContent = "刷新中…";
await fetch("/api/refresh/today", { method: "POST" });
await loadPeriod("today");
await loadPeriod("today", true);
if (currentView === "stats") await loadStats();
});
document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret);
document.getElementById("btn-llm-refresh")?.addEventListener("click", async () => {
await loadLlmInterpretations();
await refreshLlmStatus();
});
document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
statsData = null;
loadStats();
@@ -371,7 +517,3 @@ document.getElementById("btn-export-stats")?.addEventListener("click", exportSta
loadPeriod("today");
loadPeriod("yesterday");
loadPeriod("daybefore");
setInterval(() => {
if (currentView === "today") loadPeriod("today");
}, REFRESH_MS);
+120 -32
View File
@@ -1,10 +1,13 @@
/** 日 K + 成交量(Canvas 高清) */
/** 日 K + 成交量(Canvas 高清)· 浏览器 localStorage 缓存 · 点击全屏 */
const chartDataCache = new Map();
const chartQueue = [];
let chartQueueRunning = false;
const CHART_FETCH_GAP_MS = 120;
const LS_KLINE_PREFIX = "ba_kline_";
const KLINE_TTL_MS = 60 * 60 * 1000;
const COLORS = {
bg: "#0d1118",
grid: "#2a3548",
@@ -16,7 +19,40 @@ const COLORS = {
};
const MINI_SIZE = { w: 380, h: 100 };
const MODAL_SIZE = { w: 1280, h: 720 };
function modalSize() {
const fs = document.fullscreenElement;
if (fs) {
return {
w: Math.max(800, window.innerWidth - 48),
h: Math.max(480, window.innerHeight - 100),
};
}
return { w: 1280, h: 720 };
}
function loadKlineFromLS(symbol) {
try {
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null;
return obj;
} catch {
return null;
}
}
function saveKlineToLS(symbol, candles, source) {
try {
localStorage.setItem(
LS_KLINE_PREFIX + symbol,
JSON.stringify({ ts: Date.now(), candles, source })
);
} catch {
/* quota */
}
}
function enqueueCharts(root) {
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
@@ -66,7 +102,7 @@ function drawCandlestickChart(canvas, candles, options = {}) {
if (!canvas || !candles.length) return;
const large = options.large === true;
const size = large ? MODAL_SIZE : MINI_SIZE;
const size = large ? modalSize() : MINI_SIZE;
const volRatio = large ? 0.22 : 0.32;
const pad = large
? { t: 16, r: 16, b: 28, l: 56 }
@@ -164,7 +200,7 @@ function drawCandlestickChart(canvas, candles, options = {}) {
function drawEmptyChart(canvas, large = false) {
if (!canvas) return;
const size = large ? MODAL_SIZE : MINI_SIZE;
const size = large ? modalSize() : MINI_SIZE;
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
ctx.fillStyle = "#1a2332";
ctx.fillRect(0, 0, w, h);
@@ -173,6 +209,32 @@ function drawEmptyChart(canvas, large = false) {
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
}
async function fetchKlines(symbol) {
let candles = chartDataCache.get(symbol);
let source = "memory";
if (candles) return { candles, source };
const ls = loadKlineFromLS(symbol);
if (ls) {
candles = ls.candles;
source = "browser";
chartDataCache.set(symbol, candles);
return { candles, source: ls.source || source };
}
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
candles = data.candles || [];
source = data.source || "db";
chartDataCache.set(symbol, candles);
saveKlineToLS(symbol, candles, source);
return { candles, source };
}
async function loadMiniChart(box) {
const symbol = box.dataset.symbol;
if (!symbol) return;
@@ -182,26 +244,22 @@ async function loadMiniChart(box) {
if (status) status.textContent = "加载…";
try {
let candles = chartDataCache.get(symbol);
let source = "cache";
if (!candles) {
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
candles = data.candles || [];
source = data.source || "db";
chartDataCache.set(symbol, candles);
}
const { candles, source } = await fetchKlines(symbol);
if (!candles.length) throw new Error("无K线数据");
drawCandlestickChart(canvas, candles, { large: false });
box.dataset.loaded = "1";
const srcLabel =
source === "db" ? "本地" : source === "db_stale" ? "本地(旧)" : source === "cache" ? "缓存" : "同步";
source === "browser"
? "浏览器"
: source === "db"
? "本地"
: source === "db_stale"
? "本地(旧)"
: source === "cache"
? "缓存"
: "同步";
if (status) status.textContent = `${candles.length}日·${srcLabel}`;
box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击放大`;
box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击全屏`;
} catch (e) {
if (status) status.textContent = "—";
box.title = `${symbol}: ${e.message}`;
@@ -211,6 +269,36 @@ async function loadMiniChart(box) {
}
}
let chartModalSymbol = "";
function closeChartModal() {
const modal = document.getElementById("chart-modal");
if (!modal) return;
modal.classList.add("hidden");
if (document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {});
}
}
function openChartModal(symbol) {
const candles = chartDataCache.get(symbol);
if (!candles?.length) return;
chartModalSymbol = symbol;
const modal = document.getElementById("chart-modal");
modal.classList.remove("hidden");
document.getElementById("chart-modal-title").textContent =
`${symbol} · 日K + 成交量(${candles.length} 根)`;
const canvas = document.getElementById("chart-modal-canvas");
drawCandlestickChart(canvas, candles, { large: true });
const inner = modal.querySelector(".chart-modal-inner");
const req = inner.requestFullscreen || inner.webkitRequestFullscreen;
if (req) {
req.call(inner).catch(() => {});
}
}
function setupChartModal() {
let modal = document.getElementById("chart-modal");
if (!modal) {
@@ -221,33 +309,33 @@ function setupChartModal() {
<div class="chart-modal-inner">
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
<h3 id="chart-modal-title"></h3>
<p class="chart-modal-hint">日K + 成交量 · 300根 · 滚轮可缩放页面</p>
<p class="chart-modal-hint">日K + 成交量 · 300根 · 点击全屏 · Esc 退出</p>
<div class="chart-modal-canvas-wrap">
<canvas id="chart-modal-canvas"></canvas>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelector(".chart-modal-close").onclick = () =>
modal.classList.add("hidden");
modal.querySelector(".chart-modal-close").onclick = closeChartModal;
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.add("hidden");
if (e.target === modal) closeChartModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") modal.classList.add("hidden");
if (e.key === "Escape") closeChartModal();
});
document.addEventListener("fullscreenchange", () => {
if (!chartModalSymbol) return;
const canvas = document.getElementById("chart-modal-canvas");
const candles = chartDataCache.get(chartModalSymbol);
if (canvas && candles?.length) {
drawCandlestickChart(canvas, candles, { large: true });
}
});
}
document.body.addEventListener("click", (e) => {
const box = e.target.closest(".mini-chart[data-symbol]");
if (!box || box.dataset.loaded !== "1") return;
const symbol = box.dataset.symbol;
const candles = chartDataCache.get(symbol);
if (!candles) return;
modal.classList.remove("hidden");
document.getElementById("chart-modal-title").textContent =
`${symbol} · 日K + 成交量(${candles.length} 根)`;
const canvas = document.getElementById("chart-modal-canvas");
drawCandlestickChart(canvas, candles, { large: true });
openChartModal(box.dataset.symbol);
});
}
+14 -2
View File
@@ -9,7 +9,7 @@
<body>
<header class="site-header">
<h1>币安 U本位合约 · 成交额排名</h1>
<p class="subtitle">北京时间 08:00 切日 · Top30 · 日K+成交量+资金费率</p>
<p class="subtitle">北京时间 08:00 切日 · Top30 · 今日每4小时自动刷新+手动 · 08:05 AI解读三日交集</p>
</header>
<nav class="main-nav" id="main-nav">
@@ -22,7 +22,7 @@
<main id="view-today" class="view-panel active">
<section class="panel">
<div class="panel-head">
<h2>今日周期 <span class="live">实时</span></h2>
<h2>今日周期 <span class="live">4h+手动</span></h2>
<span class="period" id="today-period"></span>
<span class="updated" id="today-updated"></span>
<div class="panel-actions">
@@ -78,6 +78,18 @@
<p class="stats-desc" id="stats-desc"></p>
<div class="table-wrap" id="stats-table-wrap"></div>
</section>
<section class="panel llm-panel">
<div class="panel-head">
<h2>大模型解读 <span class="llm-model" id="llm-model-label"></span></h2>
<span class="updated" id="llm-status-text"></span>
<div class="panel-actions">
<button type="button" class="btn-secondary" id="btn-llm-run">开始解读</button>
<button type="button" class="btn-secondary" id="btn-llm-refresh">刷新解读</button>
</div>
</div>
<p class="stats-desc">每日 08:05(北京时间)自动对「三日 Top30 交集」逐币解读,每币约 3 分钟;启动时也会自动跑一轮(需配置 LLM_API_KEY)。</p>
<div id="llm-interpret-list" class="llm-list"></div>
</section>
</main>
<footer>
+74
View File
@@ -339,6 +339,80 @@ button:hover {
overflow: auto;
}
.chart-modal-inner:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
display: flex;
flex-direction: column;
}
.chart-modal-inner:fullscreen .chart-modal-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.llm-panel {
margin-top: 1rem;
}
.llm-model {
font-size: 0.75rem;
color: var(--accent);
font-weight: normal;
}
.llm-list {
display: grid;
gap: 0.75rem;
max-height: 420px;
overflow-y: auto;
}
.llm-card {
background: #121a26;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.llm-card h4 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.llm-card h4 small {
color: var(--muted);
font-weight: normal;
}
.llm-text {
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
white-space: pre-wrap;
}
.llm-inline summary {
cursor: pointer;
color: var(--accent);
font-size: 0.85rem;
}
.llm-col {
max-width: 280px;
font-size: 0.82rem;
}
.muted {
color: var(--muted);
}
.chart-modal-inner h3 {
margin: 0 0 0.25rem;
font-size: 1.15rem;