增加大模型

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
+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