feat: V5.2 frontend differentiation - strategy tabs, side-by-side scores, visual badges

- Paper page: prominent strategy tabs (全部/V5.1/V5.2) at top
- Paper trades: strategy column with color-coded badges (blue=V5.1, green=V5.2)
- Paper positions: FR/Liq scores displayed prominently for V5.2
- Signals page: side-by-side V5.1 vs V5.2 score comparison cards
- Signals page title updated to 'V5.1 vs V5.2'
- New API endpoint for strategy comparison data
- Layout: local font fallback for build stability
This commit is contained in:
root 2026-03-01 12:21:19 +00:00
parent 7ba53a5005
commit 778cf8cce1
7 changed files with 676 additions and 213 deletions

63
V52_FRONTEND_TASK.md Normal file
View File

@ -0,0 +1,63 @@
# V5.2 Frontend Differentiation Task
## Problem
V5.1 and V5.2 currently share the same pages. Boss wants clear visual separation.
## Requirements
### 1. Signals Page (/signals) - Side-by-side comparison
Currently shows one set of scores per coin. Change to show BOTH V5.1 and V5.2 scores side by side.
For the "Latest Signal" cards at the top, each coin should show:
```
BTC SHORT V5.1: 80分 | V5.2: 85分 5m前
```
The V5.2 score should show FR and Liquidation subscores that V5.1 doesn't have.
To get V5.2 scores, add a new API endpoint `/api/signals/latest-v52` that returns the V5.2 evaluation alongside V5.1. Or modify the existing `/api/signals/latest` to include both strategy scores.
### 2. Paper Trading Page (/paper) - Strategy Tabs at TOP
Add prominent tabs at the very top of the page:
```
[全部] [V5.1 模拟盘] [V5.2 模拟盘]
```
When selecting a strategy tab:
- Current positions: only show positions for that strategy
- Trade history: only show trades for that strategy
- Stats: only show stats for that strategy
- Equity curve: only show curve for that strategy
- The "全部" tab shows everything combined (current behavior)
### 3. Visual Differentiation
- V5.1 trades/positions: use a subtle blue-gray badge
- V5.2 trades/positions: use a green badge with ✨ icon
- V5.2 positions should show extra info: FR score and Liquidation score prominently
### 4. Backend API Changes Needed
#### Modify `/api/signals/latest` endpoint in main.py
Return both V5.1 and V5.2 evaluations. The signal_engine already evaluates both strategies per cycle and saves the primary one. We need to also save V5.2 evaluations or compute them on-the-fly.
Simplest approach: Add a field to the signal_indicators table or return strategy-specific data.
Actually, the simplest approach for NOW: In the latest signal cards, just show the score that's already there (from primary strategy), and add a note showing which strategy it's from. The real differentiation happens in paper trades where the strategy column exists.
#### `/api/paper/trades` already supports `?strategy=` filter (Codex added this)
#### `/api/paper/stats-by-strategy` already exists
### 5. Key Files to Modify
- `frontend/app/paper/page.tsx` - Add strategy tabs at top, filter everything by selected strategy
- `frontend/app/signals/page.tsx` - Show V5.2 specific info (FR/Liq scores) in latest signal cards
- Backend: may need minor API tweaks
### 6. Important
- Don't break existing functionality
- The strategy tabs should be very prominent (not small buttons buried in a section)
- Use consistent styling: slate-800 bg for active tab, slate-100 for inactive
- Test with `npm run build`
When completely finished, run:
openclaw system event --text "Done: V5.2 frontend differentiation - strategy tabs, visual badges, FR/Liq display" --mode now

View File

@ -2,7 +2,7 @@ from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
import httpx
from datetime import datetime, timedelta
import asyncio, time, os
import asyncio, time, os, json
from auth import router as auth_router, get_current_user, ensure_tables as ensure_auth_tables
from db import (
@ -436,6 +436,102 @@ async def get_signal_latest(user: dict = Depends(get_current_user)):
return result
def _primary_signal_strategy() -> str:
strategy_dir = os.path.join(os.path.dirname(__file__), "strategies")
try:
names = []
for fn in os.listdir(strategy_dir):
if not fn.endswith(".json"):
continue
with open(os.path.join(strategy_dir, fn), "r", encoding="utf-8") as f:
cfg = json.load(f)
if cfg.get("name"):
names.append(cfg["name"])
if "v52_8signals" in names:
return "v52_8signals"
if "v51_baseline" in names:
return "v51_baseline"
except Exception:
pass
return "v51_baseline"
def _normalize_factors(raw):
if not raw:
return {}
if isinstance(raw, str):
try:
return json.loads(raw)
except Exception:
return {}
if isinstance(raw, dict):
return raw
return {}
@app.get("/api/signals/latest-v52")
async def get_signal_latest_v52(user: dict = Depends(get_current_user)):
"""返回V5.1/V5.2并排展示所需的最新信号信息。"""
primary_strategy = _primary_signal_strategy()
result = {}
for sym in SYMBOLS:
base_row = await async_fetchrow(
"SELECT ts, score, signal FROM signal_indicators WHERE symbol = $1 ORDER BY ts DESC LIMIT 1",
sym,
)
strategy_rows = await async_fetch(
"SELECT strategy, score, direction, entry_ts, score_factors "
"FROM paper_trades WHERE symbol = $1 AND strategy IN ('v51_baseline','v52_8signals') "
"ORDER BY entry_ts DESC",
sym,
)
latest_by_strategy: dict[str, dict] = {}
for row in strategy_rows:
st = (row.get("strategy") or "v51_baseline")
if st not in latest_by_strategy:
latest_by_strategy[st] = row
if "v51_baseline" in latest_by_strategy and "v52_8signals" in latest_by_strategy:
break
def build_strategy_payload(strategy_name: str):
trade_row = latest_by_strategy.get(strategy_name)
if trade_row:
payload = {
"score": trade_row.get("score"),
"signal": trade_row.get("direction"),
"ts": trade_row.get("entry_ts"),
"source": "paper_trade",
}
elif base_row and primary_strategy == strategy_name:
payload = {
"score": base_row.get("score"),
"signal": base_row.get("signal"),
"ts": base_row.get("ts"),
"source": "signal_indicators",
}
else:
payload = {
"score": None,
"signal": None,
"ts": None,
"source": "unavailable",
}
factors = _normalize_factors(trade_row.get("score_factors") if trade_row else None)
payload["funding_rate_score"] = factors.get("funding_rate", {}).get("score")
payload["liquidation_score"] = factors.get("liquidation", {}).get("score")
return payload
result[sym.replace("USDT", "")] = {
"primary_strategy": primary_strategy,
"latest_signal": base_row.get("signal") if base_row else None,
"latest_ts": base_row.get("ts") if base_row else None,
"v51": build_strategy_payload("v51_baseline"),
"v52": build_strategy_payload("v52_8signals"),
}
return result
@app.get("/api/signals/market-indicators")
async def get_market_indicators(user: dict = Depends(get_current_user)):
"""返回最新的market_indicators数据V5.1新增4个数据源"""
@ -532,8 +628,12 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us
@app.get("/api/paper/summary")
async def paper_summary(user: dict = Depends(get_current_user)):
async def paper_summary(
strategy: str = "all",
user: dict = Depends(get_current_user),
):
"""模拟盘总览"""
if strategy == "all":
closed = await async_fetch(
"SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
)
@ -541,6 +641,20 @@ async def paper_summary(user: dict = Depends(get_current_user)):
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')"
)
first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades")
else:
closed = await async_fetch(
"SELECT pnl_r, direction FROM paper_trades "
"WHERE status NOT IN ('active','tp1_hit') AND strategy = $1",
strategy,
)
active = await async_fetch(
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1",
strategy,
)
first = await async_fetchrow(
"SELECT MIN(created_at) as start FROM paper_trades WHERE strategy = $1",
strategy,
)
total = len(closed)
wins = len([r for r in closed if r["pnl_r"] > 0])
@ -565,13 +679,24 @@ async def paper_summary(user: dict = Depends(get_current_user)):
@app.get("/api/paper/positions")
async def paper_positions(user: dict = Depends(get_current_user)):
async def paper_positions(
strategy: str = "all",
user: dict = Depends(get_current_user),
):
"""当前活跃持仓(含实时价格和浮动盈亏)"""
if strategy == "all":
rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors "
"FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC"
)
else:
rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors "
"FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 ORDER BY entry_ts DESC",
strategy,
)
# 从币安API获取实时价格
prices = {}
symbols_needed = list(set(r["symbol"] for r in rows))
@ -660,10 +785,21 @@ async def paper_trades(
@app.get("/api/paper/equity-curve")
async def paper_equity_curve(user: dict = Depends(get_current_user)):
async def paper_equity_curve(
strategy: str = "all",
user: dict = Depends(get_current_user),
):
"""权益曲线"""
if strategy == "all":
rows = await async_fetch(
"SELECT exit_ts, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC"
"SELECT exit_ts, pnl_r FROM paper_trades "
"WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC"
)
else:
rows = await async_fetch(
"SELECT exit_ts, pnl_r FROM paper_trades "
"WHERE status NOT IN ('active','tp1_hit') AND strategy = $1 ORDER BY exit_ts ASC",
strategy,
)
cumulative = 0.0
curve = []
@ -674,12 +810,22 @@ async def paper_equity_curve(user: dict = Depends(get_current_user)):
@app.get("/api/paper/stats")
async def paper_stats(user: dict = Depends(get_current_user)):
async def paper_stats(
strategy: str = "all",
user: dict = Depends(get_current_user),
):
"""详细统计"""
if strategy == "all":
rows = await async_fetch(
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
)
else:
rows = await async_fetch(
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit') AND strategy = $1",
strategy,
)
if not rows:
return {"error": "暂无数据"}

View File

@ -9,6 +9,8 @@
--muted: #64748b;
--primary: #2563eb;
--primary-foreground: #ffffff;
--font-geist-sans: "Segoe UI", "PingFang SC", "Noto Sans", sans-serif;
--font-geist-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
@theme inline {

View File

@ -1,13 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import { AuthProvider } from "@/lib/auth";
import AuthHeader from "@/components/AuthHeader";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "Arbitrage Engine",
description: "Funding rate arbitrage monitoring system",
@ -16,7 +12,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-slate-50 text-slate-900`}>
<body className="antialiased min-h-screen bg-slate-50 text-slate-900">
<AuthProvider>
<div className="flex min-h-screen">
<Sidebar />

View File

@ -1,4 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { authFetch, useAuth } from "@/lib/auth";
@ -27,10 +28,40 @@ function parseFactors(raw: any) {
return raw;
}
type StrategyFilter = "all" | "v51_baseline" | "v52_8signals";
const STRATEGY_TABS: { value: StrategyFilter; label: string; hint: string }[] = [
{ value: "all", label: "全部", hint: "总览" },
{ value: "v51_baseline", label: "V5.1 模拟盘", hint: "经典五层" },
{ value: "v52_8signals", label: "V5.2 模拟盘", hint: "8信号 + FR/Liq" },
];
function normalizeStrategy(strategy: string | null | undefined): StrategyFilter {
if (strategy === "v52_8signals") return "v52_8signals";
if (strategy === "v51_baseline") return "v51_baseline";
return "v51_baseline";
}
function strategyName(strategy: string | null | undefined) {
if (strategy === "v52_8signals") return "V5.2";
if (strategy === "v51_baseline") return "V5.1";
return strategy || "V5.1";
const normalized = normalizeStrategy(strategy);
if (normalized === "v52_8signals") return "V5.2";
return "V5.1";
}
function strategyBadgeClass(strategy: string | null | undefined) {
return normalizeStrategy(strategy) === "v52_8signals"
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-slate-200 text-slate-700 border border-slate-300";
}
function strategyBadgeText(strategy: string | null | undefined) {
return normalizeStrategy(strategy) === "v52_8signals" ? "✨ V5.2" : "V5.1";
}
function strategyTabDescription(strategy: StrategyFilter) {
if (strategy === "all") return "全部策略合并视图";
if (strategy === "v52_8signals") return "仅展示 V5.2 数据(含 FR / Liq";
return "仅展示 V5.1 数据";
}
// ─── 控制面板(开关+配置)──────────────────────────────────────
@ -40,7 +71,12 @@ function ControlPanel() {
const [saving, setSaving] = useState(false);
useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) setConfig(await r.json()); } catch {} };
const f = async () => {
try {
const r = await authFetch("/api/paper/config");
if (r.ok) setConfig(await r.json());
} catch {}
};
f();
}, []);
@ -52,8 +88,11 @@ function ControlPanel() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !config.enabled }),
});
if (r.ok) setConfig(await r.json().then(j => j.config));
} catch {} finally { setSaving(false); }
if (r.ok) setConfig(await r.json().then((j) => j.config));
} catch {
} finally {
setSaving(false);
}
};
if (!config) return null;
@ -61,12 +100,13 @@ function ControlPanel() {
return (
<div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}>
<div className="flex items-center gap-3">
<button onClick={toggle} disabled={saving}
<button
onClick={toggle}
disabled={saving}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
config.enabled
? "bg-red-500 text-white hover:bg-red-600"
: "bg-emerald-500 text-white hover:bg-emerald-600"
}`}>
config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"
}`}
>
{saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"}
</button>
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>
@ -84,23 +124,40 @@ function ControlPanel() {
// ─── 总览面板 ────────────────────────────────────────────────────
function SummaryCards() {
function SummaryCards({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any>(null);
useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/summary"); if (r.ok) setData(await r.json()); } catch {} };
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
}, []);
const f = async () => {
try {
const r = await authFetch(`/api/paper/summary?strategy=${strategy}`);
if (r.ok) setData(await r.json());
} catch {}
};
f();
const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [strategy]);
if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
return (
<div className="grid grid-cols-3 lg:grid-cols-7 gap-1.5">
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400"></p>
<p className={`font-mono font-bold text-base ${data.balance >= 10000 ? "text-emerald-600" : "text-red-500"}`}>${data.balance?.toLocaleString()}</p>
<p className={`font-mono font-bold text-base ${data.balance >= 10000 ? "text-emerald-600" : "text-red-500"}`}>
${data.balance?.toLocaleString()}
</p>
</div>
<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 >= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl >= 0 ? "+" : ""}{data.total_pnl}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>
<p className={`font-mono font-bold text-lg ${data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{data.total_pnl >= 0 ? "+" : ""}
{data.total_pnl}R
</p>
<p className={`font-mono text-[10px] ${data.total_pnl_usdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400"></p>
@ -140,13 +197,15 @@ function LatestSignals() {
if (r.ok) {
const j = await r.json();
if (j.data && j.data.length > 0) {
setSignals(prev => ({ ...prev, [sym]: j.data[0] }));
setSignals((prev) => ({ ...prev, [sym]: j.data[0] }));
}
}
} catch {}
}
};
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
f();
const iv = setInterval(f, 15000);
return () => clearInterval(iv);
}, []);
return (
@ -155,7 +214,7 @@ function LatestSignals() {
<h3 className="font-semibold text-slate-800 text-xs"></h3>
</div>
<div className="divide-y divide-slate-50">
{COINS.map(sym => {
{COINS.map((sym) => {
const s = signals[sym];
const coin = sym.replace("USDT", "");
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
@ -185,19 +244,27 @@ function LatestSignals() {
// ─── 当前持仓 ────────────────────────────────────────────────────
function ActivePositions() {
function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
const [positions, setPositions] = useState<any[]>([]);
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
// 从API获取持仓列表10秒刷新
useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/positions"); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} };
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
}, []);
const f = async () => {
try {
const r = await authFetch(`/api/paper/positions?strategy=${strategy}`);
if (r.ok) {
const j = await r.json();
setPositions(j.data || []);
}
} catch {}
};
f();
const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [strategy]);
// WebSocket实时价格aggTrade逐笔成交
useEffect(() => {
const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map(s => `${s}@aggTrade`).join("/");
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 {
@ -205,23 +272,26 @@ function ActivePositions() {
if (msg.data) {
const sym = msg.data.s;
const price = parseFloat(msg.data.p);
if (sym && price > 0) setWsPrices(prev => ({ ...prev, [sym]: price }));
if (sym && price > 0) setWsPrices((prev) => ({ ...prev, [sym]: price }));
}
} catch {}
};
return () => ws.close();
}, []);
if (positions.length === 0) return (
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">
{strategy === "all" ? "暂无活跃持仓" : `${strategyName(strategy)} 暂无活跃持仓`}
</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></h3>
<h3 className="font-semibold text-slate-800 text-xs">
<span className="text-[10px] text-emerald-500 font-normal"> </span>
</h3>
</div>
<div className="divide-y divide-slate-100">
{positions.map((p: any) => {
@ -234,25 +304,30 @@ function ActivePositions() {
const entry = p.entry_price || 0;
const atr = p.atr_at_entry || 1;
const riskDist = 2.0 * 0.7 * atr;
// TP1触发后只剩半仓0.5×TP1锁定 + 0.5×当前浮盈
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0;
const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
const unrealUsdt = unrealR * 200;
const isV52 = normalizeStrategy(p.strategy) === "v52_8signals";
return (
<div key={p.id} className="px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div key={p.id} className={`px-3 py-2 ${isV52 ? "bg-emerald-50/60" : "bg-slate-50/70"}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<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">
{strategyName(p.strategy)} · {p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}
<span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(p.strategy)}`}>
{strategyBadgeText(p.strategy)}
</span>
<span className="text-[10px] text-slate-500">{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}</span>
{isV52 && (
<span className="text-[10px] font-semibold text-emerald-700">FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}</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
{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(0)})
@ -260,15 +335,20 @@ function ActivePositions() {
<span className="text-[10px] text-slate-400">{holdMin}m</span>
</div>
</div>
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600">
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap">
<span>入场: ${fmtPrice(p.entry_price)}</span>
<span className="text-blue-600">现价: ${currentPrice ? fmtPrice(currentPrice) : "-"}</span>
<span className="text-emerald-600">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span>
<span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span>
<span className="text-red-500">SL: ${fmtPrice(p.sl_price)}</span>
<span className="text-amber-600">FR: {frScore >= 0 ? "+" : ""}{frScore}</span>
<span className="text-cyan-600">Liq: {liqScore >= 0 ? "+" : ""}{liqScore}</span>
{!isV52 && <span className="text-slate-400">FR/Liq V5.2 </span>}
</div>
{isV52 && (
<div className="mt-1 grid grid-cols-2 gap-2 text-[10px] font-semibold">
<div className="rounded-md bg-emerald-100/70 text-emerald-800 px-2 py-1"> Funding Rate Score: {frScore >= 0 ? "+" : ""}{frScore}</div>
<div className="rounded-md bg-cyan-100/70 text-cyan-800 px-2 py-1"> Liquidation Score: {liqScore >= 0 ? "+" : ""}{liqScore}</div>
</div>
)}
</div>
);
})}
@ -279,20 +359,32 @@ function ActivePositions() {
// ─── 权益曲线 ────────────────────────────────────────────────────
function EquityCurve() {
function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any[]>([]);
useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/equity-curve"); 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;
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/equity-curve?strategy=${strategy}`);
if (r.ok) {
const j = await r.json();
setData(j.data || []);
}
} catch {}
};
f();
const iv = setInterval(f, 30000);
return () => clearInterval(iv);
}, [strategy]);
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>
{data.length < 2 ? (
<div className="px-3 py-6 text-center text-xs text-slate-400">{strategy === "all" ? "暂无足够历史数据" : `${strategyName(strategy)} 暂无足够历史数据`}</div>
) : (
<div className="p-2" style={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
@ -304,6 +396,7 @@ function EquityCurve() {
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@ -312,49 +405,55 @@ function EquityCurve() {
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
type FilterResult = "all" | "win" | "loss";
type FilterStrategy = "all" | "v51_baseline" | "v52_8signals";
function TradeHistory() {
function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
const [trades, setTrades] = useState<any[]>([]);
const [symbol, setSymbol] = useState<FilterSymbol>("all");
const [result, setResult] = useState<FilterResult>("all");
const [strategy, setStrategy] = useState<FilterStrategy>("all");
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${strategy}&limit=50`);
if (r.ok) { const j = await r.json(); setTrades(j.data || []); }
if (r.ok) {
const j = await r.json();
setTrades(j.data || []);
}
} catch {}
};
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
f();
const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [symbol, result, strategy]);
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"}`}>
<div className="flex items-center gap-1 flex-wrap">
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategy === "all" ? "bg-slate-100 text-slate-600" : strategyBadgeClass(strategy)}`}>
{strategy === "all" ? "全部策略" : `${strategyBadgeText(strategy)} 视图`}
</span>
<span className="text-slate-300">|</span>
{(["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"}`}>
{(["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>
))}
<span className="text-slate-300">|</span>
{(["all", "v51_baseline", "v52_8signals"] as FilterStrategy[]).map(s => (
<button key={s} onClick={() => setStrategy(s)}
className={`px-2 py-0.5 rounded text-[10px] ${strategy === s ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}>
{s === "all" ? "全部策略" : strategyName(s)}
</button>
))}
</div>
</div>
<div className="max-h-64 overflow-y-auto">
@ -381,15 +480,12 @@ function TradeHistory() {
const factors = parseFactors(t.score_factors);
const frScore = factors?.funding_rate?.score ?? 0;
const liqScore = factors?.liquidation?.score ?? 0;
const isV52 = normalizeStrategy(t.strategy) === "v52_8signals";
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 text-[10px]">
<span className={`px-1.5 py-0.5 rounded ${
t.strategy === "v52_8signals" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-600"
}`}>
{strategyName(t.strategy)}
</span>
<span className={`px-1.5 py-0.5 rounded font-semibold ${strategyBadgeClass(t.strategy)}`}>{strategyBadgeText(t.strategy)}</span>
</td>
<td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction}
@ -397,22 +493,41 @@ function TradeHistory() {
<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.exit_price ? fmtPrice(t.exit_price) : "-"}</td>
<td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r > 0 ? "text-emerald-600" : t.pnl_r < 0 ? "text-red-500" : "text-slate-500"}`}>
{t.pnl_r > 0 ? "+" : ""}{t.pnl_r?.toFixed(2)}
{t.pnl_r > 0 ? "+" : ""}
{t.pnl_r?.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" :
t.status === "signal_flip" ? "bg-purple-100 text-purple-700" :
"bg-slate-100 text-slate-600"
}`}>
{t.status === "tp" ? "止盈" : t.status === "sl" ? "止损" : t.status === "sl_be" ? "保本" : t.status === "timeout" ? "超时" : t.status === "signal_flip" ? "翻转" : t.status}
<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"
: t.status === "signal_flip"
? "bg-purple-100 text-purple-700"
: "bg-slate-100 text-slate-600"
}`}
>
{t.status === "tp"
? "止盈"
: t.status === "sl"
? "止损"
: t.status === "sl_be"
? "保本"
: t.status === "timeout"
? "超时"
: t.status === "signal_flip"
? "翻转"
: t.status}
</span>
</td>
<td className="px-2 py-1.5 text-right font-mono">
<div>{t.score}</div>
<div className="text-[9px] text-slate-400">FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}</div>
<div className={`text-[9px] ${isV52 ? "text-emerald-600 font-semibold" : "text-slate-400"}`}>
{isV52 ? `✨ FR ${frScore >= 0 ? "+" : ""}${frScore} · Liq ${liqScore >= 0 ? "+" : ""}${liqScore}` : "FR/Liq 仅V5.2"}
</div>
</td>
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
</tr>
@ -428,103 +543,110 @@ function TradeHistory() {
// ─── 统计面板 ────────────────────────────────────────────────────
function StatsPanel() {
function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any>(null);
const [tab, setTab] = useState("ALL");
const [strategyStats, setStrategyStats] = useState<any[]>([]);
const [strategyTab, setStrategyTab] = useState<"all" | "v51_baseline" | "v52_8signals">("all");
useEffect(() => {
const f = async () => {
try {
const [statsRes, byStrategyRes] = await Promise.all([
authFetch("/api/paper/stats"),
authFetch("/api/paper/stats-by-strategy"),
]);
if (statsRes.ok) setData(await statsRes.json());
if (byStrategyRes.ok) {
const j = await byStrategyRes.json();
setStrategyStats(j.data || []);
}
const r = await authFetch(`/api/paper/stats?strategy=${strategy}`);
if (r.ok) setData(await r.json());
} catch {}
};
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
}, []);
f();
const iv = setInterval(f, 30000);
return () => clearInterval(iv);
}, [strategy]);
if (!data || data.error) return null;
useEffect(() => {
setTab("ALL");
}, [strategy]);
if (!data || data.error) {
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 text-xs text-slate-400"></div>
</div>
);
}
const tabs = ["ALL", "BTC", "ETH", "XRP", "SOL"];
const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || null);
const strategyView = strategyTab === "all"
? (() => {
if (!strategyStats.length) return null;
const total = strategyStats.reduce((sum, s) => sum + (s.total || 0), 0);
const weightedWins = strategyStats.reduce((sum, s) => sum + (s.total || 0) * ((s.win_rate || 0) / 100), 0);
return {
strategy: "all",
total,
win_rate: total > 0 ? (weightedWins / total) * 100 : 0,
total_pnl: strategyStats.reduce((sum, s) => sum + (s.total_pnl || 0), 0),
active_positions: strategyStats.reduce((sum, s) => sum + (s.active_positions || 0), 0),
};
})()
: (strategyStats.find((s) => s.strategy === strategyTab) || 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 flex items-center justify-between">
<h3 className="font-semibold text-slate-800 text-xs"></h3>
<div className="flex gap-1">
{tabs.map(t => (
<button key={t} onClick={() => setTab(t)}
<div className="flex items-center gap-1">
{strategy !== "all" && <span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(strategy)}`}>{strategyBadgeText(strategy)}</span>}
{tabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${tab === t ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500 hover:bg-slate-200"}`}
>{t === "ALL" ? "总计" : t}</button>
>
{t === "ALL" ? "总计" : t}
</button>
))}
</div>
</div>
{st ? (
<div className="p-3 space-y-3">
<div className="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">{st.win_rate}%</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.win_loss_ratio}</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold text-emerald-600">+{st.avg_win}R</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold text-red-500">-{st.avg_loss}R</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.mdd}R</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.sharpe}</p></div>
<div><span className="text-slate-400"></span><p className={`font-mono font-bold ${(st.total_pnl ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>{(st.total_pnl ?? 0) >= 0 ? "+" : ""}{st.total_pnl ?? "-"}R</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.total ?? data.total}</p></div>
<div><span className="text-slate-400"></span><p className="font-mono">{st.long_win_rate}% ({st.long_count})</p></div>
<div><span className="text-slate-400"></span><p className="font-mono">{st.short_win_rate}% ({st.short_count})</p></div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.win_rate}%</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.win_loss_ratio}</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold text-emerald-600">+{st.avg_win}R</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold text-red-500">-{st.avg_loss}R</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.mdd}R</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.sharpe}</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className={`font-mono font-bold ${(st.total_pnl ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{(st.total_pnl ?? 0) >= 0 ? "+" : ""}
{st.total_pnl ?? "-"}R
</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.total ?? data.total}</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono">{st.long_win_rate}% ({st.long_count})</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono">{st.short_win_rate}% ({st.short_count})</p>
</div>
{tab === "ALL" && data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => (
<div key={t}><span className="text-slate-400">{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}</span><p className="font-mono">{v.win_rate}% ({v.total})</p></div>
<div key={t}>
<span className="text-slate-400">{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}</span>
<p className="font-mono">{v.win_rate}% ({v.total})</p>
</div>
))}
</div>
<div className="border-t border-slate-100 pt-2 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-slate-700"></p>
<div className="flex gap-1">
{(["all", "v51_baseline", "v52_8signals"] as const).map((s) => (
<button
key={s}
onClick={() => setStrategyTab(s)}
className={`px-2 py-0.5 rounded text-[10px] ${strategyTab === s ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500 hover:bg-slate-200"}`}
>
{s === "all" ? "全部" : strategyName(s)}
</button>
))}
</div>
</div>
{strategyView ? (
<div className="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">{strategyView.strategy === "all" ? "ALL" : strategyName(strategyView.strategy)}</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{(strategyView.win_rate || 0).toFixed(1)}%</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{strategyView.total || 0}</p></div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{strategyView.active_positions || 0}</p></div>
<div><span className="text-slate-400"></span><p className={`font-mono font-bold ${(strategyView.total_pnl || 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>{(strategyView.total_pnl || 0) >= 0 ? "+" : ""}{(strategyView.total_pnl || 0).toFixed(2)}R</p></div>
</div>
) : (
<div className="text-xs text-slate-400"></div>
)}
</div>
</div>
) : (
<div className="p-3 text-xs text-slate-400"></div>
@ -537,31 +659,55 @@ function StatsPanel() {
export default function PaperTradingPage() {
const { isLoggedIn, loading } = useAuth();
const [strategyTab, setStrategyTab] = useState<StrategyFilter>("all");
if (loading) return <div className="text-center text-slate-400 py-8">...</div>;
if (!isLoggedIn) return (
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>
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">
</Link>
</div>
);
return (
<div className="space-y-3">
<div className="rounded-2xl border-2 border-slate-200 bg-white p-2.5 shadow-sm">
<p className="text-[11px] font-semibold text-slate-500 mb-2"></p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{STRATEGY_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setStrategyTab(tab.value)}
className={`rounded-xl border-2 px-4 py-3 text-left transition-all ${
strategyTab === tab.value
? "border-slate-800 bg-slate-800 text-white shadow"
: "border-slate-100 bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<p className="text-sm font-bold">{tab.label}</p>
<p className={`text-[10px] mt-0.5 ${strategyTab === tab.value ? "text-slate-200" : "text-slate-500"}`}>{tab.hint}</p>
</button>
))}
</div>
</div>
<div>
<h1 className="text-lg font-bold text-slate-900">📊 </h1>
<p className="text-[10px] text-slate-500">V5.2AB测试 · · </p>
<p className="text-[10px] text-slate-500">V5.2AB测试 · · · {strategyTabDescription(strategyTab)}</p>
</div>
<ControlPanel />
<SummaryCards />
<SummaryCards strategy={strategyTab} />
<LatestSignals />
<ActivePositions />
<EquityCurve />
<TradeHistory />
<StatsPanel />
<ActivePositions strategy={strategyTab} />
<EquityCurve strategy={strategyTab} />
<TradeHistory strategy={strategyTab} />
<StatsPanel strategy={strategyTab} />
</div>
);
}

View File

@ -47,6 +47,23 @@ interface LatestIndicator {
} | null;
}
interface StrategyScoreSnapshot {
score: number | null;
signal: string | null;
ts: number | null;
source?: string;
funding_rate_score?: number | null;
liquidation_score?: number | null;
}
interface StrategyLatestRow {
primary_strategy?: "v51_baseline" | "v52_8signals";
latest_signal?: string | null;
latest_ts?: number | null;
v51?: StrategyScoreSnapshot;
v52?: StrategyScoreSnapshot;
}
interface MarketIndicatorValue {
value: Record<string, unknown>;
ts: number;
@ -81,6 +98,14 @@ function pct(v: number, digits = 1): string {
return `${(v * 100).toFixed(digits)}%`;
}
function agoLabel(ts: number | null | undefined): string {
if (!ts) return "--";
const minutes = Math.round((Date.now() - ts) / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}m前`;
return `${Math.round(minutes / 60)}h前`;
}
function LayerScore({ label, score, max, colorClass }: { label: string; score: number; max: number; colorClass: string }) {
const ratio = Math.max(0, Math.min((score / max) * 100, 100));
return (
@ -94,6 +119,73 @@ function LayerScore({ label, score, max, colorClass }: { label: string; score: n
);
}
function LatestStrategyComparison() {
const [rows, setRows] = useState<Record<Symbol, StrategyLatestRow | undefined>>({
BTC: undefined,
ETH: undefined,
XRP: undefined,
SOL: undefined,
});
useEffect(() => {
const fetch = async () => {
try {
const res = await authFetch("/api/signals/latest-v52");
if (!res.ok) return;
const json = await res.json();
setRows({
BTC: json.BTC,
ETH: json.ETH,
XRP: json.XRP,
SOL: json.SOL,
});
} catch {}
};
fetch();
const iv = setInterval(fetch, 10000);
return () => clearInterval(iv);
}, []);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs">V5.1 vs V5.2</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 p-2">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map((sym) => {
const row = rows[sym];
const latestSignal = row?.latest_signal;
const v51 = row?.v51;
const v52 = row?.v52;
const v52Fr = v52?.funding_rate_score;
const v52Liq = v52?.liquidation_score;
return (
<div key={sym} className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2">
<div className="flex items-center justify-between">
<p className="font-mono text-sm font-bold text-slate-800">{sym}</p>
<span className="text-[10px] text-slate-400">{agoLabel(row?.latest_ts ?? null)}</span>
</div>
<p className={`text-[11px] mt-0.5 font-semibold ${latestSignal === "LONG" ? "text-emerald-600" : latestSignal === "SHORT" ? "text-red-500" : "text-slate-400"}`}>
{latestSignal === "LONG" ? "🟢 LONG" : latestSignal === "SHORT" ? "🔴 SHORT" : "⚪ 无信号"}
</p>
<div className="mt-1 text-[11px] text-slate-700 flex items-center gap-1.5 flex-wrap">
<span className="rounded bg-slate-200 text-slate-700 px-1.5 py-0.5 font-mono">V5.1: {v51?.score ?? "--"}</span>
<span className="rounded bg-emerald-100 text-emerald-700 px-1.5 py-0.5 font-mono"> V5.2: {v52?.score ?? "--"}</span>
</div>
<div className="mt-1 text-[10px] text-slate-500">
{v52Fr === null || v52Fr === undefined ? "FR --" : `FR ${v52Fr >= 0 ? "+" : ""}${v52Fr}`} · {v52Liq === null || v52Liq === undefined ? "Liq --" : `Liq ${v52Liq >= 0 ? "+" : ""}${v52Liq}`}
</div>
<div className="mt-1 text-[9px] text-slate-400">
来源: V5.1 {v51?.source || "--"} | V5.2 {v52?.source || "--"}
</div>
</div>
);
})}
</div>
</div>
);
}
function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState<MarketIndicatorSet | null>(null);
@ -436,8 +528,8 @@ export default function SignalsPage() {
{/* 标题 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-lg font-bold text-slate-900"> V5.1</h1>
<p className="text-slate-500 text-[10px]">100 · · </p>
<h1 className="text-lg font-bold text-slate-900"> V5.1 vs V5.2</h1>
<p className="text-slate-500 text-[10px]"> · V5.2 Funding Rate / Liquidation </p>
</div>
<div className="flex gap-1">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
@ -449,6 +541,8 @@ export default function SignalsPage() {
</div>
</div>
<LatestStrategyComparison />
{/* 实时指标卡片 */}
<IndicatorCards symbol={symbol} />

View File

@ -10,6 +10,7 @@
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lightweight-charts": "^5.0.0",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"react": "19.2.3",
@ -3765,6 +3766,12 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5127,6 +5134,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightweight-charts": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
"integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",