feat: rate_snapshots 2s persistent storage + /live realtime chart page

This commit is contained in:
root 2026-02-27 05:47:26 +00:00
parent cf531d8c44
commit 8efa6ede32
3 changed files with 210 additions and 1 deletions

View File

@ -2,7 +2,7 @@ from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import httpx import httpx
from datetime import datetime, timedelta from datetime import datetime, timedelta
import asyncio, time import asyncio, time, sqlite3, os
app = FastAPI(title="Arbitrage Engine API") app = FastAPI(title="Arbitrage Engine API")
@ -16,6 +16,7 @@ app.add_middleware(
BINANCE_FAPI = "https://fapi.binance.com/fapi/v1" BINANCE_FAPI = "https://fapi.binance.com/fapi/v1"
SYMBOLS = ["BTCUSDT", "ETHUSDT"] SYMBOLS = ["BTCUSDT", "ETHUSDT"]
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "arb.db")
# 简单内存缓存history/stats 60秒rates 3秒 # 简单内存缓存history/stats 60秒rates 3秒
_cache: dict = {} _cache: dict = {}
@ -30,6 +31,53 @@ def set_cache(key: str, data):
_cache[key] = {"ts": time.time(), "data": data} _cache[key] = {"ts": time.time(), "data": data}
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS rate_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
btc_rate REAL NOT NULL,
eth_rate REAL NOT NULL,
btc_price REAL NOT NULL,
eth_price REAL NOT NULL,
btc_index_price REAL,
eth_index_price REAL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_snapshots_ts ON rate_snapshots(ts)")
conn.commit()
conn.close()
def save_snapshot(rates: dict):
try:
conn = sqlite3.connect(DB_PATH)
btc = rates.get("BTC", {})
eth = rates.get("ETH", {})
conn.execute(
"INSERT INTO rate_snapshots (ts, btc_rate, eth_rate, btc_price, eth_price, btc_index_price, eth_index_price) VALUES (?,?,?,?,?,?,?)",
(
int(time.time()),
float(btc.get("lastFundingRate", 0)),
float(eth.get("lastFundingRate", 0)),
float(btc.get("markPrice", 0)),
float(eth.get("markPrice", 0)),
float(btc.get("indexPrice", 0)),
float(eth.get("indexPrice", 0)),
)
)
conn.commit()
conn.close()
except Exception as e:
pass # 落库失败不影响API响应
@app.on_event("startup")
async def startup():
init_db()
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
@ -57,6 +105,28 @@ async def get_rates():
"timestamp": data["time"], "timestamp": data["time"],
} }
set_cache("rates", result) set_cache("rates", result)
# 异步落库(不阻塞响应)
asyncio.create_task(asyncio.to_thread(save_snapshot, result))
return result
@app.get("/api/snapshots")
async def get_snapshots(hours: int = 24, limit: int = 5000):
"""查询本地落库的实时快照数据"""
since = int(time.time()) - hours * 3600
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT ts, btc_rate, eth_rate, btc_price, eth_price FROM rate_snapshots WHERE ts >= ? ORDER BY ts ASC LIMIT ?",
(since, limit)
).fetchall()
conn.close()
return {
"count": len(rows),
"hours": hours,
"data": [dict(r) for r in rows]
}
set_cache("rates", result)
return result return result

138
frontend/app/live/page.tsx Normal file
View File

@ -0,0 +1,138 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
LineChart, Line, XAxis, YAxis, Tooltip, Legend,
ResponsiveContainer, ReferenceLine, CartesianGrid
} from "recharts";
interface Snapshot {
ts: number;
btc_rate: number;
eth_rate: number;
btc_price: number;
eth_price: number;
}
interface ChartPoint {
time: string;
btcRate: number;
ethRate: number;
btcPrice: number;
ethPrice: number;
}
export default function LivePage() {
const [data, setData] = useState<ChartPoint[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [hours, setHours] = useState(2);
const fetchSnapshots = useCallback(async () => {
try {
const r = await fetch(`/api/snapshots?hours=${hours}&limit=3600`);
const json = await r.json();
const rows: Snapshot[] = json.data || [];
setCount(json.count || 0);
// 降采样每30条取1条避免图表过密
const step = Math.max(1, Math.floor(rows.length / 300));
const sampled = rows.filter((_, i) => i % step === 0);
setData(sampled.map(row => ({
time: new Date(row.ts * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
btcRate: parseFloat((row.btc_rate * 100).toFixed(5)),
ethRate: parseFloat((row.eth_rate * 100).toFixed(5)),
btcPrice: row.btc_price,
ethPrice: row.eth_price,
})));
} catch { /* ignore */ } finally {
setLoading(false);
}
}, [hours]);
useEffect(() => {
fetchSnapshots();
const iv = setInterval(fetchSnapshots, 10_000);
return () => clearInterval(iv);
}, [fetchSnapshots]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-900"></h1>
<p className="text-slate-500 text-sm mt-1">
8线 · <span className="text-blue-600 font-medium">{count.toLocaleString()}</span>
</p>
</div>
<div className="flex gap-2 text-sm">
{[1, 2, 6, 12, 24].map(h => (
<button
key={h}
onClick={() => setHours(h)}
className={`px-3 py-1.5 rounded-lg border transition-colors ${hours === h ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}
>
{h}h
</button>
))}
</div>
</div>
{loading ? (
<div className="text-slate-400 py-12 text-center">...</div>
) : data.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-12 text-center text-slate-400">
2
</div>
) : (
<>
{/* 费率图 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
<h2 className="text-slate-700 font-semibold mb-1"></h2>
<p className="text-slate-400 text-xs mb-4">===</p>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false}
tickFormatter={v => `${v.toFixed(3)}%`} width={60} />
<Tooltip formatter={(v: number) => [`${v.toFixed(5)}%`]}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<Line type="monotone" dataKey="btcRate" name="BTC费率" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
<Line type="monotone" dataKey="ethRate" name="ETH费率" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
{/* 价格图 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
<h2 className="text-slate-700 font-semibold mb-1"></h2>
<p className="text-slate-400 text-xs mb-4"></p>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<YAxis yAxisId="btc" orientation="left" tick={{ fill: "#2563eb", fontSize: 10 }} tickLine={false} axisLine={false}
tickFormatter={v => `$${(v/1000).toFixed(0)}k`} width={55} />
<YAxis yAxisId="eth" orientation="right" tick={{ fill: "#7c3aed", fontSize: 10 }} tickLine={false} axisLine={false}
tickFormatter={v => `$${v.toFixed(0)}`} width={55} />
<Tooltip formatter={(v: number, name: string) => [name.includes("BTC") ? `$${v.toLocaleString()}` : `$${v.toFixed(2)}`, name]}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line yAxisId="btc" type="monotone" dataKey="btcPrice" name="BTC价格" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
<Line yAxisId="eth" type="monotone" dataKey="ethPrice" name="ETH价格" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
{/* 说明 */}
<div className="rounded-lg border border-blue-100 bg-blue-50 px-5 py-3 text-sm text-slate-600">
<span className="text-blue-600 font-medium"></span>
2Binance拉取实时溢价指数8
</div>
</>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useState } from "react";
const navLinks = [ const navLinks = [
{ href: "/", label: "仪表盘" }, { href: "/", label: "仪表盘" },
{ href: "/live", label: "实时变动" },
{ href: "/history", label: "历史" }, { href: "/history", label: "历史" },
{ href: "/signals", label: "信号" }, { href: "/signals", label: "信号" },
{ href: "/about", label: "说明" }, { href: "/about", label: "说明" },