后端: - /api/live/trades 返回 gross_pnl_r, fee_r, funding_r, slippage_r, net_pnl_r - gross = 方向盈亏(含TP1半仓锁定) - fee_r = 实际手续费/risk_usd - funding_r = 不利资金费/risk_usd - slippage_r = 滑点损失估算 - net = pnl_r(已是净值) 前端L10: - 表头改为: Gross | Fee | FR | Slip | Net - 颜色: gross绿/红, fee橙, FR紫, slip灰, net加粗绿/红 - 每笔交易一目了然:赚了多少、扣了多少、净剩多少
606 lines
37 KiB
TypeScript
606 lines
37 KiB
TypeScript
"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";
|
||
|
||
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 }); }
|
||
function fmtMs(ms: number) { return ms > 999 ? `${(ms/1000).toFixed(1)}s` : `${ms}ms`; }
|
||
|
||
const LIVE_STRATEGY = "v52_8signals";
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L0: 顶部固定风险条(sticky,永远可见)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L0_RiskBar() {
|
||
const [risk, setRisk] = useState<any>(null);
|
||
const [recon, setRecon] = useState<any>(null);
|
||
const [account, setAccount] = useState<any>(null);
|
||
useEffect(() => {
|
||
const f = async () => {
|
||
try { const r = await authFetch("/api/live/risk-status"); if (r.ok) setRisk(await r.json()); } catch {}
|
||
try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setRecon(await r.json()); } catch {}
|
||
try { const r = await authFetch("/api/live/account"); if (r.ok) setAccount(await r.json()); } catch {}
|
||
};
|
||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||
}, []);
|
||
|
||
const riskStatus = risk?.status || "unknown";
|
||
const riskColor = riskStatus === "normal" ? "bg-emerald-500" : riskStatus === "circuit_break" ? "bg-red-500" : "bg-amber-500";
|
||
const reconOk = recon?.status === "ok";
|
||
const totalR = (risk?.today_realized_r || 0) + Math.min(risk?.today_unrealized_r || 0, 0);
|
||
const rBudgetPct = Math.abs(totalR / 5 * 100); // -5R日限
|
||
const rBudgetColor = rBudgetPct >= 100 ? "text-red-500" : rBudgetPct >= 80 ? "text-amber-500" : "text-emerald-500";
|
||
|
||
return (
|
||
<div className="sticky top-0 z-50 bg-slate-900 text-white px-4 py-2 rounded-lg shadow-lg flex items-center justify-between flex-wrap gap-2 text-[11px]">
|
||
{/* 交易状态 */}
|
||
<div className="flex items-center gap-1.5">
|
||
<div className={`w-2 h-2 rounded-full ${riskColor} ${riskStatus === "circuit_break" ? "animate-pulse" : ""}`} />
|
||
<span className="font-medium">{riskStatus === "normal" ? "运行中" : riskStatus === "circuit_break" ? "🔴 熔断" : riskStatus === "warning" ? "⚠️ 警告" : "未知"}</span>
|
||
{risk?.block_new_entries && <span className="px-1 py-0.5 rounded bg-red-800 text-red-200 text-[9px]">禁新仓</span>}
|
||
{risk?.reduce_only && <span className="px-1 py-0.5 rounded bg-red-800 text-red-200 text-[9px]">只减仓</span>}
|
||
</div>
|
||
|
||
{/* R预算 */}
|
||
<div className="flex items-center gap-3 font-mono">
|
||
<div>
|
||
<span className="text-slate-400">已实现</span>
|
||
<span className={`ml-1 font-bold ${(risk?.today_realized_r||0) >= 0 ? "text-emerald-400" : "text-red-400"}`}>{(risk?.today_realized_r||0) >= 0 ? "+" : ""}{risk?.today_realized_r||0}R</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-400">未实现</span>
|
||
<span className={`ml-1 font-bold ${(risk?.today_unrealized_r||0) >= 0 ? "text-emerald-400" : "text-red-400"}`}>{(risk?.today_unrealized_r||0) >= 0 ? "+" : ""}{risk?.today_unrealized_r||0}R</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-400">日限</span>
|
||
<span className={`ml-1 font-bold ${rBudgetColor}`}>{totalR >= 0 ? "+" : ""}{totalR.toFixed(1)}/-5R</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 对账+清算 */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-1">
|
||
<div className={`w-1.5 h-1.5 rounded-full ${reconOk ? "bg-emerald-400" : "bg-red-400 animate-pulse"}`} />
|
||
<span className="text-slate-300">对账{reconOk ? "✓" : `✗(${recon?.diffs?.length||0})`}</span>
|
||
</div>
|
||
<div className="text-slate-300">连亏 <span className="font-bold text-white">{risk?.consecutive_losses||0}</span></div>
|
||
{risk?.circuit_break_reason && <span className="text-red-300 text-[9px] max-w-[150px] truncate">{risk.circuit_break_reason}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L1: 一键止血区
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L1_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("操作失败"); }
|
||
};
|
||
const ConfirmBtn = ({ action, label, color }: { action: string; label: string; color: string }) => (
|
||
confirming === action ? (
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-[10px] text-red-600">确认?</span>
|
||
<button onClick={() => doAction(action)} className={`px-2 py-1 rounded text-[10px] font-bold ${color} 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(action)} className={`px-3 py-1.5 rounded-lg text-[11px] font-bold ${color} text-white`}>{label}</button>
|
||
)
|
||
);
|
||
return (
|
||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-2.5">
|
||
<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 items-center">
|
||
<ConfirmBtn action="emergency-close" label="🔴 全平" color="bg-red-500 hover:bg-red-600" />
|
||
<ConfirmBtn action="block-new" label="🟡 禁新仓" color="bg-amber-500 hover:bg-amber-600" />
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L2: 账户概览(8卡片)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L2_AccountOverview() {
|
||
const [data, setData] = useState<any>(null);
|
||
const [summary, setSummary] = useState<any>(null);
|
||
useEffect(() => {
|
||
const f = async () => {
|
||
try { const r = await authFetch("/api/live/account"); if (r.ok) setData(await r.json()); } catch {}
|
||
try { const r = await authFetch(`/api/live/summary?strategy=${LIVE_STRATEGY}`); if (r.ok) setSummary(await r.json()); } catch {}
|
||
};
|
||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||
}, []);
|
||
const d = data || {};
|
||
const s = summary || {};
|
||
const cards = [
|
||
{ label: "账户权益", value: `$${(d.equity||0).toFixed(2)}`, color: "" },
|
||
{ label: "可用保证金", value: `$${(d.available_margin||0).toFixed(2)}`, color: "" },
|
||
{ label: "已用保证金", value: `$${(d.used_margin||0).toFixed(2)}`, color: "" },
|
||
{ label: "有效杠杆", value: `${d.effective_leverage||0}x`, color: (d.effective_leverage||0) > 10 ? "text-red-500" : "" },
|
||
{ label: "今日净PnL", value: `${(d.today_realized_r||0)>=0?"+":""}${d.today_realized_r||0}R ($${d.today_realized_usdt||0})`, color: (d.today_realized_r||0)>=0 ? "text-emerald-600" : "text-red-500" },
|
||
{ label: "总净PnL", value: `${(s.total_pnl_r||0)>=0?"+":""}${s.total_pnl_r||0}R ($${s.total_pnl_usdt||0})`, color: (s.total_pnl_r||0)>=0 ? "text-emerald-600" : "text-red-500" },
|
||
{ label: "成本占比", value: `$${(s.total_fee_usdt||0)+(s.total_funding_usdt||0)}`, color: "text-amber-600" },
|
||
{ label: "胜率/PF", value: `${s.win_rate||0}% / ${s.profit_factor||0}`, color: "" },
|
||
];
|
||
return (
|
||
<div className="grid grid-cols-4 lg:grid-cols-8 gap-1.5">
|
||
{cards.map((c, i) => (
|
||
<div key={i} className="bg-white rounded-lg border border-slate-200 px-2 py-1.5">
|
||
<p className="text-[9px] text-slate-400 truncate">{c.label}</p>
|
||
<p className={`font-mono font-bold text-sm ${c.color || "text-slate-800"} truncate`}>{c.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L3: 当前持仓(WS实时)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L3_Positions() {
|
||
const [positions, setPositions] = useState<any[]>([]);
|
||
const [wsPrices, setWsPrices] = useState<Record<string,number>>({});
|
||
const [recon, setRecon] = useState<any>(null);
|
||
|
||
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 {}
|
||
try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setRecon(await r.json()); } 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 m = JSON.parse(e.data); if (m.data) setWsPrices(p => ({...p, [m.data.s]: parseFloat(m.data.p)})); } catch {} };
|
||
return () => ws.close();
|
||
}, []);
|
||
|
||
// 从对账数据获取清算距离
|
||
const liqDist: Record<string, number> = {};
|
||
if (recon?.exchange_positions) {
|
||
for (const ep of recon.exchange_positions) {
|
||
if (ep.liquidation_price > 0 && ep.mark_price > 0) {
|
||
const dist = ep.direction === "LONG"
|
||
? (ep.mark_price - ep.liquidation_price) / ep.mark_price * 100
|
||
: (ep.liquidation_price - ep.mark_price) / ep.mark_price * 100;
|
||
liqDist[ep.symbol] = dist;
|
||
}
|
||
}
|
||
}
|
||
|
||
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">L3 当前持仓 <span className="text-[10px] text-emerald-500">● 实时</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 cp = 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"?(cp-entry)/rd:(entry-cp)/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";
|
||
const dist = liqDist[p.symbol];
|
||
const distColor = dist !== undefined ? (dist < 8 ? "bg-slate-900 text-white" : dist < 12 ? "bg-red-50 text-red-700" : dist < 20 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700") : "bg-slate-50 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>
|
||
{dist !== undefined && <span className={`text-[9px] px-1.5 py-0.5 rounded font-mono font-bold ${distColor}`}>清算{dist.toFixed(1)}%</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">现价: ${cp ? fmtPrice(cp) : "-"}</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"}`}>裸奔 {fmtMs(p.protection_gap_ms||0)}</span>
|
||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">S→O {fmtMs(p.signal_to_order_ms||0)}</span>
|
||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">O→F {fmtMs(p.order_to_fill_ms||0)}</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>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L4: 执行质量面板
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L4_ExecutionQuality() {
|
||
const [data, setData] = useState<any>(null);
|
||
useEffect(() => {
|
||
const f = async () => { try { const r = await authFetch("/api/live/execution-quality"); if (r.ok) setData(await r.json()); } catch {} };
|
||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||
}, []);
|
||
if (!data || data.error) return null;
|
||
const o = data.overall || {};
|
||
const MetricRow = ({ label, stat, unit, yellowThresh, redThresh }: any) => {
|
||
const color = stat?.p95 > redThresh ? "text-red-500" : stat?.p95 > yellowThresh ? "text-amber-500" : "text-emerald-600";
|
||
return (
|
||
<div className="flex items-center justify-between text-[11px]">
|
||
<span className="text-slate-500">{label}</span>
|
||
<div className="flex gap-3 font-mono">
|
||
<span className="text-slate-400">avg <span className="text-slate-800 font-bold">{stat?.avg}{unit}</span></span>
|
||
<span className="text-slate-400">P50 <span className="text-slate-800">{stat?.p50}{unit}</span></span>
|
||
<span className="text-slate-400">P95 <span className={`font-bold ${color}`}>{stat?.p95}{unit}</span></span>
|
||
</div>
|
||
</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">L4 执行质量 <span className="text-[10px] text-slate-400">{data.total_trades}笔</span></h3></div>
|
||
<div className="p-3 space-y-2">
|
||
<MetricRow label="滑点" stat={o.slippage_bps} unit="bps" yellowThresh={2.5} redThresh={8} />
|
||
<MetricRow label="信号→下单" stat={o.signal_to_order_ms} unit="ms" yellowThresh={250} redThresh={1200} />
|
||
<MetricRow label="下单→成交" stat={o.order_to_fill_ms} unit="ms" yellowThresh={600} redThresh={3500} />
|
||
<MetricRow label="裸奔时间" stat={o.protection_gap_ms} unit="ms" yellowThresh={2000} redThresh={5000} />
|
||
</div>
|
||
{data.by_symbol && Object.keys(data.by_symbol).length > 0 && (
|
||
<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-1.5">
|
||
{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-[10px]">
|
||
<span className="font-mono font-bold">{sym.replace("USDT","")}</span>
|
||
<span className="text-slate-400 ml-1">{v.count}笔</span>
|
||
<div className="font-mono mt-0.5">
|
||
<span>滑点P95: {v.slippage_bps?.p95}bps</span>
|
||
<span className="ml-2">裸奔P95: {fmtMs(v.protection_gap_ms?.p95||0)}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L5: 对账面板
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L5_Reconciliation() {
|
||
const [data, setData] = useState<any>(null);
|
||
useEffect(() => {
|
||
const f = async () => { try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setData(await r.json()); } catch {} };
|
||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||
}, []);
|
||
if (!data) return null;
|
||
const ok = data.status === "ok";
|
||
return (
|
||
<div className={`rounded-xl border ${ok ? "border-slate-200" : "border-red-300"} bg-white overflow-hidden`}>
|
||
<div className={`px-3 py-2 border-b ${ok ? "border-slate-100" : "border-red-200 bg-red-50"}`}>
|
||
<h3 className="font-semibold text-slate-800 text-xs">L5 对账 {ok ? <span className="text-emerald-500">✓ 一致</span> : <span className="text-red-500">✗ 差异</span>}</h3>
|
||
</div>
|
||
<div className="p-3 grid grid-cols-2 gap-4 text-[11px]">
|
||
<div>
|
||
<p className="text-slate-400 mb-1">本地仓位 ({data.local_positions?.length || 0})</p>
|
||
{(data.local_positions || []).map((p: any) => (
|
||
<div key={p.id} className="font-mono">{p.symbol?.replace("USDT","")} {p.direction} @ {fmtPrice(p.entry_price)}</div>
|
||
))}
|
||
{!data.local_positions?.length && <div className="text-slate-300">无</div>}
|
||
</div>
|
||
<div>
|
||
<p className="text-slate-400 mb-1">币安仓位 ({data.exchange_positions?.length || 0})</p>
|
||
{(data.exchange_positions || []).map((p: any, i: number) => (
|
||
<div key={i} className="font-mono">{p.symbol?.replace("USDT","")} {p.direction} qty={p.amount} liq={fmtPrice(p.liquidation_price)}</div>
|
||
))}
|
||
{!data.exchange_positions?.length && <div className="text-slate-300">无</div>}
|
||
</div>
|
||
</div>
|
||
<div className="px-3 pb-2 text-[10px] text-slate-400">
|
||
挂单: 本地预期 {data.local_orders||0} / 币安 {data.exchange_orders||0}
|
||
</div>
|
||
{data.diffs?.length > 0 && (
|
||
<div className="px-3 pb-3 space-y-1">
|
||
{data.diffs.map((d: any, i: number) => (
|
||
<div key={i} className={`text-[10px] px-2 py-1 rounded ${d.severity==="critical"?"bg-red-50 text-red-700":"bg-amber-50 text-amber-700"}`}>
|
||
⚠ [{d.symbol}] {d.detail}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L6: 风控状态
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L6_RiskStatus() {
|
||
const [risk, setRisk] = useState<any>(null);
|
||
useEffect(() => {
|
||
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 thresholds = [
|
||
{ rule: "单日亏损 > -5R", status: (risk.today_total_r||0) > -5 ? "✅" : "🔴" },
|
||
{ rule: "连续亏损 < 5次", status: (risk.consecutive_losses||0) < 5 ? "✅" : "🔴" },
|
||
{ rule: "API连接正常", status: risk.status !== "circuit_break" || !risk.circuit_break_reason?.includes("API") ? "✅" : "🔴" },
|
||
];
|
||
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">L6 风控状态</h3></div>
|
||
<div className="p-3">
|
||
<div className="grid grid-cols-3 gap-2 text-[11px] mb-2">
|
||
{thresholds.map((t, i) => (
|
||
<div key={i} className="flex items-center gap-1"><span>{t.status}</span><span className="text-slate-600">{t.rule}</span></div>
|
||
))}
|
||
</div>
|
||
{risk.circuit_break_reason && (
|
||
<div className="text-[10px] bg-red-50 text-red-700 px-2 py-1.5 rounded">
|
||
<span className="font-bold">熔断原因:</span>{risk.circuit_break_reason}
|
||
{risk.auto_resume_time && <span className="ml-2 text-slate-500">预计恢复: {new Date(risk.auto_resume_time * 1000).toLocaleTimeString("zh-CN")}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L8: 实盘 vs 模拟盘对照
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L8_PaperComparison() {
|
||
const [data, setData] = useState<any>(null);
|
||
useEffect(() => {
|
||
const f = async () => { try { const r = await authFetch("/api/live/paper-comparison?limit=20"); if (r.ok) setData(await r.json()); } catch {} };
|
||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||
}, []);
|
||
if (!data || !data.data?.length) 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">L8 实盘 vs 模拟盘 <span className="text-[10px] text-slate-400">平均R差: {data.avg_pnl_diff_r}R</span></h3>
|
||
</div>
|
||
<div className="max-h-48 overflow-y-auto">
|
||
<table className="w-full text-[10px]">
|
||
<thead className="bg-slate-50 sticky top-0"><tr className="text-slate-500">
|
||
<th className="px-2 py-1 text-left">币种</th><th className="px-2 py-1">方向</th>
|
||
<th className="px-2 py-1 text-right">实盘入场</th><th className="px-2 py-1 text-right">模拟入场</th><th className="px-2 py-1 text-right">价差bps</th>
|
||
<th className="px-2 py-1 text-right">实盘PnL</th><th className="px-2 py-1 text-right">模拟PnL</th><th className="px-2 py-1 text-right">R差</th>
|
||
</tr></thead>
|
||
<tbody className="divide-y divide-slate-50">
|
||
{data.data.map((r: any, i: number) => (
|
||
<tr key={i} className="hover:bg-slate-50">
|
||
<td className="px-2 py-1 font-mono">{r.symbol?.replace("USDT","")}</td>
|
||
<td className={`px-2 py-1 text-center font-bold ${r.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{r.direction}</td>
|
||
<td className="px-2 py-1 text-right font-mono">{r.live_entry ? fmtPrice(r.live_entry) : "-"}</td>
|
||
<td className="px-2 py-1 text-right font-mono">{r.paper_entry ? fmtPrice(r.paper_entry) : "-"}</td>
|
||
<td className="px-2 py-1 text-right font-mono">{r.entry_diff_bps || "-"}</td>
|
||
<td className={`px-2 py-1 text-right font-mono ${(r.live_pnl||0)>=0?"text-emerald-600":"text-red-500"}`}>{r.live_pnl?.toFixed(2) || "-"}</td>
|
||
<td className={`px-2 py-1 text-right font-mono ${(r.paper_pnl||0)>=0?"text-emerald-600":"text-red-500"}`}>{r.paper_pnl?.toFixed(2) || "-"}</td>
|
||
<td className={`px-2 py-1 text-right font-mono font-bold ${(r.pnl_diff_r||0)>=0?"text-emerald-600":"text-red-500"}`}>{r.pnl_diff_r?.toFixed(2) || "-"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L9: 权益曲线+回撤
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L9_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;
|
||
// 计算回撤
|
||
let peak = 0;
|
||
const withDD = data.map(d => {
|
||
if (d.pnl > peak) peak = d.pnl;
|
||
return { ...d, dd: -(peak - d.pnl) };
|
||
});
|
||
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">L9 权益曲线 + 回撤</h3></div>
|
||
<div className="p-2" style={{ height: 200 }}>
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<AreaChart data={withDD}>
|
||
<XAxis dataKey="ts" tickFormatter={v => bjt(v)} tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} tickFormatter={v => `${v}R`} />
|
||
<Tooltip labelFormatter={v => bjt(Number(v))} />
|
||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||
<Area type="monotone" dataKey="pnl" name="累计PnL" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||
<Area type="monotone" dataKey="dd" name="回撤" stroke="#ef4444" fill="#fee2e2" strokeWidth={1} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L10: 历史交易表
|
||
// ═══════════════════════════════════════════════════════════════
|
||
type FS = "all"|"BTC"|"ETH"|"XRP"|"SOL";
|
||
type FR = "all"|"win"|"loss";
|
||
|
||
function L10_TradeHistory() {
|
||
const [trades, setTrades] = useState<any[]>([]);
|
||
const [symbol, setSymbol] = useState<FS>("all");
|
||
const [result, setResult] = useState<FR>("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, 15000); 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">L10 历史交易</h3>
|
||
<div className="flex gap-1">
|
||
{(["all","BTC","ETH","XRP","SOL"] as FS[]).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 FR[]).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-96 overflow-y-auto">
|
||
{trades.length === 0 ? <div className="text-center text-slate-400 text-sm py-6">暂无交易记录</div> : (
|
||
<table className="w-full text-[10px]">
|
||
<thead className="bg-slate-50 sticky top-0"><tr className="text-slate-500">
|
||
<th className="px-1.5 py-1 text-left">币种</th><th className="px-1.5 py-1">方向</th>
|
||
<th className="px-1.5 py-1 text-right">入场</th><th className="px-1.5 py-1 text-right">出场</th>
|
||
<th className="px-1.5 py-1 text-right">Gross</th>
|
||
<th className="px-1.5 py-1 text-right">Fee</th>
|
||
<th className="px-1.5 py-1 text-right">FR</th>
|
||
<th className="px-1.5 py-1 text-right">Slip</th>
|
||
<th className="px-1.5 py-1 text-right font-bold">Net</th>
|
||
<th className="px-1.5 py-1">状态</th>
|
||
<th className="px-1.5 py-1 text-right">时长</th>
|
||
</tr></thead>
|
||
<tbody className="divide-y divide-slate-50">
|
||
{trades.map((t: any) => {
|
||
const hm = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts-t.entry_ts)/60000) : 0;
|
||
const gross = t.gross_pnl_r || 0;
|
||
const fee = t.fee_r || 0;
|
||
const fr = t.funding_r || 0;
|
||
const slip = t.slippage_r || 0;
|
||
const net = t.net_pnl_r ?? t.pnl_r ?? 0;
|
||
return (<tr key={t.id} className="hover:bg-slate-50">
|
||
<td className="px-1.5 py-1 font-mono">{t.symbol?.replace("USDT","")}</td>
|
||
<td className={`px-1.5 py-1 text-center font-bold ${t.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{t.direction==="LONG"?"▲":"▼"}</td>
|
||
<td className="px-1.5 py-1 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||
<td className="px-1.5 py-1 text-right font-mono">{t.exit_price?fmtPrice(t.exit_price):"-"}</td>
|
||
<td className={`px-1.5 py-1 text-right font-mono ${gross>=0?"text-emerald-600":"text-red-500"}`}>{gross>=0?"+":""}{gross.toFixed(2)}</td>
|
||
<td className="px-1.5 py-1 text-right font-mono text-amber-600">-{fee.toFixed(2)}</td>
|
||
<td className="px-1.5 py-1 text-right font-mono text-violet-600">{fr > 0 ? `-${fr.toFixed(2)}` : "0"}</td>
|
||
<td className="px-1.5 py-1 text-right font-mono text-slate-500">{slip > 0 ? `-${slip.toFixed(2)}` : "0"}</td>
|
||
<td className={`px-1.5 py-1 text-right font-mono font-bold ${net>0?"text-emerald-600":net<0?"text-red-500":"text-slate-500"}`}>{net>0?"+":""}{net.toFixed(2)}R</td>
|
||
<td className="px-1.5 py-1 text-center"><span className={`px-1 py-0.5 rounded text-[8px] ${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-1.5 py-1 text-right text-slate-400">{hm}m</td>
|
||
</tr>);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// L11: 系统健康
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function L11_SystemHealth() {
|
||
const [data, setData] = useState<any>(null);
|
||
useEffect(() => {
|
||
const f = async () => { try { const r = await authFetch("/api/live/health"); if (r.ok) setData(await r.json()); } catch {} };
|
||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||
}, []);
|
||
if (!data) return null;
|
||
const procs = data.processes || {};
|
||
const fresh = data.data_freshness || {};
|
||
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">L11 系统健康</h3></div>
|
||
<div className="p-3">
|
||
{Object.keys(procs).length > 0 && (
|
||
<div className="mb-2">
|
||
<p className="text-[10px] text-slate-400 mb-1">进程状态</p>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-1.5">
|
||
{Object.entries(procs).map(([name, p]: [string, any]) => (
|
||
<div key={name} className={`rounded-lg px-2 py-1 text-[10px] ${p.status==="online"?"bg-emerald-50":"bg-red-50"}`}>
|
||
<span className="font-medium">{name}</span>
|
||
<span className={`ml-1 ${p.status==="online"?"text-emerald-600":"text-red-500"}`}>{p.status}</span>
|
||
<span className="text-slate-400 ml-1">{p.memory_mb}MB ↻{p.restarts}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{fresh.market_data && (
|
||
<div className="text-[10px]">
|
||
<span className="text-slate-400">行情数据: </span>
|
||
<span className={`font-mono ${fresh.market_data.status==="green"?"text-emerald-600":fresh.market_data.status==="yellow"?"text-amber-500":"text-red-500"}`}>
|
||
{fresh.market_data.age_sec}秒前 {fresh.market_data.status==="green"?"✓":"⚠"}
|
||
</span>
|
||
</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">
|
||
<L0_RiskBar />
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-lg font-bold text-slate-900">⚡ 实盘交易</h1>
|
||
<p className="text-[10px] text-slate-500">V5.2策略 · 币安USDT永续合约 · 测试网</p>
|
||
</div>
|
||
</div>
|
||
<L1_EmergencyPanel />
|
||
<L2_AccountOverview />
|
||
<L3_Positions />
|
||
<L4_ExecutionQuality />
|
||
<L5_Reconciliation />
|
||
<L6_RiskStatus />
|
||
<L8_PaperComparison />
|
||
<L9_EquityCurve />
|
||
<L10_TradeHistory />
|
||
<L11_SystemHealth />
|
||
</div>
|
||
);
|
||
}
|