feat: 完整11层实盘页面 + 补充API
前端(/live page.tsx 594行): - L0: 顶部固定风险条(sticky) - 交易状态/R预算/对账/清算/连亏 - L1: 一键止血区 - 全平/禁新仓/恢复(双重确认) - L2: 账户概览8卡片 - 权益/保证金/杠杆/今日PnL/总PnL/成本/胜率PF - L3: 当前持仓(WS实时) - 含清算距离/滑点/裸奔/延迟/OrderID - L4: 执行质量面板 - 滑点/延迟P50/P95按币种分组 - L5: 对账面板 - 本地vs币安持仓+挂单+差异列表 - L6: 风控状态 - 规则检查+熔断原因+恢复条件 - L8: 实盘vs模拟盘对照 - signal_id匹配+入场差/R差 - L9: 权益曲线+回撤 - 双Area叠加 - L10: 历史交易 - 含成交价/滑点/费用+筛选 - L11: 系统健康 - PM2进程状态+数据新鲜度 后端新增API: - /api/live/account: 币安账户数据 - /api/live/health: PM2进程+数据新鲜度 - /api/live/reconciliation: 对账(本地vs币安) - /api/live/execution-quality: 执行质量统计 - /api/live/paper-comparison: 实盘vs模拟盘
This commit is contained in:
parent
1ef1f97b5d
commit
cb869926e2
337
backend/main.py
337
backend/main.py
@ -1465,3 +1465,340 @@ async def live_resume(user: dict = Depends(get_current_user)):
|
||||
return {"ok": True, "message": "已恢复交易"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 实盘 API 补充(L0-L11)
|
||||
# ============================================================
|
||||
|
||||
@app.get("/api/live/account")
|
||||
async def live_account(user: dict = Depends(get_current_user)):
|
||||
"""L2: 账户概览 — 权益/保证金/杠杆/今日成交额"""
|
||||
import httpx
|
||||
import hashlib, hmac, time as _time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
api_key = os.environ.get("BINANCE_API_KEY", "")
|
||||
secret_key = os.environ.get("BINANCE_SECRET_KEY", "")
|
||||
trade_env = os.environ.get("TRADE_ENV", "testnet")
|
||||
base = "https://testnet.binancefuture.com" if trade_env == "testnet" else "https://fapi.binance.com"
|
||||
|
||||
if not api_key or not secret_key:
|
||||
return {"error": "API keys not configured", "equity": 0, "available_margin": 0, "used_margin": 0, "effective_leverage": 0}
|
||||
|
||||
def sign(params):
|
||||
params["timestamp"] = int(_time.time() * 1000)
|
||||
qs = urlencode(params)
|
||||
sig = hmac.new(secret_key.encode(), qs.encode(), hashlib.sha256).hexdigest()
|
||||
params["signature"] = sig
|
||||
return params
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
# 账户信息
|
||||
params = sign({})
|
||||
resp = await client.get(f"{base}/fapi/v2/account", params=params, headers={"X-MBX-APIKEY": api_key})
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"API {resp.status_code}"}
|
||||
acc = resp.json()
|
||||
|
||||
equity = float(acc.get("totalWalletBalance", 0))
|
||||
available = float(acc.get("availableBalance", 0))
|
||||
used_margin = float(acc.get("totalInitialMargin", 0))
|
||||
unrealized = float(acc.get("totalUnrealizedProfit", 0))
|
||||
effective_leverage = round(used_margin / equity, 2) if equity > 0 else 0
|
||||
|
||||
# 今日已实现PnL
|
||||
today_realized_r = 0
|
||||
today_fee = 0
|
||||
today_volume = 0
|
||||
try:
|
||||
rows = await async_fetch(
|
||||
"SELECT pnl_r, fee_usdt FROM live_trades WHERE exit_ts >= $1 AND status NOT IN ('active','tp1_hit')",
|
||||
int(datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0).timestamp() * 1000)
|
||||
)
|
||||
today_realized_r = sum(r["pnl_r"] or 0 for r in rows)
|
||||
today_fee = sum(r["fee_usdt"] or 0 for r in rows)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"equity": round(equity, 2),
|
||||
"available_margin": round(available, 2),
|
||||
"used_margin": round(used_margin, 2),
|
||||
"unrealized_pnl": round(unrealized, 2),
|
||||
"effective_leverage": effective_leverage,
|
||||
"today_realized_r": round(today_realized_r, 2),
|
||||
"today_realized_usdt": round(today_realized_r * 2, 2),
|
||||
"today_fee": round(today_fee, 2),
|
||||
"today_volume": round(today_volume, 2),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/live/health")
|
||||
async def live_system_health(user: dict = Depends(get_current_user)):
|
||||
"""L11: 系统健康 — 各进程心跳、API状态、数据新鲜度"""
|
||||
import subprocess, time as _time
|
||||
|
||||
health = {
|
||||
"ts": int(_time.time() * 1000),
|
||||
"processes": {},
|
||||
"data_freshness": {},
|
||||
"api_status": "unknown",
|
||||
}
|
||||
|
||||
# PM2进程状态
|
||||
try:
|
||||
result = subprocess.run(["pm2", "jlist"], capture_output=True, text=True, timeout=5)
|
||||
import json as _json
|
||||
procs = _json.loads(result.stdout) if result.stdout else []
|
||||
for p in procs:
|
||||
name = p.get("name", "")
|
||||
if name in ("live-executor", "position-sync", "risk-guard", "signal-engine", "market-collector", "paper-monitor", "liq-collector"):
|
||||
health["processes"][name] = {
|
||||
"status": p.get("pm2_env", {}).get("status", "unknown"),
|
||||
"uptime_ms": p.get("pm2_env", {}).get("pm_uptime", 0),
|
||||
"restarts": p.get("pm2_env", {}).get("restart_time", 0),
|
||||
"memory_mb": round(p.get("monit", {}).get("memory", 0) / 1024 / 1024, 1),
|
||||
"cpu": p.get("monit", {}).get("cpu", 0),
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
# 数据新鲜度
|
||||
try:
|
||||
now_ms = int(_time.time() * 1000)
|
||||
# 最新行情数据
|
||||
latest_market = await async_fetchrow("SELECT MAX(ts) as ts FROM signal_indicators")
|
||||
if latest_market and latest_market["ts"]:
|
||||
age_sec = (now_ms - latest_market["ts"]) / 1000
|
||||
health["data_freshness"]["market_data"] = {
|
||||
"last_ts": latest_market["ts"],
|
||||
"age_sec": round(age_sec, 1),
|
||||
"status": "red" if age_sec > 10 else "yellow" if age_sec > 5 else "green",
|
||||
}
|
||||
|
||||
# 最新对账
|
||||
risk_state = {}
|
||||
try:
|
||||
import json as _json2
|
||||
with open("/tmp/risk_guard_state.json") as f:
|
||||
risk_state = _json2.load(f)
|
||||
except:
|
||||
pass
|
||||
health["risk_guard"] = risk_state
|
||||
except:
|
||||
pass
|
||||
|
||||
return health
|
||||
|
||||
|
||||
@app.get("/api/live/reconciliation")
|
||||
async def live_reconciliation(user: dict = Depends(get_current_user)):
|
||||
"""L5: 对账状态 — 本地 vs 币安"""
|
||||
import httpx, hashlib, hmac, time as _time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
api_key = os.environ.get("BINANCE_API_KEY", "")
|
||||
secret_key = os.environ.get("BINANCE_SECRET_KEY", "")
|
||||
trade_env = os.environ.get("TRADE_ENV", "testnet")
|
||||
base = "https://testnet.binancefuture.com" if trade_env == "testnet" else "https://fapi.binance.com"
|
||||
|
||||
if not api_key or not secret_key:
|
||||
return {"error": "API keys not configured"}
|
||||
|
||||
def sign(params):
|
||||
params["timestamp"] = int(_time.time() * 1000)
|
||||
qs = urlencode(params)
|
||||
sig = hmac.new(secret_key.encode(), qs.encode(), hashlib.sha256).hexdigest()
|
||||
params["signature"] = sig
|
||||
return params
|
||||
|
||||
result = {"local_positions": [], "exchange_positions": [], "local_orders": 0, "exchange_orders": 0, "diffs": [], "status": "ok"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
# 币安持仓
|
||||
params = sign({})
|
||||
resp = await client.get(f"{base}/fapi/v2/positionRisk", params=params, headers={"X-MBX-APIKEY": api_key})
|
||||
exchange_positions = []
|
||||
if resp.status_code == 200:
|
||||
for p in resp.json():
|
||||
amt = float(p.get("positionAmt", 0))
|
||||
if amt != 0:
|
||||
exchange_positions.append({
|
||||
"symbol": p["symbol"],
|
||||
"direction": "LONG" if amt > 0 else "SHORT",
|
||||
"amount": abs(amt),
|
||||
"entry_price": float(p.get("entryPrice", 0)),
|
||||
"mark_price": float(p.get("markPrice", 0)),
|
||||
"liquidation_price": float(p.get("liquidationPrice", 0)),
|
||||
"unrealized_pnl": float(p.get("unRealizedProfit", 0)),
|
||||
})
|
||||
result["exchange_positions"] = exchange_positions
|
||||
|
||||
# 币安挂单
|
||||
params2 = sign({})
|
||||
resp2 = await client.get(f"{base}/fapi/v1/openOrders", params=params2, headers={"X-MBX-APIKEY": api_key})
|
||||
exchange_orders = resp2.json() if resp2.status_code == 200 else []
|
||||
result["exchange_orders"] = len(exchange_orders)
|
||||
|
||||
# 本地持仓
|
||||
local = await async_fetch(
|
||||
"SELECT id, symbol, direction, entry_price, sl_price, tp1_price, tp2_price, status, tp1_hit "
|
||||
"FROM live_trades WHERE status IN ('active','tp1_hit')"
|
||||
)
|
||||
result["local_positions"] = [dict(r) for r in local]
|
||||
result["local_orders"] = len(local) * 3 # 预期每仓3挂单(SL+TP1+TP2)
|
||||
|
||||
# 对比差异
|
||||
local_syms = {r["symbol"]: r for r in local}
|
||||
exchange_syms = {p["symbol"]: p for p in exchange_positions}
|
||||
|
||||
for sym, lp in local_syms.items():
|
||||
if sym not in exchange_syms:
|
||||
result["diffs"].append({"symbol": sym, "type": "local_only", "severity": "critical", "detail": f"本地有{lp['direction']}仓位但币安无持仓"})
|
||||
else:
|
||||
ep = exchange_syms[sym]
|
||||
if lp["direction"] != ep["direction"]:
|
||||
result["diffs"].append({"symbol": sym, "type": "direction_mismatch", "severity": "critical", "detail": f"本地={lp['direction']} 币安={ep['direction']}"})
|
||||
# 清算距离
|
||||
if ep["liquidation_price"] > 0 and ep["mark_price"] > 0:
|
||||
if ep["direction"] == "LONG":
|
||||
dist = (ep["mark_price"] - ep["liquidation_price"]) / ep["mark_price"] * 100
|
||||
else:
|
||||
dist = (ep["liquidation_price"] - ep["mark_price"]) / ep["mark_price"] * 100
|
||||
if dist < 8:
|
||||
result["diffs"].append({"symbol": sym, "type": "liquidation_critical", "severity": "critical", "detail": f"距清算仅{dist:.1f}%"})
|
||||
elif dist < 12:
|
||||
result["diffs"].append({"symbol": sym, "type": "liquidation_warning", "severity": "high", "detail": f"距清算{dist:.1f}%"})
|
||||
|
||||
for sym, ep in exchange_syms.items():
|
||||
if sym not in local_syms:
|
||||
result["diffs"].append({"symbol": sym, "type": "exchange_only", "severity": "high", "detail": f"币安有{ep['direction']}仓位但本地无记录"})
|
||||
|
||||
if result["diffs"]:
|
||||
result["status"] = "mismatch"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/live/execution-quality")
|
||||
async def live_execution_quality(user: dict = Depends(get_current_user)):
|
||||
"""L4: 执行质量面板"""
|
||||
rows = await async_fetch(
|
||||
"SELECT symbol, slippage_bps, signal_to_order_ms, order_to_fill_ms, protection_gap_ms "
|
||||
"FROM live_trades WHERE signal_to_order_ms IS NOT NULL ORDER BY created_at DESC LIMIT 200"
|
||||
)
|
||||
if not rows:
|
||||
return {"error": "no data"}
|
||||
|
||||
# 按币种分组
|
||||
by_coin = {}
|
||||
all_slips = []
|
||||
all_s2o = []
|
||||
all_o2f = []
|
||||
all_prot = []
|
||||
|
||||
for r in rows:
|
||||
sym = r["symbol"]
|
||||
if sym not in by_coin:
|
||||
by_coin[sym] = {"slippages": [], "s2o": [], "o2f": [], "protection": [], "count": 0}
|
||||
by_coin[sym]["count"] += 1
|
||||
if r["slippage_bps"] is not None:
|
||||
by_coin[sym]["slippages"].append(r["slippage_bps"])
|
||||
all_slips.append(r["slippage_bps"])
|
||||
if r["signal_to_order_ms"] is not None:
|
||||
by_coin[sym]["s2o"].append(r["signal_to_order_ms"])
|
||||
all_s2o.append(r["signal_to_order_ms"])
|
||||
if r["order_to_fill_ms"] is not None:
|
||||
by_coin[sym]["o2f"].append(r["order_to_fill_ms"])
|
||||
all_o2f.append(r["order_to_fill_ms"])
|
||||
if r["protection_gap_ms"] is not None:
|
||||
by_coin[sym]["protection"].append(r["protection_gap_ms"])
|
||||
all_prot.append(r["protection_gap_ms"])
|
||||
|
||||
def percentile(arr, p):
|
||||
if not arr: return 0
|
||||
s = sorted(arr)
|
||||
idx = min(int(len(s) * p / 100), len(s) - 1)
|
||||
return s[idx]
|
||||
|
||||
def stats(arr):
|
||||
if not arr: return {"avg": 0, "p50": 0, "p95": 0, "min": 0, "max": 0}
|
||||
return {
|
||||
"avg": round(sum(arr)/len(arr), 2),
|
||||
"p50": round(percentile(arr, 50), 2),
|
||||
"p95": round(percentile(arr, 95), 2),
|
||||
"min": round(min(arr), 2),
|
||||
"max": round(max(arr), 2),
|
||||
}
|
||||
|
||||
result = {
|
||||
"total_trades": len(rows),
|
||||
"overall": {
|
||||
"slippage_bps": stats(all_slips),
|
||||
"signal_to_order_ms": stats(all_s2o),
|
||||
"order_to_fill_ms": stats(all_o2f),
|
||||
"protection_gap_ms": stats(all_prot),
|
||||
},
|
||||
"by_symbol": {},
|
||||
}
|
||||
|
||||
for sym, d in by_coin.items():
|
||||
result["by_symbol"][sym] = {
|
||||
"count": d["count"],
|
||||
"slippage_bps": stats(d["slippages"]),
|
||||
"signal_to_order_ms": stats(d["s2o"]),
|
||||
"order_to_fill_ms": stats(d["o2f"]),
|
||||
"protection_gap_ms": stats(d["protection"]),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/live/paper-comparison")
|
||||
async def live_paper_comparison(
|
||||
limit: int = 50,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""L8: 实盘 vs 模拟盘对照"""
|
||||
# 按signal_id匹配
|
||||
rows = await async_fetch("""
|
||||
SELECT lt.symbol, lt.direction, lt.entry_price as live_entry, lt.exit_price as live_exit,
|
||||
lt.pnl_r as live_pnl, lt.slippage_bps as live_slip, lt.entry_ts as live_entry_ts,
|
||||
lt.signal_id,
|
||||
pt.entry_price as paper_entry, pt.exit_price as paper_exit, pt.pnl_r as paper_pnl
|
||||
FROM live_trades lt
|
||||
LEFT JOIN paper_trades pt ON lt.signal_id = pt.signal_id AND lt.strategy = pt.strategy
|
||||
WHERE lt.status NOT IN ('active','tp1_hit')
|
||||
ORDER BY lt.exit_ts DESC
|
||||
LIMIT $1
|
||||
""", limit)
|
||||
|
||||
comparisons = []
|
||||
total_entry_diff = 0
|
||||
total_pnl_diff = 0
|
||||
count = 0
|
||||
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if r["paper_entry"] and r["live_entry"]:
|
||||
d["entry_diff"] = round(r["live_entry"] - r["paper_entry"], 6)
|
||||
d["entry_diff_bps"] = round(d["entry_diff"] / r["paper_entry"] * 10000, 2) if r["paper_entry"] else 0
|
||||
if r["paper_pnl"] is not None and r["live_pnl"] is not None:
|
||||
d["pnl_diff_r"] = round(r["live_pnl"] - r["paper_pnl"], 4)
|
||||
total_pnl_diff += d["pnl_diff_r"]
|
||||
count += 1
|
||||
comparisons.append(d)
|
||||
|
||||
return {
|
||||
"count": len(comparisons),
|
||||
"avg_pnl_diff_r": round(total_pnl_diff / count, 4) if count > 0 else 0,
|
||||
"data": comparisons,
|
||||
}
|
||||
|
||||
@ -8,80 +8,101 @@ 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 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";
|
||||
|
||||
// ─── 风控状态 ────────────────────────────────────────────────────
|
||||
function RiskStatusPanel() {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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 {} };
|
||||
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);
|
||||
}, []);
|
||||
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" ? "🔴" : "⚪";
|
||||
|
||||
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={`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 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="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>}
|
||||
<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 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 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>
|
||||
{(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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 紧急操作 ────────────────────────────────────────────────────
|
||||
function EmergencyPanel() {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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-3">
|
||||
<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">
|
||||
{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>
|
||||
<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>}
|
||||
@ -89,71 +110,108 @@ function EmergencyPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 总览 ────────────────────────────────────────────────────────
|
||||
function SummaryCards() {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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/summary?strategy=${LIVE_STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
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);
|
||||
}, []);
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-4">加载中...</div>;
|
||||
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-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 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 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() {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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 {} };
|
||||
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 msg = JSON.parse(e.data); if (msg.data) { setWsPrices(prev => ({ ...prev, [msg.data.s]: parseFloat(msg.data.p) })); } } catch {} };
|
||||
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">当前持仓 <span className="text-[10px] text-emerald-500 font-normal">● 实时</span> <span className="text-[10px] text-slate-400 font-normal ml-2">币安合约</span></h3>
|
||||
<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 currentPrice = wsPrices[p.symbol] || p.current_price || 0;
|
||||
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" ? (currentPrice - entry) / rd : (entry - currentPrice) / rd) : 0;
|
||||
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>
|
||||
@ -161,19 +219,21 @@ function ActivePositions() {
|
||||
<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-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"}`}>裸奔 {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 ${(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>
|
||||
@ -184,128 +244,51 @@ function ActivePositions() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
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() {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L4: 执行质量面板
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L4_ExecutionQuality() {
|
||||
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 {} };
|
||||
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">详细统计</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 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 && (
|
||||
{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-2">
|
||||
<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-[11px]">
|
||||
<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.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>
|
||||
<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>
|
||||
@ -315,7 +298,269 @@ function StatsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主页面 ──────────────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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-72 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">出场</th>
|
||||
<th className="px-1.5 py-1 text-right">PnL</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">时长</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;
|
||||
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.fill_price?fmtPrice(t.fill_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 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)}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 font-mono ${Math.abs(t.slippage_bps||0)>8?"text-red-500":"text-slate-600"}`}>{(t.slippage_bps||0).toFixed(1)}</td>
|
||||
<td className="px-1.5 py-1 text-right font-mono text-amber-600">${(t.fee_usdt||0).toFixed(2)}</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>;
|
||||
@ -326,20 +571,25 @@ export default function LiveTradingPage() {
|
||||
<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>
|
||||
<RiskStatusPanel />
|
||||
<EmergencyPanel />
|
||||
<SummaryCards />
|
||||
<ActivePositions />
|
||||
<EquityCurve />
|
||||
<TradeHistory />
|
||||
<StatsPanel />
|
||||
</div>
|
||||
<L1_EmergencyPanel />
|
||||
<L2_AccountOverview />
|
||||
<L3_Positions />
|
||||
<L4_ExecutionQuality />
|
||||
<L5_Reconciliation />
|
||||
<L6_RiskStatus />
|
||||
<L8_PaperComparison />
|
||||
<L9_EquityCurve />
|
||||
<L10_TradeHistory />
|
||||
<L11_SystemHealth />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user