feat: /live 实盘交易前端页面
- 风控状态面板: 实时显示(正常/警告/熔断)、已实现R+未实现R+合计、连亏次数 - 紧急操作: 全平(双重确认)、禁止开仓、恢复交易 - 总览卡片: 盈亏R+USDT、胜率、持仓数、PF、手续费、资金费 - 当前持仓: WebSocket实时价格、滑点/裸奔/延迟指标、OrderID - 权益曲线: Recharts AreaChart - 历史交易: 含成交价/滑点/费用列、币种/盈亏筛选 - 详细统计: 滑点P50/P95/均值、按币种分组 - 导航栏: 新增实盘入口(Bolt图标) 风格与模拟盘一致: 白底+slate+emerald/red配色
This commit is contained in:
parent
832f78a1d7
commit
1ef1f97b5d
@ -1,130 +1,345 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine, CartesianGrid
|
||||
} from "recharts";
|
||||
|
||||
interface ChartPoint {
|
||||
time: string;
|
||||
btcRate: number;
|
||||
ethRate: number;
|
||||
btcPrice: number;
|
||||
ethPrice: number;
|
||||
function bjt(ms: number) {
|
||||
const d = new Date(ms + 8 * 3600 * 1000);
|
||||
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
function fmtPrice(p: number) {
|
||||
return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
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 json = await api.snapshots(hours, 3600);
|
||||
const rows = 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]);
|
||||
const LIVE_STRATEGY = "v52_8signals";
|
||||
|
||||
// ─── 风控状态 ────────────────────────────────────────────────────
|
||||
function RiskStatusPanel() {
|
||||
const [risk, setRisk] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
fetchSnapshots();
|
||||
const iv = setInterval(fetchSnapshots, 10_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchSnapshots]);
|
||||
|
||||
const f = async () => { try { const r = await authFetch("/api/live/risk-status"); if (r.ok) setRisk(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!risk) return null;
|
||||
const statusColor = risk.status === "normal" ? "border-emerald-400 bg-emerald-50" : risk.status === "warning" ? "border-amber-400 bg-amber-50" : risk.status === "circuit_break" ? "border-red-400 bg-red-50" : "border-slate-200 bg-slate-50";
|
||||
const statusIcon = risk.status === "normal" ? "🟢" : risk.status === "warning" ? "🟡" : risk.status === "circuit_break" ? "🔴" : "⚪";
|
||||
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 className={`rounded-xl border-2 ${statusColor} px-4 py-3`}>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{statusIcon}</span>
|
||||
<div>
|
||||
<span className="font-bold text-sm text-slate-800">风控: {risk.status === "normal" ? "正常" : risk.status === "warning" ? "警告" : risk.status === "circuit_break" ? "熔断中" : "未知"}</span>
|
||||
{risk.circuit_break_reason && <p className="text-[10px] text-red-600 mt-0.5">{risk.circuit_break_reason}</p>}
|
||||
</div>
|
||||
</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 className="flex gap-4 text-[11px] font-mono">
|
||||
<div><span className="text-slate-400">已实现</span><p className={`font-bold ${(risk.today_realized_r||0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>{(risk.today_realized_r||0) >= 0 ? "+" : ""}{risk.today_realized_r||0}R</p></div>
|
||||
<div><span className="text-slate-400">未实现</span><p className={`font-bold ${(risk.today_unrealized_r||0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>{(risk.today_unrealized_r||0) >= 0 ? "+" : ""}{risk.today_unrealized_r||0}R</p></div>
|
||||
<div><span className="text-slate-400">合计</span><p className={`font-bold ${(risk.today_total_r||0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>{(risk.today_total_r||0) >= 0 ? "+" : ""}{risk.today_total_r||0}R</p></div>
|
||||
<div><span className="text-slate-400">连亏</span><p className="font-bold text-slate-800">{risk.consecutive_losses||0}次</p></div>
|
||||
</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秒后开始积累)
|
||||
{(risk.block_new_entries || risk.reduce_only) && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
{risk.block_new_entries && <span className="text-[10px] px-2 py-0.5 rounded bg-red-100 text-red-700 font-medium">🚫 禁止新开仓</span>}
|
||||
{risk.reduce_only && <span className="text-[10px] px-2 py-0.5 rounded bg-red-100 text-red-700 font-medium">🔒 只减仓</span>}
|
||||
</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, name) => [name?.toString().includes("BTC") ? `$${Number(v).toLocaleString()}` : `$${Number(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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 紧急操作 ────────────────────────────────────────────────────
|
||||
function EmergencyPanel() {
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
const doAction = async (action: string) => {
|
||||
try { const r = await authFetch(`/api/live/${action}`, { method: "POST" }); const j = await r.json(); setMsg(j.message || j.error || "已执行"); setConfirming(null); setTimeout(() => setMsg(""), 5000); } catch { setMsg("操作失败"); }
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">⚡ 紧急操作</h3>
|
||||
<div className="flex gap-2">
|
||||
{confirming === "emergency-close" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-red-600 font-medium">确认全平?</span>
|
||||
<button onClick={() => doAction("emergency-close")} className="px-2 py-1 rounded text-[10px] font-bold bg-red-600 text-white">确认</button>
|
||||
<button onClick={() => setConfirming(null)} className="px-2 py-1 rounded text-[10px] bg-slate-200 text-slate-600">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirming("emergency-close")} className="px-3 py-1.5 rounded-lg text-[11px] font-bold bg-red-500 text-white hover:bg-red-600">🔴 紧急全平</button>
|
||||
)}
|
||||
{confirming === "block-new" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-amber-600 font-medium">确认?</span>
|
||||
<button onClick={() => doAction("block-new")} className="px-2 py-1 rounded text-[10px] font-bold bg-amber-500 text-white">确认</button>
|
||||
<button onClick={() => setConfirming(null)} className="px-2 py-1 rounded text-[10px] bg-slate-200 text-slate-600">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirming("block-new")} className="px-3 py-1.5 rounded-lg text-[11px] font-bold bg-amber-500 text-white hover:bg-amber-600">🟡 禁止开仓</button>
|
||||
)}
|
||||
<button onClick={() => doAction("resume")} className="px-3 py-1.5 rounded-lg text-[11px] font-bold bg-emerald-500 text-white hover:bg-emerald-600">✅ 恢复交易</button>
|
||||
</div>
|
||||
</div>
|
||||
{msg && <p className="text-[10px] text-blue-600 mt-1">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 总览 ────────────────────────────────────────────────────────
|
||||
function SummaryCards() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/summary?strategy=${LIVE_STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-4">加载中...</div>;
|
||||
return (
|
||||
<div className="grid grid-cols-3 lg:grid-cols-7 gap-1.5">
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">总盈亏(R)</p>
|
||||
<p className={`font-mono font-bold text-lg ${data.total_pnl_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl_r >= 0 ? "+" : ""}{data.total_pnl_r}R</p>
|
||||
<p className={`font-mono text-[10px] ${data.total_pnl_usdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"><p className="text-[10px] text-slate-400">胜率</p><p className="font-mono font-bold text-lg text-slate-800">{data.win_rate}%</p></div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"><p className="text-[10px] text-slate-400">总交易</p><p className="font-mono font-bold text-lg text-slate-800">{data.total_trades}</p></div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"><p className="text-[10px] text-slate-400">持仓中</p><p className="font-mono font-bold text-lg text-blue-600">{data.active_positions}</p></div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"><p className="text-[10px] text-slate-400">盈亏比(PF)</p><p className="font-mono font-bold text-lg text-slate-800">{data.profit_factor}</p></div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"><p className="text-[10px] text-slate-400">手续费</p><p className="font-mono font-bold text-sm text-amber-600">${data.total_fee_usdt}</p></div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"><p className="text-[10px] text-slate-400">资金费</p><p className="font-mono font-bold text-sm text-violet-600">${data.total_funding_usdt}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 当前持仓 ────────────────────────────────────────────────────
|
||||
function ActivePositions() {
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/positions?strategy=${LIVE_STRATEGY}`); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} };
|
||||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map(s => `${s}@aggTrade`).join("/");
|
||||
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
|
||||
ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.data) { setWsPrices(prev => ({ ...prev, [msg.data.s]: parseFloat(msg.data.p) })); } } catch {} };
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
|
||||
if (positions.length === 0) return <div className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-center text-slate-400 text-sm">暂无活跃持仓</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">当前持仓 <span className="text-[10px] text-emerald-500 font-normal">● 实时</span> <span className="text-[10px] text-slate-400 font-normal ml-2">币安合约</span></h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{positions.map((p: any) => {
|
||||
const sym = p.symbol?.replace("USDT", "") || "";
|
||||
const holdMin = p.hold_time_min || Math.round((Date.now() - p.entry_ts) / 60000);
|
||||
const currentPrice = wsPrices[p.symbol] || p.current_price || 0;
|
||||
const entry = p.entry_price || 0;
|
||||
const rd = p.risk_distance || 1;
|
||||
const fullR = rd > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / rd : (entry - currentPrice) / rd) : 0;
|
||||
const tp1R = rd > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / rd : (entry - (p.tp1_price || 0)) / rd) : 0;
|
||||
const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
|
||||
const unrealUsdt = unrealR * 2;
|
||||
const holdColor = holdMin >= 60 ? "text-red-500 font-bold" : holdMin >= 45 ? "text-amber-500" : "text-slate-400";
|
||||
return (
|
||||
<div key={p.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-bold ${p.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>{p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction}</span>
|
||||
<span className="text-[10px] text-slate-400">评分{p.score} · {p.tier === "heavy" ? "加仓" : "标准"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm font-bold ${unrealR >= 0 ? "text-emerald-600" : "text-red-500"}`}>{unrealR >= 0 ? "+" : ""}{unrealR.toFixed(2)}R</span>
|
||||
<span className={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(2)})</span>
|
||||
<span className={`text-[10px] ${holdColor}`}>{holdMin}m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap">
|
||||
<span>入场: ${fmtPrice(entry)}</span>
|
||||
<span>成交: ${fmtPrice(p.fill_price || entry)}</span>
|
||||
<span className="text-blue-600">现价: ${currentPrice ? fmtPrice(currentPrice) : "-"}</span>
|
||||
<span className="text-emerald-600">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span>
|
||||
<span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span>
|
||||
<span className="text-red-500">SL: ${fmtPrice(p.sl_price)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 flex-wrap">
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded ${Math.abs(p.slippage_bps||0)>8?"bg-red-50 text-red-700":Math.abs(p.slippage_bps||0)>2.5?"bg-amber-50 text-amber-700":"bg-emerald-50 text-emerald-700"}`}>滑点 {(p.slippage_bps||0).toFixed(1)}bps</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded ${(p.protection_gap_ms||0)>5000?"bg-red-50 text-red-700":(p.protection_gap_ms||0)>2000?"bg-amber-50 text-amber-700":"bg-emerald-50 text-emerald-700"}`}>裸奔 {p.protection_gap_ms||0}ms</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">信号→下单 {p.signal_to_order_ms||0}ms</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">下单→成交 {p.order_to_fill_ms||0}ms</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">#{p.binance_order_id||"-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
function EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/equity-curve?strategy=${LIVE_STRATEGY}`); if (r.ok) { const j = await r.json(); setData(j.data || []); } } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (data.length < 2) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100"><h3 className="font-semibold text-slate-800 text-xs">权益曲线 (累计PnL)</h3></div>
|
||||
<div className="p-2" style={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<XAxis dataKey="ts" tickFormatter={(v) => bjt(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 历史交易 ────────────────────────────────────────────────────
|
||||
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
|
||||
type FilterResult = "all" | "win" | "loss";
|
||||
|
||||
function TradeHistory() {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FilterSymbol>("all");
|
||||
const [result, setResult] = useState<FilterResult>("all");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/trades?symbol=${symbol}&result=${result}&strategy=${LIVE_STRATEGY}&limit=50`); if (r.ok) { const j = await r.json(); setTrades(j.data || []); } } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, [symbol, result]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">历史交易</h3>
|
||||
<div className="flex gap-1">
|
||||
{(["all","BTC","ETH","XRP","SOL"] as FilterSymbol[]).map(s => (<button key={s} onClick={() => setSymbol(s)} className={`px-2 py-0.5 rounded text-[10px] ${symbol === s ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}>{s === "all" ? "全部" : s}</button>))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all","win","loss"] as FilterResult[]).map(r => (<button key={r} onClick={() => setResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${result === r ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}>{r === "all" ? "全部" : r === "win" ? "盈利" : "亏损"}</button>))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{trades.length === 0 ? <div className="text-center text-slate-400 text-sm py-6">暂无交易记录</div> : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="bg-slate-50 sticky top-0"><tr className="text-slate-500">
|
||||
<th className="px-2 py-1.5 text-left font-medium">币种</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">方向</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">入场</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">成交</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">出场</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">PnL(R)</th>
|
||||
<th className="px-2 py-1.5 text-center font-medium">状态</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">滑点</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">费用</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">时间</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{trades.map((t: any) => {
|
||||
const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0;
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-slate-50">
|
||||
<td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT","")}</td>
|
||||
<td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{t.fill_price ? fmtPrice(t.fill_price) : "-"}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{t.exit_price ? fmtPrice(t.exit_price) : "-"}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono font-bold ${(t.pnl_r||0) > 0 ? "text-emerald-600" : (t.pnl_r||0) < 0 ? "text-red-500" : "text-slate-500"}`}>{(t.pnl_r||0) > 0 ? "+" : ""}{(t.pnl_r||0).toFixed(2)}</td>
|
||||
<td className="px-2 py-1.5 text-center"><span className={`px-1 py-0.5 rounded text-[9px] ${t.status==="tp"?"bg-emerald-100 text-emerald-700":t.status==="sl"?"bg-red-100 text-red-700":t.status==="sl_be"?"bg-amber-100 text-amber-700":"bg-slate-100 text-slate-600"}`}>{t.status==="tp"?"止盈":t.status==="sl"?"止损":t.status==="sl_be"?"保本":t.status}</span></td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono ${Math.abs(t.slippage_bps||0)>8?"text-red-500":Math.abs(t.slippage_bps||0)>2.5?"text-amber-500":"text-slate-600"}`}>{(t.slippage_bps||0).toFixed(1)}bps</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono text-amber-600">${(t.fee_usdt||0).toFixed(2)}</td>
|
||||
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 详细统计 ────────────────────────────────────────────────────
|
||||
function StatsPanel() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/stats?strategy=${LIVE_STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data || data.error) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100"><h3 className="font-semibold text-slate-800 text-xs">详细统计</h3></div>
|
||||
<div className="p-3 grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs">
|
||||
<div><span className="text-slate-400">胜率</span><p className="font-mono font-bold">{data.win_rate}%</p></div>
|
||||
<div><span className="text-slate-400">盈亏比</span><p className="font-mono font-bold">{data.win_loss_ratio}</p></div>
|
||||
<div><span className="text-slate-400">平均盈利</span><p className="font-mono font-bold text-emerald-600">+{data.avg_win}R</p></div>
|
||||
<div><span className="text-slate-400">平均亏损</span><p className="font-mono font-bold text-red-500">-{data.avg_loss}R</p></div>
|
||||
<div><span className="text-slate-400">最大回撤</span><p className="font-mono font-bold">{data.mdd}R</p></div>
|
||||
<div><span className="text-slate-400">总盈亏</span><p className={`font-mono font-bold ${data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl >= 0 ? "+" : ""}{data.total_pnl}R</p></div>
|
||||
<div><span className="text-slate-400">总笔数</span><p className="font-mono font-bold">{data.total}</p></div>
|
||||
<div><span className="text-slate-400">滑点P50</span><p className="font-mono font-bold">{data.p50_slippage_bps}bps</p></div>
|
||||
<div><span className="text-slate-400">滑点P95</span><p className="font-mono font-bold">{data.p95_slippage_bps}bps</p></div>
|
||||
<div><span className="text-slate-400">平均滑点</span><p className="font-mono font-bold">{data.avg_slippage_bps}bps</p></div>
|
||||
</div>
|
||||
{data.by_symbol && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-[10px] text-slate-400 mb-1">按币种</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{Object.entries(data.by_symbol).map(([sym, v]: [string, any]) => (
|
||||
<div key={sym} className="rounded-lg bg-slate-50 px-2 py-1.5 text-[11px]">
|
||||
<span className="font-mono font-bold">{sym.replace("USDT","")}</span>
|
||||
<span className="text-slate-400 ml-1">{v.total}笔 {v.win_rate}%</span>
|
||||
<span className={`ml-1 font-mono font-bold ${v.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>{v.total_pnl >= 0 ? "+" : ""}{v.total_pnl}R</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主页面 ──────────────────────────────────────────────────────
|
||||
export default function LiveTradingPage() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
if (loading) return <div className="text-center text-slate-400 py-8">加载中...</div>;
|
||||
if (!isLoggedIn) return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<p className="text-slate-600 font-medium">请先登录查看实盘</p>
|
||||
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">登录</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">⚡ 实盘交易</h1>
|
||||
<p className="text-[10px] text-slate-500">V5.2策略 · 币安USDT永续合约 · 测试网</p>
|
||||
</div>
|
||||
<RiskStatusPanel />
|
||||
<EmergencyPanel />
|
||||
<SummaryCards />
|
||||
<ActivePositions />
|
||||
<EquityCurve />
|
||||
<TradeHistory />
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,12 +7,13 @@ import { useAuth } from "@/lib/auth";
|
||||
import {
|
||||
LayoutDashboard, Info,
|
||||
Menu, X, Zap, LogIn, UserPlus,
|
||||
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles
|
||||
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles, Bolt
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
|
||||
{ href: "/trades", label: "成交流", icon: Activity },
|
||||
{ href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" },
|
||||
{ href: "/signals", label: "V5.1 信号引擎", icon: Crosshair, section: "── V5.1 ──" },
|
||||
{ href: "/paper", label: "V5.1 模拟盘", icon: LineChart },
|
||||
{ href: "/signals-v52", label: "V5.2 信号引擎", icon: Sparkles, section: "── V5.2 ──" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user