From 778cf8cce13d9de5434e378aa2ad2c4a6ed1a806 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 12:21:19 +0000 Subject: [PATCH] feat: V5.2 frontend differentiation - strategy tabs, side-by-side scores, visual badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- V52_FRONTEND_TASK.md | 63 +++++ backend/main.py | 194 +++++++++++-- frontend/app/globals.css | 2 + frontend/app/layout.tsx | 6 +- frontend/app/paper/page.tsx | 510 ++++++++++++++++++++++------------ frontend/app/signals/page.tsx | 98 ++++++- frontend/package-lock.json | 16 ++ 7 files changed, 676 insertions(+), 213 deletions(-) create mode 100644 V52_FRONTEND_TASK.md 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 => ( - ))} | - {(["all", "win", "loss"] as FilterResult[]).map(r => ( - ))} - | - {(["all", "v51_baseline", "v52_8signals"] as FilterStrategy[]).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 => ( - + > + {t === "ALL" ? "总计" : t} + ))}
{st ? (
-
胜率

{st.win_rate}%

-
盈亏比

{st.win_loss_ratio}

-
平均盈利

+{st.avg_win}R

-
平均亏损

-{st.avg_loss}R

-
最大回撤

{st.mdd}R

-
夏普比率

{st.sharpe}

-
总盈亏

= 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) => ( - - ))} -
+
+ 胜率 +

{st.win_rate}%

- {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

+
+
+ 最大回撤 +

{st.mdd}R

+
+
+ 夏普比率 +

{st.sharpe}

+
+
+ 总盈亏 +

= 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) => ( + + ))} +
+
+

📊 模拟盘

-

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",