增加大模型
This commit is contained in:
+11
-1
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 环境要求
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 到本地库。"""
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user