From e054db112de3c183ebb5221015b6e49c58f559f3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 28 Feb 2026 11:10:28 +0000 Subject: [PATCH] feat: paper trading - backend (table+signal_engine integration+5 APIs) + frontend page --- backend/db.py | 21 +++ backend/main.py | 175 +++++++++++++++++++ backend/signal_engine.py | 122 +++++++++++++ frontend/app/paper/page.tsx | 298 ++++++++++++++++++++++++++++++++ frontend/components/Sidebar.tsx | 3 +- 5 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 frontend/app/paper/page.tsx diff --git a/backend/db.py b/backend/db.py index a488f02..83616da 100644 --- a/backend/db.py +++ b/backend/db.py @@ -228,6 +228,27 @@ CREATE TABLE IF NOT EXISTS invite_codes ( used_by BIGINT REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() ); + +-- 模拟盘交易表 +CREATE TABLE IF NOT EXISTS paper_trades ( + id BIGSERIAL PRIMARY KEY, + symbol TEXT NOT NULL, + direction TEXT NOT NULL, + score INT NOT NULL, + tier TEXT NOT NULL, + entry_price DOUBLE PRECISION NOT NULL, + entry_ts BIGINT NOT NULL, + exit_price DOUBLE PRECISION, + exit_ts BIGINT, + tp1_price DOUBLE PRECISION NOT NULL, + tp2_price DOUBLE PRECISION NOT NULL, + sl_price DOUBLE PRECISION NOT NULL, + tp1_hit BOOLEAN DEFAULT FALSE, + status TEXT DEFAULT 'active', + pnl_r DOUBLE PRECISION DEFAULT 0, + atr_at_entry DOUBLE PRECISION DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); """ diff --git a/backend/main.py b/backend/main.py index 6fccd1b..f264246 100644 --- a/backend/main.py +++ b/backend/main.py @@ -494,6 +494,181 @@ async def get_signal_trades( status, limit ) return {"count": len(rows), "data": rows} + ) + return {"count": len(rows), "data": rows} + + +# ─── 模拟盘 API ────────────────────────────────────────────────── + +@app.get("/api/paper/summary") +async def paper_summary(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") + + total = len(closed) + wins = len([r for r in closed if r["pnl_r"] > 0]) + total_pnl = sum(r["pnl_r"] for r in closed) + win_rate = (wins / total * 100) if total > 0 else 0 + gross_profit = sum(r["pnl_r"] for r in closed if r["pnl_r"] > 0) + gross_loss = abs(sum(r["pnl_r"] for r in closed if r["pnl_r"] <= 0)) + profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else 0 + + return { + "total_trades": total, + "win_rate": round(win_rate, 1), + "total_pnl": round(total_pnl, 2), + "active_positions": len(active), + "profit_factor": round(profit_factor, 2), + "start_time": str(first["start"]) if first and first["start"] else None, + } + + +@app.get("/api/paper/positions") +async def paper_positions(user: dict = Depends(get_current_user)): + """当前活跃持仓""" + rows = await async_fetch( + "SELECT id, symbol, direction, score, tier, entry_price, entry_ts, " + "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry " + "FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC" + ) + return {"data": rows} + + +@app.get("/api/paper/trades") +async def paper_trades( + symbol: str = "all", + result: str = "all", + limit: int = 100, + user: dict = Depends(get_current_user), +): + """历史交易列表""" + conditions = ["status NOT IN ('active','tp1_hit')"] + params = [] + idx = 1 + + if symbol != "all": + conditions.append(f"symbol = ${idx}") + params.append(symbol.upper() + "USDT") + idx += 1 + + if result == "win": + conditions.append("pnl_r > 0") + elif result == "loss": + conditions.append("pnl_r <= 0") + + where = " AND ".join(conditions) + params.append(limit) + rows = await async_fetch( + f"SELECT id, symbol, direction, score, tier, entry_price, exit_price, " + f"entry_ts, exit_ts, pnl_r, status, tp1_hit " + f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}", + *params + ) + return {"count": len(rows), "data": rows} + + +@app.get("/api/paper/equity-curve") +async def paper_equity_curve(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" + ) + cumulative = 0.0 + curve = [] + for r in rows: + cumulative += r["pnl_r"] + curve.append({"ts": r["exit_ts"], "pnl": round(cumulative, 2)}) + return {"data": curve} + + +@app.get("/api/paper/stats") +async def paper_stats(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 not rows: + return {"error": "暂无数据"} + + total = len(rows) + wins = [r for r in rows if r["pnl_r"] > 0] + losses = [r for r in rows if r["pnl_r"] <= 0] + + # 基础统计 + win_rate = len(wins) / total * 100 + avg_win = sum(r["pnl_r"] for r in wins) / len(wins) if wins else 0 + avg_loss = abs(sum(r["pnl_r"] for r in losses)) / len(losses) if losses else 0 + win_loss_ratio = avg_win / avg_loss if avg_loss > 0 else 0 + + # MDD + peak = 0.0 + mdd = 0.0 + running = 0.0 + for r in sorted(rows, key=lambda x: x["exit_ts"] or 0): + running += r["pnl_r"] + peak = max(peak, running) + mdd = max(mdd, peak - running) + + # 夏普 + returns = [r["pnl_r"] for r in rows] + if len(returns) > 1: + import statistics + avg_ret = statistics.mean(returns) + std_ret = statistics.stdev(returns) + sharpe = (avg_ret / std_ret) * (252 ** 0.5) if std_ret > 0 else 0 + else: + sharpe = 0 + + # 按币种 + by_symbol = {} + for r in rows: + s = r["symbol"].replace("USDT", "") + if s not in by_symbol: + by_symbol[s] = {"total": 0, "wins": 0} + by_symbol[s]["total"] += 1 + if r["pnl_r"] > 0: + by_symbol[s]["wins"] += 1 + symbol_stats = {s: {"total": v["total"], "win_rate": round(v["wins"]/v["total"]*100, 1)} for s, v in by_symbol.items()} + + # 按方向 + longs = [r for r in rows if r["direction"] == "LONG"] + shorts = [r for r in rows if r["direction"] == "SHORT"] + long_wr = len([r for r in longs if r["pnl_r"] > 0]) / len(longs) * 100 if longs else 0 + short_wr = len([r for r in shorts if r["pnl_r"] > 0]) / len(shorts) * 100 if shorts else 0 + + # 按档位 + by_tier = {} + for r in rows: + t = r["tier"] + if t not in by_tier: + by_tier[t] = {"total": 0, "wins": 0} + by_tier[t]["total"] += 1 + if r["pnl_r"] > 0: + by_tier[t]["wins"] += 1 + tier_stats = {t: {"total": v["total"], "win_rate": round(v["wins"]/v["total"]*100, 1)} for t, v in by_tier.items()} + + return { + "total": total, + "win_rate": round(win_rate, 1), + "avg_win": round(avg_win, 2), + "avg_loss": round(avg_loss, 2), + "win_loss_ratio": round(win_loss_ratio, 2), + "mdd": round(mdd, 2), + "sharpe": round(sharpe, 2), + "long_win_rate": round(long_wr, 1), + "long_count": len(longs), + "short_win_rate": round(short_wr, 1), + "short_count": len(shorts), + "by_symbol": symbol_stats, + "by_tier": tier_stats, + } # ─── 服务器状态监控 ─────────────────────────────────────────────── diff --git a/backend/signal_engine.py b/backend/signal_engine.py index 301dc99..b017564 100644 --- a/backend/signal_engine.py +++ b/backend/signal_engine.py @@ -463,6 +463,117 @@ def save_indicator_1m(ts: int, symbol: str, result: dict): conn.commit() +# ─── 模拟盘 ────────────────────────────────────────────────────── + +def paper_open_trade(symbol: str, direction: str, price: float, score: int, tier: str, atr: float, now_ms: int): + """模拟开仓""" + risk_atr = 0.7 * atr + if risk_atr <= 0: + return + if direction == "LONG": + sl = price - 2.0 * risk_atr + tp1 = price + 1.5 * risk_atr + tp2 = price + 3.0 * risk_atr + else: + sl = price + 2.0 * risk_atr + tp1 = price - 1.5 * risk_atr + tp2 = price - 3.0 * risk_atr + + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + (symbol, direction, score, tier, price, now_ms, tp1, tp2, sl, atr) + ) + conn.commit() + logger.info(f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}") + + +def paper_check_positions(symbol: str, current_price: float, now_ms: int): + """检查模拟盘持仓的止盈止损""" + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, direction, entry_price, tp1_price, tp2_price, sl_price, tp1_hit, entry_ts " + "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') ORDER BY id", + (symbol,) + ) + positions = cur.fetchall() + + for pos in positions: + pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts = pos + closed = False + new_status = None + pnl_r = 0.0 + + if direction == "LONG": + if current_price <= sl: + closed = True + if tp1_hit: + new_status = "sl_be" + pnl_r = 0.5 * 1.5 + else: + new_status = "sl" + pnl_r = -1.0 + elif not tp1_hit and current_price >= tp1: + # TP1触发,移动止损到成本价 + new_sl = entry_price * 1.0005 + cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", (new_sl, pid)) + logger.info(f"[{symbol}] 📝 TP1触发 LONG @ {current_price:.2f}, SL移至成本{new_sl:.2f}") + elif tp1_hit and current_price >= tp2: + closed = True + new_status = "tp" + pnl_r = 0.5 * 1.5 + 0.5 * 3.0 # 2.25R + else: # SHORT + if current_price >= sl: + closed = True + if tp1_hit: + new_status = "sl_be" + pnl_r = 0.5 * 1.5 + else: + new_status = "sl" + pnl_r = -1.0 + elif not tp1_hit and current_price <= tp1: + new_sl = entry_price * 0.9995 + cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", (new_sl, pid)) + logger.info(f"[{symbol}] 📝 TP1触发 SHORT @ {current_price:.2f}, SL移至成本{new_sl:.2f}") + elif tp1_hit and current_price <= tp2: + closed = True + new_status = "tp" + pnl_r = 2.25 + + # 时间止损:60分钟 + if not closed and (now_ms - entry_ts > 60 * 60 * 1000): + closed = True + new_status = "timeout" + if direction == "LONG": + move = (current_price - entry_price) / entry_price + else: + move = (entry_price - current_price) / entry_price + risk_pct = abs(sl - entry_price) / entry_price + pnl_r = move / risk_pct if risk_pct > 0 else 0 + if tp1_hit: + pnl_r = max(pnl_r, 0.5 * 1.5) + + if closed: + cur.execute( + "UPDATE paper_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s", + (new_status, current_price, now_ms, round(pnl_r, 4), pid) + ) + logger.info(f"[{symbol}] 📝 模拟平仓: {direction} @ {current_price:.2f} status={new_status} pnl={pnl_r:+.2f}R") + + conn.commit() + + +def paper_has_active_position(symbol: str) -> bool: + """检查该币种是否有活跃持仓""" + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", (symbol,)) + return cur.fetchone()[0] > 0 + + # ─── 主循环 ────────────────────────────────────────────────────── def main(): @@ -496,6 +607,17 @@ def main(): if result.get("signal"): logger.info(f"[{sym}] 🚨 信号: {result['signal']} score={result['score']} price={result['price']:.1f}") + # 模拟盘开仓 + if not paper_has_active_position(sym): + paper_open_trade( + sym, result["signal"], result["price"], + result["score"], result.get("tier", "standard"), + result["atr"], now_ms + ) + + # 模拟盘持仓检查(每次循环都检查,不管有没有新信号) + if result.get("price") and result["price"] > 0: + paper_check_positions(sym, result["price"], now_ms) cycle += 1 if cycle % 60 == 0: diff --git a/frontend/app/paper/page.tsx b/frontend/app/paper/page.tsx new file mode 100644 index 0000000..5f923a7 --- /dev/null +++ b/frontend/app/paper/page.tsx @@ -0,0 +1,298 @@ +"use client"; +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { authFetch } from "@/lib/auth"; +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts"; + +// ─── 工具函数 ──────────────────────────────────────────────────── + +function bjt(ms: number) { + const d = new Date(ms + 8 * 3600 * 1000); + return `${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; +} + +function fmtPrice(p: number) { + return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); +} + +// ─── 总览面板 ──────────────────────────────────────────────────── + +function SummaryCards() { + 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); + }, []); + if (!data) return
加载中...
; + return ( +
+
+

总盈亏(R)

+

= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl >= 0 ? "+" : ""}{data.total_pnl}

+
+
+

胜率

+

{data.win_rate}%

+
+
+

总交易

+

{data.total_trades}

+
+
+

持仓中

+

{data.active_positions}

+
+
+

盈亏比(PF)

+

{data.profit_factor}

+
+
+

运行

+

{data.start_time ? "运行中 ✅" : "等待首笔"}

+
+
+ ); +} + +// ─── 当前持仓 ──────────────────────────────────────────────────── + +function ActivePositions() { + const [positions, setPositions] = useState([]); + 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, 5000); return () => clearInterval(iv); + }, []); + + if (positions.length === 0) return ( +
+ 暂无活跃持仓 +
+ ); + + return ( +
+
+

当前持仓

+
+
+ {positions.map((p: any) => { + const sym = p.symbol?.replace("USDT", "") || ""; + const holdMin = Math.round((Date.now() - p.entry_ts) / 60000); + return ( +
+
+
+ + {p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction} + + 评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"} +
+ {holdMin}分钟 +
+
+ 入场: ${fmtPrice(p.entry_price)} + TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""} + TP2: ${fmtPrice(p.tp2_price)} + SL: ${fmtPrice(p.sl_price)} +
+
+ ); + })} +
+
+ ); +} + +// ─── 权益曲线 ──────────────────────────────────────────────────── + +function EquityCurve() { + 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; + + return ( +
+
+

权益曲线 (累计PnL)

+
+
+ + + bjt(v)} tick={{ fontSize: 10 }} /> + `${v}R`} /> + bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} /> + + + + +
+
+ ); +} + +// ─── 历史交易列表 ──────────────────────────────────────────────── + +type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL"; +type FilterResult = "all" | "win" | "loss"; + +function TradeHistory() { + const [trades, setTrades] = useState([]); + const [symbol, setSymbol] = useState("all"); + const [result, setResult] = useState("all"); + + useEffect(() => { + const f = async () => { + try { + const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&limit=50`); + if (r.ok) { const j = await r.json(); setTrades(j.data || []); } + } catch {} + }; + f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); + }, [symbol, result]); + + return ( +
+
+

历史交易

+
+ {(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map(s => ( + + ))} + | + {(["all", "win", "loss"] as FilterResult[]).map(r => ( + + ))} +
+
+
+ {trades.length === 0 ? ( +
暂无交易记录
+ ) : ( + + + + + + + + + + + + + + + {trades.map((t: any) => { + const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0; + return ( + + + + + + + + + + + ); + })} + +
币种方向入场出场PnL(R)状态分数时间
{t.symbol?.replace("USDT", "")} + {t.direction === "LONG" ? "🟢" : "🔴"} {t.direction} + {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.status === "tp" ? "止盈" : t.status === "sl" ? "止损" : t.status === "sl_be" ? "保本" : t.status === "timeout" ? "超时" : t.status} + + {t.score}{holdMin}m
+ )} +
+
+ ); +} + +// ─── 统计面板 ──────────────────────────────────────────────────── + +function StatsPanel() { + const [data, setData] = useState(null); + useEffect(() => { + const f = async () => { try { const r = await authFetch("/api/paper/stats"); if (r.ok) setData(await r.json()); } catch {} }; + f(); const iv = setInterval(f, 30000); return () => clearInterval(iv); + }, []); + + if (!data || data.error) return null; + + return ( +
+
+

详细统计

+
+
+
胜率

{data.win_rate}%

+
盈亏比

{data.win_loss_ratio}

+
平均盈利

+{data.avg_win}R

+
平均亏损

-{data.avg_loss}R

+
最大回撤

{data.mdd}R

+
夏普比率

{data.sharpe}

+
做多胜率

{data.long_win_rate}% ({data.long_count}笔)

+
做空胜率

{data.short_win_rate}% ({data.short_count}笔)

+ {data.by_symbol && Object.entries(data.by_symbol).map(([s, v]: [string, any]) => ( +
{s}胜率

{v.win_rate}% ({v.total}笔)

+ ))} + {data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => ( +
{t === "heavy" ? "加仓" : t === "standard" ? "标准" : "轻仓"}档

{v.win_rate}% ({v.total}笔)

+ ))} +
+
+ ); +} + +// ─── 主页面 ────────────────────────────────────────────────────── + +export default function PaperTradingPage() { + const [isLoggedIn, setIsLoggedIn] = useState(false); + useEffect(() => { + const token = typeof window !== "undefined" ? localStorage.getItem("token") : null; + setIsLoggedIn(!!token); + }, []); + + if (!isLoggedIn) return ( +
+
🔒
+

请先登录查看模拟盘

+ 登录 +
+ ); + + return ( +
+
+

📊 模拟盘

+

V5.1信号引擎自动交易 · 实时追踪 · 数据驱动优化

+
+ + + + + + +
+ ); +} diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index ddff7a9..4764b85 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -7,13 +7,14 @@ import { useAuth } from "@/lib/auth"; import { LayoutDashboard, Info, Menu, X, Zap, LogIn, UserPlus, - ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor + ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart } from "lucide-react"; const navItems = [ { href: "/", label: "仪表盘", icon: LayoutDashboard }, { href: "/trades", label: "成交流", icon: Activity }, { href: "/signals", label: "信号引擎 V5.1", icon: Crosshair }, + { href: "/paper", label: "模拟盘", icon: LineChart }, { href: "/server", label: "服务器", icon: Monitor }, { href: "/about", label: "说明", icon: Info }, ];