feat: rate_snapshots 2s persistent storage + /live realtime chart page
This commit is contained in:
parent
cf531d8c44
commit
8efa6ede32
@ -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
138
frontend/app/live/page.tsx
Normal 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>
|
||||||
|
每2秒从Binance拉取实时溢价指数,本地永久存储。这是8小时结算周期内费率变动的原始数据,不在任何公开数据源中提供。
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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: "说明" },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user