From 27031ab67603aa9fa4215229e0e13d934b8dfe7f Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 26 May 2026 09:38:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=A7=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 ++- README.md | 4 +- backend/app/chart_image.py | 66 ++++++++++++++ backend/app/config.py | 7 +- backend/app/db.py | 92 ++++++++++++++++++++ backend/app/llm_service.py | 174 +++++++++++++++++++++++++++++++++++++ backend/app/main.py | 51 ++++++++++- backend/app/scheduler.py | 36 ++++++-- backend/app/stats.py | 21 ++--- backend/requirements.txt | 1 + web/app.js | 160 ++++++++++++++++++++++++++++++++-- web/charts.js | 152 +++++++++++++++++++++++++------- web/index.html | 16 +++- web/style.css | 74 ++++++++++++++++ 14 files changed, 797 insertions(+), 69 deletions(-) create mode 100644 backend/app/chart_image.py create mode 100644 backend/app/llm_service.py diff --git a/.env.example b/.env.example index 89a9845..c550e87 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d45a910..67b1b26 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ - 成交额排名 Top30(USDT 计价) - 高亮标记(不改变排名):成交额 ≥ 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` ## 环境要求 diff --git a/backend/app/chart_image.py b/backend/app/chart_image.py new file mode 100644 index 0000000..cbfad88 --- /dev/null +++ b/backend/app/chart_image.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index 201427d..c64b712 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db.py b/backend/app/db.py index 973551b..05c2c7d 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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( diff --git a/backend/app/llm_service.py b/backend/app/llm_service.py new file mode 100644 index 0000000..fb95a74 --- /dev/null +++ b/backend/app/llm_service.py @@ -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()) diff --git a/backend/app/main.py b/backend/app/main.py index 1792e1d..92ea5ef 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 到本地库。""" diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 21224b4..d0ab7a1 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -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, ) diff --git a/backend/app/stats.py b/backend/app/stats.py index 8176460..27afee4 100644 --- a/backend/app/stats.py +++ b/backend/app/stats.py @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt index a66c0f3..7f9a05e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/web/app.js b/web/app.js index e85ca03..ac61efd 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = '加载中…'; 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 = `${e.message}`; @@ -258,6 +295,7 @@ function renderStatsTable() { 昨日排名昨日涨跌昨日成交额 前日排名前日涨跌前日成交额 三日总成交额 + AI解读 `; @@ -271,30 +309,128 @@ function renderStatsTable() { const pct = x.price_change_pct ?? 0; return `${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}`; }; + const llm = llmInterpretMap[row.symbol]; + const llmCell = llm + ? `
AI解读
${escapeHtml(llm.content)}
${llm.created_at?.slice(0, 19) || ""}
` + : ''; return ` ${row.symbol} ${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")} ${formatVol(row.total_quote_volume)} + ${llmCell} `; }) .join(""); } +function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
"); +} + 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 = '

暂无解读记录

'; + return; + } + el.innerHTML = items + .map( + (it) => ` +
+

${it.symbol} ${it.batch_id}

+
${escapeHtml(it.content)}
+ +
` + ) + .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 = '

统计中…

'; 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 = `

${e.message}

`; } @@ -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); diff --git a/web/charts.js b/web/charts.js index 99698e1..d30d917 100644 --- a/web/charts.js +++ b/web/charts.js @@ -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() {

-

日K + 成交量 · 300根 · 滚轮可缩放页面

+

日K + 成交量 · 300根 · 点击全屏 · Esc 退出

`; 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); }); } diff --git a/web/index.html b/web/index.html index 550f8ba..c30d6aa 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@