diff --git a/V52_FRONTEND_TASK.md b/V52_FRONTEND_TASK.md
new file mode 100644
index 0000000..64254b9
--- /dev/null
+++ b/V52_FRONTEND_TASK.md
@@ -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
diff --git a/backend/main.py b/backend/main.py
index 2cf849f..16894d9 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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,15 +628,33 @@ 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),
+):
"""模拟盘总览"""
- 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')"
- )
- first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades")
+ if strategy == "all":
+ 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')"
+ )
+ 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),
+):
"""当前活跃持仓(含实时价格和浮动盈亏)"""
- 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"
- )
+ 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,11 +785,22 @@ 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),
+):
"""权益曲线"""
- rows = await async_fetch(
- "SELECT exit_ts, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC"
- )
+ 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"
+ )
+ 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 = []
for r in rows:
@@ -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),
+):
"""详细统计"""
- rows = await async_fetch(
- "SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
- "FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
- )
+ 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": "暂无数据"}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index deb4ccd..f2b3c2a 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -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 {
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 0747d89..ef32d93 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -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 (
-
+
diff --git a/frontend/app/paper/page.tsx b/frontend/app/paper/page.tsx
index 14e88e7..b6bfeca 100644
--- a/frontend/app/paper/page.tsx
+++ b/frontend/app/paper/page.tsx
@@ -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 (
-
@@ -84,23 +124,40 @@ function ControlPanel() {
// ─── 总览面板 ────────────────────────────────────────────────────
-function SummaryCards() {
+function SummaryCards({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState(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 加载中...
;
+
return (
当前资金
-
= 10000 ? "text-emerald-600" : "text-red-500"}`}>${data.balance?.toLocaleString()}
+
= 10000 ? "text-emerald-600" : "text-red-500"}`}>
+ ${data.balance?.toLocaleString()}
+
总盈亏(R)
-
= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl >= 0 ? "+" : ""}{data.total_pnl}R
-
= 0 ? "text-emerald-500" : "text-red-400"}`}>{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
+
= 0 ? "text-emerald-600" : "text-red-500"}`}>
+ {data.total_pnl >= 0 ? "+" : ""}
+ {data.total_pnl}R
+
+
= 0 ? "text-emerald-500" : "text-red-400"}`}>
+ {data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
+
胜率
@@ -136,17 +193,19 @@ function LatestSignals() {
const f = async () => {
for (const sym of COINS) {
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) {
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() {
最新信号
- {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;
@@ -174,7 +233,7 @@ function LatestSignals() {
⚪ 无信号
)}
- {ago !== null &&
{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}}
+ {ago !== null &&
{ago < 60 ? `${ago}m前` : `${Math.round(ago / 60)}h前`}}
);
})}
@@ -185,19 +244,27 @@ function LatestSignals() {
// ─── 当前持仓 ────────────────────────────────────────────────────
-function ActivePositions() {
+function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
const [positions, setPositions] = useState([]);
const [wsPrices, setWsPrices] = useState>({});
- // 从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 (
+
+ {strategy === "all" ? "暂无活跃持仓" : `${strategyName(strategy)} 暂无活跃持仓`}
+
+ );
return (
-
当前持仓 ● 实时
+
+ 当前持仓 ● 实时
+
{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 (
-
-
-
+
+
+
{p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction}
-
- {strategyName(p.strategy)} · 评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}
+
+ {strategyBadgeText(p.strategy)}
+ 评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}
+ {isV52 && (
+ FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}
+ )}
= 0 ? "text-emerald-600" : "text-red-500"}`}>
- {unrealR >= 0 ? "+" : ""}{unrealR.toFixed(2)}R
+ {unrealR >= 0 ? "+" : ""}
+ {unrealR.toFixed(2)}R
= 0 ? "text-emerald-500" : "text-red-400"}`}>
({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(0)})
@@ -260,15 +335,20 @@ function ActivePositions() {
{holdMin}m
-
+
入场: ${fmtPrice(p.entry_price)}
现价: ${currentPrice ? fmtPrice(currentPrice) : "-"}
TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}
TP2: ${fmtPrice(p.tp2_price)}
SL: ${fmtPrice(p.sl_price)}
- FR: {frScore >= 0 ? "+" : ""}{frScore}
- Liq: {liqScore >= 0 ? "+" : ""}{liqScore}
+ {!isV52 && FR/Liq 仅 V5.2 显示}
+ {isV52 && (
+
+
✨ Funding Rate Score: {frScore >= 0 ? "+" : ""}{frScore}
+
✨ Liquidation Score: {liqScore >= 0 ? "+" : ""}{liqScore}
+
+ )}
);
})}
@@ -279,31 +359,44 @@ function ActivePositions() {
// ─── 权益曲线 ────────────────────────────────────────────────────
-function EquityCurve() {
+function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState
([]);
- 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 (
权益曲线 (累计PnL)
-
-
-
- bjt(v)} tick={{ fontSize: 10 }} />
- `${v}R`} />
- bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
-
-
-
-
-
+ {data.length < 2 ? (
+
{strategy === "all" ? "暂无足够历史数据" : `${strategyName(strategy)} 暂无足够历史数据`}
+ ) : (
+
+
+
+ bjt(v)} tick={{ fontSize: 10 }} />
+ `${v}R`} />
+ bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
+
+
+
+
+
+ )}
);
}
@@ -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([]);
const [symbol, setSymbol] = useState("all");
const [result, setResult] = useState("all");
- const [strategy, setStrategy] = useState("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 (
历史交易
-
- {(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map(s => (
-
@@ -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 (
| {t.symbol?.replace("USDT", "")} |
-
- {strategyName(t.strategy)}
-
+ {strategyBadgeText(t.strategy)}
|
{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction}
@@ -397,22 +493,41 @@ function TradeHistory() {
| {fmtPrice(t.entry_price)} |
{t.exit_price ? fmtPrice(t.exit_price) : "-"} |
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)}
|
-
- {t.status === "tp" ? "止盈" : t.status === "sl" ? "止损" : t.status === "sl_be" ? "保本" : t.status === "timeout" ? "超时" : t.status === "signal_flip" ? "翻转" : t.status}
+
+ {t.status === "tp"
+ ? "止盈"
+ : t.status === "sl"
+ ? "止损"
+ : t.status === "sl_be"
+ ? "保本"
+ : t.status === "timeout"
+ ? "超时"
+ : t.status === "signal_flip"
+ ? "翻转"
+ : t.status}
|
{t.score}
- FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}
+
+ {isV52 ? `✨ FR ${frScore >= 0 ? "+" : ""}${frScore} · Liq ${liqScore >= 0 ? "+" : ""}${liqScore}` : "FR/Liq 仅V5.2"}
+
|
{holdMin}m |
@@ -428,102 +543,109 @@ function TradeHistory() {
// ─── 统计面板 ────────────────────────────────────────────────────
-function StatsPanel() {
+function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState
(null);
const [tab, setTab] = useState("ALL");
- const [strategyStats, setStrategyStats] = useState([]);
- 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 (
+
+ );
+ }
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 (
详细统计
-
- {tabs.map(t => (
-
setTab(t)}
+
+ {strategy !== "all" && {strategyBadgeText(strategy)}}
+ {tabs.map((t) => (
+ 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}
+ >
+ {t === "ALL" ? "总计" : t}
+
))}
{st ? (
-
-
-
-
-
-
-
总盈亏= 0 ? "text-emerald-600" : "text-red-500"}`}>{(st.total_pnl ?? 0) >= 0 ? "+" : ""}{st.total_pnl ?? "-"}R
-
总笔数{st.total ?? data.total}
-
做多胜率{st.long_win_rate}% ({st.long_count}笔)
-
做空胜率{st.short_win_rate}% ({st.short_count}笔)
- {tab === "ALL" && data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => (
-
{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}{v.win_rate}% ({v.total}笔)
- ))}
-
-
-
-
策略对比
-
- {(["all", "v51_baseline", "v52_8signals"] as const).map((s) => (
- 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)}
-
- ))}
-
+
- {strategyView ? (
-
-
策略{strategyView.strategy === "all" ? "ALL" : strategyName(strategyView.strategy)}
-
胜率{(strategyView.win_rate || 0).toFixed(1)}%
-
总笔数{strategyView.total || 0}
-
活跃仓位{strategyView.active_positions || 0}
-
总盈亏= 0 ? "text-emerald-600" : "text-red-500"}`}>{(strategyView.total_pnl || 0) >= 0 ? "+" : ""}{(strategyView.total_pnl || 0).toFixed(2)}R
+
+
盈亏比
+
{st.win_loss_ratio}
+
+
+
平均盈利
+
+{st.avg_win}R
+
+
+
平均亏损
+
-{st.avg_loss}R
+
+
+
+
+
总盈亏
+
= 0 ? "text-emerald-600" : "text-red-500"}`}>
+ {(st.total_pnl ?? 0) >= 0 ? "+" : ""}
+ {st.total_pnl ?? "-"}R
+
+
+
+
总笔数
+
{st.total ?? data.total}
+
+
+
做多胜率
+
{st.long_win_rate}% ({st.long_count}笔)
+
+
+
做空胜率
+
{st.short_win_rate}% ({st.short_count}笔)
+
+ {tab === "ALL" && data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => (
+
+
{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}
+
{v.win_rate}% ({v.total}笔)
- ) : (
-
暂无策略统计
- )}
+ ))}
) : (
@@ -537,31 +659,55 @@ function StatsPanel() {
export default function PaperTradingPage() {
const { isLoggedIn, loading } = useAuth();
+ const [strategyTab, setStrategyTab] = useState
("all");
if (loading) return 加载中...
;
- if (!isLoggedIn) return (
-
- );
+ if (!isLoggedIn)
+ return (
+
+
🔒
+
请先登录查看模拟盘
+
+ 登录
+
+
+ );
return (
+
+
策略视图(顶部切换)
+
+ {STRATEGY_TABS.map((tab) => (
+
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"
+ }`}
+ >
+ {tab.label}
+ {tab.hint}
+
+ ))}
+
+
+
📊 模拟盘
-
V5.2策略AB测试 · 实时追踪 · 数据驱动优化
+
V5.2策略AB测试 · 实时追踪 · 数据驱动优化 · {strategyTabDescription(strategyTab)}
-
+
-
-
-
-
+
+
+
+
);
}
diff --git a/frontend/app/signals/page.tsx b/frontend/app/signals/page.tsx
index 8e45496..09e322c 100644
--- a/frontend/app/signals/page.tsx
+++ b/frontend/app/signals/page.tsx
@@ -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;
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>({
+ 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 (
+
+
+
最新信号对比(V5.1 vs V5.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 (
+
+
+
{sym}
+
{agoLabel(row?.latest_ts ?? null)}
+
+
+ {latestSignal === "LONG" ? "🟢 LONG" : latestSignal === "SHORT" ? "🔴 SHORT" : "⚪ 无信号"}
+
+
+ V5.1: {v51?.score ?? "--"}分
+ ✨ V5.2: {v52?.score ?? "--"}分
+
+
+ {v52Fr === null || v52Fr === undefined ? "FR --" : `FR ${v52Fr >= 0 ? "+" : ""}${v52Fr}`} · {v52Liq === null || v52Liq === undefined ? "Liq --" : `Liq ${v52Liq >= 0 ? "+" : ""}${v52Liq}`}
+
+
+ 来源: V5.1 {v51?.source || "--"} | V5.2 {v52?.source || "--"}
+
+
+ );
+ })}
+
+
+ );
+}
+
function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState(null);
@@ -436,8 +528,8 @@ export default function SignalsPage() {
{/* 标题 */}
-
⚡ 信号引擎 V5.1
-
五层100分评分 · 市场拥挤度 · 环境确认
+
⚡ 信号引擎 V5.1 vs V5.2
+
并排评分对比 · V5.2 含 Funding Rate / Liquidation 额外维度
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
@@ -449,6 +541,8 @@ export default function SignalsPage() {
+
+
{/* 实时指标卡片 */}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 32d4a48..e00daa3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",