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 from fastapi.middleware.cors import CORSMiddleware
import httpx import httpx
from datetime import datetime, timedelta 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 auth import router as auth_router, get_current_user, ensure_tables as ensure_auth_tables
from db import ( from db import (
@ -436,6 +436,102 @@ async def get_signal_latest(user: dict = Depends(get_current_user)):
return result 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") @app.get("/api/signals/market-indicators")
async def get_market_indicators(user: dict = Depends(get_current_user)): async def get_market_indicators(user: dict = Depends(get_current_user)):
"""返回最新的market_indicators数据V5.1新增4个数据源""" """返回最新的market_indicators数据V5.1新增4个数据源"""
@ -532,15 +628,33 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us
@app.get("/api/paper/summary") @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),
):
"""模拟盘总览""" """模拟盘总览"""
closed = await async_fetch( if strategy == "all":
"SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" closed = await async_fetch(
) "SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
active = await async_fetch( )
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')" active = await async_fetch(
) "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')"
first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades") )
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) total = len(closed)
wins = len([r for r in closed if r["pnl_r"] > 0]) 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") @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),
):
"""当前活跃持仓(含实时价格和浮动盈亏)""" """当前活跃持仓(含实时价格和浮动盈亏)"""
rows = await async_fetch( if strategy == "all":
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, " rows = await async_fetch(
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors " "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
"FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC" "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获取实时价格 # 从币安API获取实时价格
prices = {} prices = {}
symbols_needed = list(set(r["symbol"] for r in rows)) symbols_needed = list(set(r["symbol"] for r in rows))
@ -660,11 +785,22 @@ async def paper_trades(
@app.get("/api/paper/equity-curve") @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),
):
"""权益曲线""" """权益曲线"""
rows = await async_fetch( if strategy == "all":
"SELECT exit_ts, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC" rows = await async_fetch(
) "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 cumulative = 0.0
curve = [] curve = []
for r in rows: for r in rows:
@ -674,12 +810,22 @@ async def paper_equity_curve(user: dict = Depends(get_current_user)):
@app.get("/api/paper/stats") @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),
):
"""详细统计""" """详细统计"""
rows = await async_fetch( if strategy == "all":
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts " rows = await async_fetch(
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" "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: if not rows:
return {"error": "暂无数据"} return {"error": "暂无数据"}

View File

@ -9,6 +9,8 @@
--muted: #64748b; --muted: #64748b;
--primary: #2563eb; --primary: #2563eb;
--primary-foreground: #ffffff; --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 { @theme inline {

View File

@ -1,13 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { AuthProvider } from "@/lib/auth"; import { AuthProvider } from "@/lib/auth";
import AuthHeader from "@/components/AuthHeader"; 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 = { export const metadata: Metadata = {
title: "Arbitrage Engine", title: "Arbitrage Engine",
description: "Funding rate arbitrage monitoring system", description: "Funding rate arbitrage monitoring system",
@ -16,7 +12,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="zh"> <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> <AuthProvider>
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar /> <Sidebar />

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { authFetch, useAuth } from "@/lib/auth"; import { authFetch, useAuth } from "@/lib/auth";
@ -27,10 +28,40 @@ function parseFactors(raw: any) {
return raw; 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) { function strategyName(strategy: string | null | undefined) {
if (strategy === "v52_8signals") return "V5.2"; const normalized = normalizeStrategy(strategy);
if (strategy === "v51_baseline") return "V5.1"; if (normalized === "v52_8signals") return "V5.2";
return strategy || "V5.1"; 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); const [saving, setSaving] = useState(false);
useEffect(() => { 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(); f();
}, []); }, []);
@ -52,8 +88,11 @@ function ControlPanel() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !config.enabled }), body: JSON.stringify({ enabled: !config.enabled }),
}); });
if (r.ok) setConfig(await r.json().then(j => j.config)); if (r.ok) setConfig(await r.json().then((j) => j.config));
} catch {} finally { setSaving(false); } } catch {
} finally {
setSaving(false);
}
}; };
if (!config) return null; if (!config) return null;
@ -61,12 +100,13 @@ function ControlPanel() {
return ( 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={`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"> <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 ${ className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
config.enabled config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"
? "bg-red-500 text-white hover:bg-red-600" }`}
: "bg-emerald-500 text-white hover:bg-emerald-600" >
}`}>
{saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"} {saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"}
</button> </button>
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}> <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); const [data, setData] = useState<any>(null);
useEffect(() => { useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/summary"); if (r.ok) setData(await r.json()); } catch {} }; const f = async () => {
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); 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>; if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
return ( return (
<div className="grid grid-cols-3 lg:grid-cols-7 gap-1.5"> <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"> <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="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>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"> <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="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 font-bold text-lg ${data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>
<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> {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>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"> <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="text-[10px] text-slate-400"></p>
@ -136,17 +193,19 @@ function LatestSignals() {
const f = async () => { const f = async () => {
for (const sym of COINS) { for (const sym of COINS) {
try { try {
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1`); const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT", "")}&limit=1`);
if (r.ok) { if (r.ok) {
const j = await r.json(); const j = await r.json();
if (j.data && j.data.length > 0) { if (j.data && j.data.length > 0) {
setSignals(prev => ({ ...prev, [sym]: j.data[0] })); setSignals((prev) => ({ ...prev, [sym]: j.data[0] }));
} }
} }
} catch {} } catch {}
} }
}; };
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv); f();
const iv = setInterval(f, 15000);
return () => clearInterval(iv);
}, []); }, []);
return ( return (
@ -155,7 +214,7 @@ function LatestSignals() {
<h3 className="font-semibold text-slate-800 text-xs"></h3> <h3 className="font-semibold text-slate-800 text-xs"></h3>
</div> </div>
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
{COINS.map(sym => { {COINS.map((sym) => {
const s = signals[sym]; const s = signals[sym];
const coin = sym.replace("USDT", ""); const coin = sym.replace("USDT", "");
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null; const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
@ -174,7 +233,7 @@ function LatestSignals() {
<span className="text-[10px] text-slate-400"> </span> <span className="text-[10px] text-slate-400"> </span>
)} )}
</div> </div>
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>} {ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago / 60)}h前`}</span>}
</div> </div>
); );
})} })}
@ -185,19 +244,27 @@ function LatestSignals() {
// ─── 当前持仓 ──────────────────────────────────────────────────── // ─── 当前持仓 ────────────────────────────────────────────────────
function ActivePositions() { function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
const [positions, setPositions] = useState<any[]>([]); const [positions, setPositions] = useState<any[]>([]);
const [wsPrices, setWsPrices] = useState<Record<string, number>>({}); const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
// 从API获取持仓列表10秒刷新
useEffect(() => { useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/positions"); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} }; const f = async () => {
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); 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(() => { 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}`); const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
ws.onmessage = (e) => { ws.onmessage = (e) => {
try { try {
@ -205,23 +272,26 @@ function ActivePositions() {
if (msg.data) { if (msg.data) {
const sym = msg.data.s; const sym = msg.data.s;
const price = parseFloat(msg.data.p); 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 {} } catch {}
}; };
return () => ws.close(); return () => ws.close();
}, []); }, []);
if (positions.length === 0) return ( if (positions.length === 0)
<div className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-center text-slate-400 text-sm"> return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-center text-slate-400 text-sm">
</div> {strategy === "all" ? "暂无活跃持仓" : `${strategyName(strategy)} 暂无活跃持仓`}
); </div>
);
return ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100"> <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>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{positions.map((p: any) => { {positions.map((p: any) => {
@ -234,25 +304,30 @@ function ActivePositions() {
const entry = p.entry_price || 0; const entry = p.entry_price || 0;
const atr = p.atr_at_entry || 1; const atr = p.atr_at_entry || 1;
const riskDist = 2.0 * 0.7 * atr; 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 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 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 unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
const unrealUsdt = unrealR * 200; const unrealUsdt = unrealR * 200;
const isV52 = normalizeStrategy(p.strategy) === "v52_8signals";
return ( return (
<div key={p.id} className="px-3 py-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"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center 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"}`}> <span className={`text-xs font-bold ${p.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction} {p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction}
</span> </span>
<span className="text-[10px] text-slate-400"> <span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(p.strategy)}`}>
{strategyName(p.strategy)} · {p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"} {strategyBadgeText(p.strategy)}
</span> </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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-mono text-sm font-bold ${unrealR >= 0 ? "text-emerald-600" : "text-red-500"}`}> <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>
<span className={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}> <span className={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(0)}) ({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(0)})
@ -260,15 +335,20 @@ function ActivePositions() {
<span className="text-[10px] text-slate-400">{holdMin}m</span> <span className="text-[10px] text-slate-400">{holdMin}m</span>
</div> </div>
</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>入场: ${fmtPrice(p.entry_price)}</span>
<span className="text-blue-600">现价: ${currentPrice ? fmtPrice(currentPrice) : "-"}</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">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span>
<span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</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-red-500">SL: ${fmtPrice(p.sl_price)}</span>
<span className="text-amber-600">FR: {frScore >= 0 ? "+" : ""}{frScore}</span> {!isV52 && <span className="text-slate-400">FR/Liq V5.2 </span>}
<span className="text-cyan-600">Liq: {liqScore >= 0 ? "+" : ""}{liqScore}</span>
</div> </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> </div>
); );
})} })}
@ -279,31 +359,44 @@ function ActivePositions() {
// ─── 权益曲线 ──────────────────────────────────────────────────── // ─── 权益曲线 ────────────────────────────────────────────────────
function EquityCurve() { function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any[]>([]); 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 ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100"> <div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs">线 (PnL)</h3> <h3 className="font-semibold text-slate-800 text-xs">线 (PnL)</h3>
</div> </div>
<div className="p-2" style={{ height: 200 }}> {data.length < 2 ? (
<ResponsiveContainer width="100%" height="100%"> <div className="px-3 py-6 text-center text-xs text-slate-400">{strategy === "all" ? "暂无足够历史数据" : `${strategyName(strategy)} 暂无足够历史数据`}</div>
<AreaChart data={data}> ) : (
<XAxis dataKey="ts" tickFormatter={(v) => bjt(v)} tick={{ fontSize: 10 }} /> <div className="p-2" style={{ height: 200 }}>
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} /> <ResponsiveContainer width="100%" height="100%">
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} /> <AreaChart data={data}>
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" /> <XAxis dataKey="ts" tickFormatter={(v) => bjt(v)} tick={{ fontSize: 10 }} />
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} /> <YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
</AreaChart> <Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
</ResponsiveContainer> <ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
</div> <Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div> </div>
); );
} }
@ -312,49 +405,55 @@ function EquityCurve() {
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL"; type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
type FilterResult = "all" | "win" | "loss"; 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 [trades, setTrades] = useState<any[]>([]);
const [symbol, setSymbol] = useState<FilterSymbol>("all"); const [symbol, setSymbol] = useState<FilterSymbol>("all");
const [result, setResult] = useState<FilterResult>("all"); const [result, setResult] = useState<FilterResult>("all");
const [strategy, setStrategy] = useState<FilterStrategy>("all");
useEffect(() => { useEffect(() => {
const f = async () => { const f = async () => {
try { try {
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${strategy}&limit=50`); 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 {} } catch {}
}; };
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); f();
const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [symbol, result, strategy]); }, [symbol, result, strategy]);
return ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <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"> <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> <h3 className="font-semibold text-slate-800 text-xs"></h3>
<div className="flex gap-1"> <div className="flex items-center gap-1 flex-wrap">
{(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map(s => ( <span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategy === "all" ? "bg-slate-100 text-slate-600" : strategyBadgeClass(strategy)}`}>
<button key={s} onClick={() => setSymbol(s)} {strategy === "all" ? "全部策略" : `${strategyBadgeText(strategy)} 视图`}
className={`px-2 py-0.5 rounded text-[10px] ${symbol === s ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}> </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} {s === "all" ? "全部" : s}
</button> </button>
))} ))}
<span className="text-slate-300">|</span> <span className="text-slate-300">|</span>
{(["all", "win", "loss"] as FilterResult[]).map(r => ( {(["all", "win", "loss"] as FilterResult[]).map((r) => (
<button key={r} onClick={() => setResult(r)} <button
className={`px-2 py-0.5 rounded text-[10px] ${result === r ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}> 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" ? "盈利" : "亏损"} {r === "all" ? "全部" : r === "win" ? "盈利" : "亏损"}
</button> </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> </div>
<div className="max-h-64 overflow-y-auto"> <div className="max-h-64 overflow-y-auto">
@ -381,15 +480,12 @@ function TradeHistory() {
const factors = parseFactors(t.score_factors); const factors = parseFactors(t.score_factors);
const frScore = factors?.funding_rate?.score ?? 0; const frScore = factors?.funding_rate?.score ?? 0;
const liqScore = factors?.liquidation?.score ?? 0; const liqScore = factors?.liquidation?.score ?? 0;
const isV52 = normalizeStrategy(t.strategy) === "v52_8signals";
return ( return (
<tr key={t.id} className="hover:bg-slate-50"> <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-mono">{t.symbol?.replace("USDT", "")}</td>
<td className="px-2 py-1.5 text-[10px]"> <td className="px-2 py-1.5 text-[10px]">
<span className={`px-1.5 py-0.5 rounded ${ <span className={`px-1.5 py-0.5 rounded font-semibold ${strategyBadgeClass(t.strategy)}`}>{strategyBadgeText(t.strategy)}</span>
t.strategy === "v52_8signals" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-600"
}`}>
{strategyName(t.strategy)}
</span>
</td> </td>
<td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}> <td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction} {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">{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">{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"}`}> <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>
<td className="px-2 py-1.5 text-center"> <td className="px-2 py-1.5 text-center">
<span className={`px-1 py-0.5 rounded text-[9px] ${ <span
t.status === "tp" ? "bg-emerald-100 text-emerald-700" : className={`px-1 py-0.5 rounded text-[9px] ${
t.status === "sl" ? "bg-red-100 text-red-700" : t.status === "tp"
t.status === "sl_be" ? "bg-amber-100 text-amber-700" : ? "bg-emerald-100 text-emerald-700"
t.status === "signal_flip" ? "bg-purple-100 text-purple-700" : : t.status === "sl"
"bg-slate-100 text-slate-600" ? "bg-red-100 text-red-700"
}`}> : t.status === "sl_be"
{t.status === "tp" ? "止盈" : t.status === "sl" ? "止损" : t.status === "sl_be" ? "保本" : t.status === "timeout" ? "超时" : t.status === "signal_flip" ? "翻转" : t.status} ? "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> </span>
</td> </td>
<td className="px-2 py-1.5 text-right font-mono"> <td className="px-2 py-1.5 text-right font-mono">
<div>{t.score}</div> <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>
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td> <td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
</tr> </tr>
@ -428,102 +543,109 @@ function TradeHistory() {
// ─── 统计面板 ──────────────────────────────────────────────────── // ─── 统计面板 ────────────────────────────────────────────────────
function StatsPanel() { function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [tab, setTab] = useState("ALL"); const [tab, setTab] = useState("ALL");
const [strategyStats, setStrategyStats] = useState<any[]>([]);
const [strategyTab, setStrategyTab] = useState<"all" | "v51_baseline" | "v52_8signals">("all");
useEffect(() => { useEffect(() => {
const f = async () => { const f = async () => {
try { try {
const [statsRes, byStrategyRes] = await Promise.all([ const r = await authFetch(`/api/paper/stats?strategy=${strategy}`);
authFetch("/api/paper/stats"), if (r.ok) setData(await r.json());
authFetch("/api/paper/stats-by-strategy"),
]);
if (statsRes.ok) setData(await statsRes.json());
if (byStrategyRes.ok) {
const j = await byStrategyRes.json();
setStrategyStats(j.data || []);
}
} catch {} } 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 tabs = ["ALL", "BTC", "ETH", "XRP", "SOL"];
const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || null); 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 ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <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"> <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> <h3 className="font-semibold text-slate-800 text-xs"></h3>
<div className="flex gap-1"> <div className="flex items-center gap-1">
{tabs.map(t => ( {strategy !== "all" && <span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(strategy)}`}>{strategyBadgeText(strategy)}</span>}
<button key={t} onClick={() => setTab(t)} {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"}`} 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>
</div> </div>
{st ? ( {st ? (
<div className="p-3 space-y-3"> <div className="p-3 space-y-3">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs"> <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>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.win_loss_ratio}</p></div> <span className="text-slate-400"></span>
<div><span className="text-slate-400"></span><p className="font-mono font-bold text-emerald-600">+{st.avg_win}R</p></div> <p className="font-mono font-bold">{st.win_rate}%</p>
<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>
<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> </div>
{strategyView ? ( <div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs"> <span className="text-slate-400"></span>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{strategyView.strategy === "all" ? "ALL" : strategyName(strategyView.strategy)}</p></div> <p className="font-mono font-bold">{st.win_loss_ratio}</p>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{(strategyView.win_rate || 0).toFixed(1)}%</p></div> </div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{strategyView.total || 0}</p></div> <div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{strategyView.active_positions || 0}</p></div> <span className="text-slate-400"></span>
<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> <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>
) : ( ))}
<div className="text-xs text-slate-400"></div>
)}
</div> </div>
</div> </div>
) : ( ) : (
@ -537,31 +659,55 @@ function StatsPanel() {
export default function PaperTradingPage() { export default function PaperTradingPage() {
const { isLoggedIn, loading } = useAuth(); const { isLoggedIn, loading } = useAuth();
const [strategyTab, setStrategyTab] = useState<StrategyFilter>("all");
if (loading) return <div className="text-center text-slate-400 py-8">...</div>; if (loading) return <div className="text-center text-slate-400 py-8">...</div>;
if (!isLoggedIn) return ( if (!isLoggedIn)
<div className="flex flex-col items-center justify-center h-64 gap-4"> return (
<div className="text-5xl">🔒</div> <div className="flex flex-col items-center justify-center h-64 gap-4">
<p className="text-slate-600 font-medium"></p> <div className="text-5xl">🔒</div>
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm"></Link> <p className="text-slate-600 font-medium"></p>
</div> <Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">
);
</Link>
</div>
);
return ( return (
<div className="space-y-3"> <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> <div>
<h1 className="text-lg font-bold text-slate-900">📊 </h1> <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> </div>
<ControlPanel /> <ControlPanel />
<SummaryCards /> <SummaryCards strategy={strategyTab} />
<LatestSignals /> <LatestSignals />
<ActivePositions /> <ActivePositions strategy={strategyTab} />
<EquityCurve /> <EquityCurve strategy={strategyTab} />
<TradeHistory /> <TradeHistory strategy={strategyTab} />
<StatsPanel /> <StatsPanel strategy={strategyTab} />
</div> </div>
); );
} }

View File

@ -47,6 +47,23 @@ interface LatestIndicator {
} | null; } | 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 { interface MarketIndicatorValue {
value: Record<string, unknown>; value: Record<string, unknown>;
ts: number; ts: number;
@ -81,6 +98,14 @@ function pct(v: number, digits = 1): string {
return `${(v * 100).toFixed(digits)}%`; 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 }) { 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)); const ratio = Math.max(0, Math.min((score / max) * 100, 100));
return ( 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 }) { function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState<MarketIndicatorSet | null>(null); 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 className="flex items-center justify-between flex-wrap gap-2">
<div> <div>
<h1 className="text-lg font-bold text-slate-900"> V5.1</h1> <h1 className="text-lg font-bold text-slate-900"> V5.1 vs V5.2</h1>
<p className="text-slate-500 text-[10px]">100 · · </p> <p className="text-slate-500 text-[10px]"> · V5.2 Funding Rate / Liquidation </p>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => ( {(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
@ -449,6 +541,8 @@ export default function SignalsPage() {
</div> </div>
</div> </div>
<LatestStrategyComparison />
{/* 实时指标卡片 */} {/* 实时指标卡片 */}
<IndicatorCards symbol={symbol} /> <IndicatorCards symbol={symbol} />

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lightweight-charts": "^5.0.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
@ -3765,6 +3766,12 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5127,6 +5134,15 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",