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
import httpx
from datetime import datetime, timedelta
import asyncio, time
import asyncio, time, sqlite3, os
app = FastAPI(title="Arbitrage Engine API")
@ -16,6 +16,7 @@ app.add_middleware(
BINANCE_FAPI = "https://fapi.binance.com/fapi/v1"
SYMBOLS = ["BTCUSDT", "ETHUSDT"]
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秒
_cache: dict = {}
@ -30,6 +31,53 @@ def set_cache(key: str, 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")
async def health():
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
@ -57,6 +105,28 @@ async def get_rates():
"timestamp": data["time"],
}
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

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 = [
{ href: "/", label: "仪表盘" },
{ href: "/live", label: "实时变动" },
{ href: "/history", label: "历史" },
{ href: "/signals", label: "信号" },
{ href: "/about", label: "说明" },