diff --git a/backend/main.py b/backend/main.py index 2e08f91..1df003b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -688,10 +688,32 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us @app.get("/api/paper/summary") async def paper_summary( strategy: str = "all", + strategy_id: str = "all", user: dict = Depends(get_current_user), ): """模拟盘总览""" - if strategy == "all": + if strategy_id != "all": + closed = await async_fetch( + "SELECT pnl_r, direction FROM paper_trades " + "WHERE status NOT IN ('active','tp1_hit') AND strategy_id = $1", + strategy_id, + ) + active = await async_fetch( + "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy_id = $1", + strategy_id, + ) + first = await async_fetchrow( + "SELECT MIN(created_at) as start FROM paper_trades WHERE strategy_id = $1", + strategy_id, + ) + # 从 strategies 表取该策略的 initial_balance + strat_row = await async_fetchrow( + "SELECT initial_balance FROM strategies WHERE strategy_id = $1", + strategy_id, + ) + initial_balance = float(strat_row["initial_balance"]) if strat_row else paper_config["initial_balance"] + risk_per_trade = paper_config["risk_per_trade"] + elif strategy == "all": closed = await async_fetch( "SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" ) @@ -699,6 +721,8 @@ async def paper_summary( "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')" ) first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades") + initial_balance = paper_config["initial_balance"] + risk_per_trade = paper_config["risk_per_trade"] else: closed = await async_fetch( "SELECT pnl_r, direction FROM paper_trades " @@ -713,13 +737,15 @@ async def paper_summary( "SELECT MIN(created_at) as start FROM paper_trades WHERE strategy = $1", strategy, ) + initial_balance = paper_config["initial_balance"] + risk_per_trade = paper_config["risk_per_trade"] 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) - paper_1r_usd = paper_config["initial_balance"] * paper_config["risk_per_trade"] + paper_1r_usd = initial_balance * risk_per_trade total_pnl_usdt = total_pnl * paper_1r_usd - balance = paper_config["initial_balance"] + total_pnl_usdt + balance = initial_balance + total_pnl_usdt 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)) @@ -740,18 +766,26 @@ async def paper_summary( @app.get("/api/paper/positions") async def paper_positions( strategy: str = "all", + strategy_id: str = "all", user: dict = Depends(get_current_user), ): """当前活跃持仓(含实时价格和浮动盈亏)""" - if strategy == "all": + if strategy_id != "all": rows = await async_fetch( - "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, " + "SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, " + "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance " + "FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy_id = $1 ORDER BY entry_ts DESC", + strategy_id, + ) + elif strategy == "all": + rows = await async_fetch( + "SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, " "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance " "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, " + "SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, " "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance " "FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 ORDER BY entry_ts DESC", strategy, @@ -809,6 +843,7 @@ async def paper_trades( symbol: str = "all", result: str = "all", strategy: str = "all", + strategy_id: str = "all", limit: int = 100, user: dict = Depends(get_current_user), ): @@ -827,7 +862,11 @@ async def paper_trades( elif result == "loss": conditions.append("pnl_r <= 0") - if strategy != "all": + if strategy_id != "all": + conditions.append(f"strategy_id = ${idx}") + params.append(strategy_id) + idx += 1 + elif strategy != "all": conditions.append(f"strategy = ${idx}") params.append(strategy) idx += 1 @@ -835,7 +874,7 @@ async def paper_trades( where = " AND ".join(conditions) params.append(limit) rows = await async_fetch( - f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, " + f"SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, exit_price, " f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors " f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}", *params @@ -846,10 +885,17 @@ async def paper_trades( @app.get("/api/paper/equity-curve") async def paper_equity_curve( strategy: str = "all", + strategy_id: str = "all", user: dict = Depends(get_current_user), ): """权益曲线""" - if strategy == "all": + if strategy_id != "all": + rows = await async_fetch( + "SELECT exit_ts, pnl_r FROM paper_trades " + "WHERE status NOT IN ('active','tp1_hit') AND strategy_id = $1 ORDER BY exit_ts ASC", + strategy_id, + ) + elif 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" @@ -871,10 +917,17 @@ async def paper_equity_curve( @app.get("/api/paper/stats") async def paper_stats( strategy: str = "all", + strategy_id: str = "all", user: dict = Depends(get_current_user), ): """详细统计""" - if strategy == "all": + if strategy_id != "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') AND strategy_id = $1", + strategy_id, + ) + elif 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')" diff --git a/backend/signal_engine.py b/backend/signal_engine.py index cbc212a..bab8d82 100644 --- a/backend/signal_engine.py +++ b/backend/signal_engine.py @@ -554,7 +554,7 @@ class SymbolState: # v53 → 统一评分(BTC/ETH/XRP/SOL) # v53_alt / v53_btc → 兼容旧策略名,转发到 _evaluate_v53() # v51/v52 → 原有代码路径(兼容,不修改) - if strategy_name.startswith("v53") or strategy_name.startswith("custom_"): + if strategy_name.startswith("v53"): allowed_symbols = strategy_cfg.get("symbols", []) if allowed_symbols and self.symbol not in allowed_symbols: snap = snapshot or self.build_evaluation_snapshot(now_ms) @@ -815,6 +815,16 @@ class SymbolState: "signal": None, "direction": None, "score": 0, "tier": None, "factors": {}, } + +def _window_ms(window_str: str) -> int: + """把CVD窗口字符串转换为毫秒,如 '5m'->300000, '1h'->3600000, '4h'->14400000""" + window_str = (window_str or "30m").strip().lower() + if window_str.endswith("h"): + return int(window_str[:-1]) * 3600 * 1000 + elif window_str.endswith("m"): + return int(window_str[:-1]) * 60 * 1000 + return 30 * 60 * 1000 # fallback 30min + def _evaluate_v53(self, now_ms: int, strategy_cfg: dict, snapshot: Optional[dict] = None) -> dict: """ V5.3 统一评分(BTC/ETH/XRP/SOL) @@ -828,31 +838,36 @@ class SymbolState: strategy_name = strategy_cfg.get("name", "v53") strategy_threshold = int(strategy_cfg.get("threshold", 75)) flip_threshold = int(strategy_cfg.get("flip_threshold", 85)) - is_fast = strategy_name.endswith("_fast") snap = snapshot or self.build_evaluation_snapshot(now_ms) - # v53_fast: 用自定义短窗口重算 cvd_fast / cvd_mid - if is_fast: - fast_ms = int(strategy_cfg.get("cvd_window_fast_ms", 5 * 60 * 1000)) - mid_ms = int(strategy_cfg.get("cvd_window_mid_ms", 30 * 60 * 1000)) + # 按策略配置的 cvd_fast_window / cvd_slow_window 动态切片重算CVD + # 支持 5m/15m/30m/1h/4h 所有组合 + cvd_fast_window = strategy_cfg.get("cvd_fast_window", "30m") + cvd_slow_window = strategy_cfg.get("cvd_slow_window", "4h") + fast_ms = _window_ms(cvd_fast_window) + slow_ms = _window_ms(cvd_slow_window) + # 默认窗口 (30m/4h) 直接用快照,否则从 trades 列表切片重算 + if cvd_fast_window == "30m" and cvd_slow_window == "4h": + cvd_fast = snap["cvd_fast"] + cvd_mid = snap["cvd_mid"] + else: cutoff_fast = now_ms - fast_ms - cutoff_mid = now_ms - mid_ms + cutoff_slow = now_ms - slow_ms buy_f = sell_f = buy_m = sell_m = 0.0 - for t_ms, qty, _price, ibm in self.win_fast.trades: + # fast: 从 win_fast (30min) 或 win_mid (4h) 中切片 + src_fast = self.win_mid if fast_ms > WINDOW_FAST else self.win_fast + for t_ms, qty, _price, ibm in src_fast.trades: if t_ms >= cutoff_fast: if ibm == 0: buy_f += qty else: sell_f += qty - # mid 从 win_mid 中读(win_mid 窗口是4h,包含30min内数据) + # slow: 从 win_mid (4h) 中切片 for t_ms, qty, _price, ibm in self.win_mid.trades: - if t_ms >= cutoff_mid: + if t_ms >= cutoff_slow: if ibm == 0: buy_m += qty else: sell_m += qty cvd_fast = buy_f - sell_f cvd_mid = buy_m - sell_m - else: - cvd_fast = snap["cvd_fast"] - cvd_mid = snap["cvd_mid"] price = snap["price"] atr = snap["atr"] diff --git a/frontend/app/strategy-plaza/[id]/PaperGeneric.tsx b/frontend/app/strategy-plaza/[id]/PaperGeneric.tsx new file mode 100644 index 0000000..b0ebf13 --- /dev/null +++ b/frontend/app/strategy-plaza/[id]/PaperGeneric.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { authFetch } from "@/lib/auth"; +import { 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 }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseFactors(raw: any) { + if (!raw) return null; + if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return null; } } + return raw; +} + +interface Props { + strategyId: string; + symbol: string; +} + +type FilterResult = "all" | "win" | "loss"; +type FilterSymbol = "all" | string; + +// ─── 控制面板(策略启停)───────────────────────────────────────── + +function ControlPanel({ strategyId }: { strategyId: string }) { + const [status, setStatus] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + (async () => { + try { + const r = await authFetch(`/api/strategies/${strategyId}`); + if (r.ok) { const j = await r.json(); setStatus(j.status); } + } catch {} + })(); + }, [strategyId]); + + const toggle = async () => { + setSaving(true); + const newStatus = status === "running" ? "paused" : "running"; + try { + const r = await authFetch(`/api/strategies/${strategyId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + if (r.ok) setStatus(newStatus); + } catch {} finally { setSaving(false); } + }; + + if (!status) return null; + return ( +
+
+ + + {status === "running" ? "🟢 运行中" : "⚪ 已暂停"} + +
+
+ ); +} + +// ─── 总览卡片 ──────────────────────────────────────────────────── + +function SummaryCards({ strategyId }: { strategyId: string }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [data, setData] = useState(null); + useEffect(() => { + const f = async () => { + try { + const r = await authFetch(`/api/paper/summary?strategy_id=${strategyId}`); + if (r.ok) setData(await r.json()); + } catch {} + }; + f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); + }, [strategyId]); + if (!data) return
加载中...
; + return ( +
+ {[ + { label: "总盈亏(R)", value: `${data.total_pnl >= 0 ? "+" : ""}${data.total_pnl}R`, sub: `${data.total_pnl_usdt >= 0 ? "+" : ""}$${data.total_pnl_usdt}`, color: data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500" }, + { label: "胜率", value: `${data.win_rate}%`, sub: `共${data.total_trades}笔`, color: "text-slate-800" }, + { label: "持仓中", value: data.active_positions, sub: "活跃仓位", color: "text-blue-600" }, + { label: "盈亏比", value: data.profit_factor, sub: "PF", color: "text-slate-800" }, + { label: "当前资金", value: `$${data.balance?.toLocaleString()}`, sub: "虚拟余额", color: data.balance >= 10000 ? "text-emerald-600" : "text-red-500" }, + { label: "状态", value: data.start_time ? "运行中 ✅" : "等待首笔", sub: "accumulating", color: "text-slate-600" }, + ].map(({ label, value, sub, color }) => ( +
+

{label}

+

{value}

+

{sub}

+
+ ))} +
+ ); +} + +// ─── 当前持仓 ──────────────────────────────────────────────────── + +function ActivePositions({ strategyId }: { strategyId: string }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [positions, setPositions] = useState([]); + const [wsPrices, setWsPrices] = useState>({}); + const RISK_USD = 200; // 1R = 200 USDT + + useEffect(() => { + const f = async () => { + try { + const r = await authFetch(`/api/paper/positions?strategy_id=${strategyId}`); + if (r.ok) setPositions((await r.json()).data || []); + } catch {} + }; + f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); + }, [strategyId]); + + useEffect(() => { + const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map(s => `${s}@aggTrade`).join("/"); + const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.data) { const sym = msg.data.s; const price = parseFloat(msg.data.p); if (sym && price > 0) setWsPrices(prev => ({ ...prev, [sym]: price })); } } catch {} }; + return () => ws.close(); + }, []); + + if (positions.length === 0) return
暂无活跃持仓
; + return ( +
+

当前持仓 ● 实时

+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {positions.map((p: any) => { + const sym = p.symbol?.replace("USDT", "") || ""; + const holdMin = Math.round((Date.now() - p.entry_ts) / 60000); + const currentPrice = wsPrices[p.symbol] || p.current_price || 0; + const entry = p.entry_price || 0; + const riskDist = p.risk_distance || Math.abs(entry - (p.sl_price || entry)) || 1; + const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0; + const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0; + const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR; + const unrealUsdt = unrealR * RISK_USD; + return ( +
+
+
+ {p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction} + 评分{p.score} +
+
+ = 0 ? "text-emerald-600" : "text-red-500"}`}>{unrealR >= 0 ? "+" : ""}{unrealR.toFixed(2)}R + = 0 ? "text-emerald-500" : "text-red-400"}`}>${unrealUsdt.toFixed(0)} + {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)} +
+
+ 入场: {p.entry_ts ? bjt(p.entry_ts) : "-"} +
+
+ ); + })} +
+
+ ); +} + +// ─── 权益曲线 ──────────────────────────────────────────────────── + +function EquityCurve({ strategyId }: { strategyId: string }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [data, setData] = useState([]); + useEffect(() => { + const f = async () => { + try { + const r = await authFetch(`/api/paper/equity-curve?strategy_id=${strategyId}`); + if (r.ok) setData((await r.json()).data || []); + } catch {} + }; + f(); const iv = setInterval(f, 30000); return () => clearInterval(iv); + }, [strategyId]); + return ( +
+

权益曲线

+ {data.length < 2 ?
数据积累中...
: ( +
+ + + bjt(Number(v))} tick={{ fontSize: 10 }} /> + `${v}R`} /> + bjt(Number(v))} formatter={(v: unknown) => [`${v}R`, "累计PnL"]} /> + + + + +
+ )} +
+ ); +} + +// ─── 历史交易 ──────────────────────────────────────────────────── + +function TradeHistory({ strategyId }: { strategyId: string }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [trades, setTrades] = useState([]); + const [filterResult, setFilterResult] = useState("all"); + const [filterSym, setFilterSym] = useState("all"); + + useEffect(() => { + const f = async () => { + try { + const r = await authFetch(`/api/paper/trades?strategy_id=${strategyId}&result=${filterResult}&symbol=${filterSym}&limit=50`); + if (r.ok) setTrades((await r.json()).data || []); + } catch {} + }; + f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); + }, [strategyId, filterResult, filterSym]); + + const fmtTime = (ms: number) => ms ? bjt(ms) : "-"; + const STATUS_LABEL: Record = { tp: "止盈", sl: "止损", sl_be: "保本", timeout: "超时", signal_flip: "翻转" }; + const STATUS_COLOR: Record = { tp: "bg-emerald-100 text-emerald-700", sl: "bg-red-100 text-red-700", sl_be: "bg-amber-100 text-amber-700", signal_flip: "bg-purple-100 text-purple-700", timeout: "bg-slate-100 text-slate-600" }; + + return ( +
+
+

历史交易

+
+ {(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map(s => ( + + ))} + | + {(["all", "win", "loss"] as FilterResult[]).map(r => ( + + ))} +
+
+
+ {trades.length === 0 ?
暂无交易记录
: ( + + + + + + + + + + + + + + + + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {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)}{STATUS_LABEL[t.status] || t.status}{t.score}{fmtTime(t.entry_ts)}{fmtTime(t.exit_ts)}{holdMin}m
+ )} +
+
+ ); +} + +// ─── 详细统计 ──────────────────────────────────────────────────── + +function StatsPanel({ strategyId }: { strategyId: string }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [data, setData] = useState(null); + const [tab, setTab] = useState("ALL"); + useEffect(() => { + const f = async () => { + try { + const r = await authFetch(`/api/paper/stats?strategy_id=${strategyId}`); + if (r.ok) setData(await r.json()); + } catch {} + }; + f(); const iv = setInterval(f, 30000); return () => clearInterval(iv); + }, [strategyId]); + if (!data || data.error) return ( +
+

详细统计

+
等待交易记录积累...
+
+ ); + const coinTabs = ["ALL", "BTC", "ETH", "XRP", "SOL"]; + const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || null); + return ( +
+
+

详细统计

+
+ {coinTabs.map(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}笔)

+
+
+ ) :
该币种暂无数据
} +
+ ); +} + +// ─── 主组件 ────────────────────────────────────────────────────── + +export default function PaperGeneric({ strategyId, symbol }: Props) { + return ( +
+
+

📈 模拟盘

+

{symbol.replace("USDT", "")} · strategy_id: {strategyId.slice(0, 8)}...

+
+ + + + + + +
+ ); +} diff --git a/frontend/app/strategy-plaza/[id]/SignalsGeneric.tsx b/frontend/app/strategy-plaza/[id]/SignalsGeneric.tsx new file mode 100644 index 0000000..a0d555e --- /dev/null +++ b/frontend/app/strategy-plaza/[id]/SignalsGeneric.tsx @@ -0,0 +1,446 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { authFetch } from "@/lib/auth"; +import { + ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, + ReferenceLine, CartesianGrid, Legend +} from "recharts"; + +interface IndicatorRow { + ts: number; + cvd_fast: number; + cvd_mid: number; + cvd_day: number; + atr_5m: number; + vwap_30m: number; + price: number; + score: number; + signal: string | null; +} + +interface LatestIndicator { + ts: number; + cvd_fast: number; + cvd_mid: number; + cvd_day: number; + cvd_fast_slope: number; + atr_5m: number; + atr_percentile: number; + vwap_30m: number; + price: number; + p95_qty: number; + p99_qty: number; + score: number; + signal: string | null; + tier?: "light" | "standard" | "heavy" | null; + gate_passed?: boolean; + factors?: { + gate_passed?: boolean; + gate_block?: string; + block_reason?: string; + obi_raw?: number; + spot_perp_div?: number; + whale_cvd_ratio?: number; + atr_pct_price?: number; + direction?: { score?: number; max?: number }; + crowding?: { score?: number; max?: number }; + environment?: { score?: number; max?: number }; + auxiliary?: { score?: number; max?: number }; + } | null; +} + +interface SignalRecord { + ts: number; + score: number; + signal: string; +} + +interface Gates { + obi_threshold: number; + whale_usd_threshold: number; + whale_flow_pct: number; + vol_atr_pct_min: number; + spot_perp_threshold: number; +} + +interface Weights { + direction: number; + env: number; + aux: number; + momentum: number; +} + +interface Props { + strategyId: string; + symbol: string; + cvdFastWindow: string; + cvdSlowWindow: string; + weights: Weights; + gates: Gates; +} + +const WINDOWS = [ + { label: "1h", value: 60 }, + { label: "4h", value: 240 }, + { label: "12h", value: 720 }, + { label: "24h", value: 1440 }, +]; + +function bjtStr(ms: number) { + const d = new Date(ms + 8 * 3600 * 1000); + return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; +} + +function bjtFull(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")}:${String(d.getUTCSeconds()).padStart(2, "0")}`; +} + +function fmt(v: number, decimals = 1): string { + if (Math.abs(v) >= 1000000) return `${(v / 1000000).toFixed(1)}M`; + if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}K`; + return v.toFixed(decimals); +} + +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 ( +
+ {label} +
+
+
+ {score}/{max} +
+ ); +} + +function GateCard({ factors, gates }: { factors: LatestIndicator["factors"]; gates: Gates }) { + if (!factors) return null; + const passed = factors.gate_passed ?? true; + const blockReason = factors.gate_block || factors.block_reason; + return ( +
+
+

🔒 Gate-Control

+ + {passed ? "✅ Gate通过" : "❌ 否决"} + +
+
+
+

波动率

+

{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%

+

需 ≥{(gates.vol_atr_pct_min * 100).toFixed(2)}%

+
+
+

OBI

+

= 0 ? "text-emerald-600" : "text-red-500"}`}> + {((factors.obi_raw ?? 0) * 100).toFixed(2)}% +

+

否决±{gates.obi_threshold}

+
+
+

期现背离

+

= 0 ? "text-emerald-600" : "text-red-500"}`}> + {((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps +

+

否决±{(gates.spot_perp_threshold * 100).toFixed(1)}%

+
+
+

鲸鱼

+

${(gates.whale_usd_threshold / 1000).toFixed(0)}k

+

{">"}{(gates.whale_flow_pct * 100).toFixed(0)}%占比

+
+
+ {blockReason && ( +

+ 否决原因: {blockReason} +

+ )} +
+ ); +} + +function IndicatorCards({ sym, strategyName, cvdFastWindow, cvdSlowWindow, weights, gates }: { + sym: string; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string; weights: Weights; gates: Gates; +}) { + const [data, setData] = useState(null); + const coin = sym.replace("USDT", "") as "BTC" | "ETH" | "XRP" | "SOL"; + + useEffect(() => { + const fetch_ = async () => { + try { + const res = await authFetch(`/api/signals/latest?strategy=${strategyName}`); + if (!res.ok) return; + const json = await res.json(); + setData(json[coin] || null); + } catch {} + }; + fetch_(); + const iv = setInterval(fetch_, 5000); + return () => clearInterval(iv); + }, [coin, strategyName]); + + if (!data) return
等待指标数据...
; + + const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方"; + const totalWeight = weights.direction + weights.env + weights.aux + weights.momentum; + + return ( +
+ {/* CVD双轨 */} +
+
+

CVD_fast ({cvdFastWindow})

+

= 0 ? "text-emerald-600" : "text-red-500"}`}> + {fmt(data.cvd_fast)} +

+

+ 斜率: = 0 ? "text-emerald-600" : "text-red-500"}> + {data.cvd_fast_slope >= 0 ? "↑" : "↓"}{fmt(Math.abs(data.cvd_fast_slope))} + +

+
+
+

CVD_slow ({cvdSlowWindow})

+

= 0 ? "text-emerald-600" : "text-red-500"}`}> + {fmt(data.cvd_mid)} +

+

{data.cvd_mid > 0 ? "多" : "空"}头占优

+
+
+

CVD共振

+

= 0 && data.cvd_mid >= 0 ? "text-emerald-600" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "text-red-500" : "text-slate-400"}`}> + {data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "✅ 多头共振" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "✅ 空头共振" : "⚠️ 分歧"} +

+

双周期共振

+
+
+ + {/* ATR + VWAP + P95/P99 */} +
+
+

ATR

+

${fmt(data.atr_5m, 2)}

+

+ 60 ? "text-amber-600 font-semibold" : "text-slate-400"}> + {data.atr_percentile.toFixed(0)}%{data.atr_percentile > 60 ? "🔥" : ""} + +

+
+
+

VWAP

+

${data.vwap_30m.toLocaleString("en-US", { maximumFractionDigits: 1 })}

+

价格在 data.vwap_30m ? "text-emerald-600" : "text-red-500"}>{priceVsVwap}

+
+
+

P95

+

{data.p95_qty?.toFixed(4) ?? "-"}

+

大单阈值

+
+
+

P99

+

{data.p99_qty?.toFixed(4) ?? "-"}

+

超大单

+
+
+ + {/* 信号状态 + 四层分 */} +
+
+
+

四层评分 · {coin}

+

+ {data.signal === "LONG" ? "🟢 做多" : data.signal === "SHORT" ? "🔴 做空" : "⚪ 无信号"} +

+
+
+

{data.score}/{totalWeight}

+

+ {data.tier === "heavy" ? "加仓" : data.tier === "standard" ? "标准" : "不开仓"} +

+
+
+
+ + + + +
+
+ + {/* Gate 卡片 */} + +
+ ); +} + +function SignalHistory({ coin, strategyName }: { coin: string; strategyName: string }) { + const [data, setData] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + const res = await authFetch(`/api/signals/signal-history?symbol=${coin}&limit=20&strategy=${strategyName}`); + if (!res.ok) return; + const json = await res.json(); + setData(json.data || []); + } catch {} + }; + fetchData(); + const iv = setInterval(fetchData, 15000); + return () => clearInterval(iv); + }, [coin, strategyName]); + + if (data.length === 0) return null; + + return ( +
+
+

最近信号

+
+
+ {data.map((s, i) => ( +
+
+ + {s.signal === "LONG" ? "🟢 LONG" : "🔴 SHORT"} + + {bjtFull(s.ts)} +
+ {s.score} +
+ ))} +
+
+ ); +} + +function CVDChart({ sym, minutes, strategyName, cvdFastWindow, cvdSlowWindow }: { + sym: string; minutes: number; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string; +}) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const coin = sym.replace("USDT", ""); + + const fetchData = useCallback(async (silent = false) => { + try { + const res = await authFetch(`/api/signals/indicators?symbol=${coin}&minutes=${minutes}&strategy=${strategyName}`); + if (!res.ok) return; + const json = await res.json(); + setData(json.data || []); + if (!silent) setLoading(false); + } catch {} + }, [coin, minutes, strategyName]); + + useEffect(() => { + setLoading(true); + fetchData(); + const iv = setInterval(() => fetchData(true), 30000); + return () => clearInterval(iv); + }, [fetchData]); + + const chartData = data.map(d => ({ + time: bjtStr(d.ts), + fast: parseFloat(d.cvd_fast?.toFixed(2) || "0"), + mid: parseFloat(d.cvd_mid?.toFixed(2) || "0"), + price: d.price, + })); + + const prices = chartData.map(d => d.price).filter(v => v > 0); + const pMin = prices.length ? Math.min(...prices) : 0; + const pMax = prices.length ? Math.max(...prices) : 0; + const pPad = (pMax - pMin) * 0.3 || pMax * 0.001; + + if (loading) return
加载指标数据...
; + if (data.length === 0) return
暂无指标数据,等待积累...
; + + return ( + + + + + + v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${v.toFixed(0)}`} + /> + { + if (name === "price") return [`$${Number(v).toLocaleString()}`, "币价"]; + if (name === "fast") return [fmt(Number(v)), `CVD_fast(${cvdFastWindow})`]; + return [fmt(Number(v)), `CVD_slow(${cvdSlowWindow})`]; + }} + contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }} + /> + + + + + + + + ); +} + +export default function SignalsGeneric({ strategyId, symbol, cvdFastWindow, cvdSlowWindow, weights, gates }: Props) { + const [minutes, setMinutes] = useState(240); + const coin = symbol.replace("USDT", ""); + const strategyName = `custom_${strategyId.slice(0, 8)}`; + + return ( +
+
+
+

⚡ 信号引擎

+

+ CVD {cvdFastWindow}/{cvdSlowWindow} · 权重 {weights.direction}/{weights.env}/{weights.aux}/{weights.momentum} · {coin} +

+
+ {coin} +
+ + + + + +
+
+
+

CVD双轨 + 币价

+

蓝=fast({cvdFastWindow}) · 紫=slow({cvdSlowWindow}) · 橙=价格

+
+
+ {WINDOWS.map(w => ( + + ))} +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/app/strategy-plaza/[id]/page.tsx b/frontend/app/strategy-plaza/[id]/page.tsx index e886199..3216f5e 100644 --- a/frontend/app/strategy-plaza/[id]/page.tsx +++ b/frontend/app/strategy-plaza/[id]/page.tsx @@ -21,6 +21,8 @@ const SignalsV53Middle = dynamic(() => import("@/app/signals-v53middle/page"), { const PaperV53 = dynamic(() => import("@/app/paper-v53/page"), { ssr: false }); const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false }); const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false }); +const SignalsGeneric = dynamic(() => import("./SignalsGeneric"), { ssr: false }); +const PaperGeneric = dynamic(() => import("./PaperGeneric"), { ssr: false }); // ─── UUID → legacy strategy name map ───────────────────────────── const UUID_TO_LEGACY: Record = { @@ -51,6 +53,7 @@ interface StrategySummary { cvd_fast_window?: string; cvd_slow_window?: string; description?: string; + symbol?: string; } interface StrategyDetail { @@ -182,20 +185,42 @@ function ConfigTab({ detail, strategyId }: { detail: StrategyDetail; strategyId: } // ─── Content router ─────────────────────────────────────────────── -function SignalsContent({ strategyId }: { strategyId: string }) { +function SignalsContent({ strategyId, symbol, detail }: { strategyId: string; symbol?: string; detail?: StrategyDetail | null }) { const legacy = UUID_TO_LEGACY[strategyId] || strategyId; if (legacy === "v53") return ; if (legacy === "v53_fast") return ; if (legacy === "v53_middle") return ; - return
暂无信号引擎页面
; + const weights = detail ? { + direction: detail.weight_direction, + env: detail.weight_env, + aux: detail.weight_aux, + momentum: detail.weight_momentum, + } : { direction: 38, env: 32, aux: 28, momentum: 2 }; + const gates = detail ? { + obi_threshold: detail.obi_threshold, + whale_usd_threshold: detail.whale_usd_threshold, + whale_flow_pct: detail.whale_flow_pct, + vol_atr_pct_min: detail.vol_atr_pct_min, + spot_perp_threshold: detail.spot_perp_threshold, + } : { obi_threshold: 0.3, whale_usd_threshold: 100000, whale_flow_pct: 0.5, vol_atr_pct_min: 0.002, spot_perp_threshold: 0.003 }; + return ( + + ); } -function PaperContent({ strategyId }: { strategyId: string }) { +function PaperContent({ strategyId, symbol }: { strategyId: string; symbol?: string }) { const legacy = UUID_TO_LEGACY[strategyId] || strategyId; if (legacy === "v53") return ; if (legacy === "v53_fast") return ; if (legacy === "v53_middle") return ; - return
暂无模拟盘页面
; + return ; } // ─── Main Page ──────────────────────────────────────────────────── @@ -350,8 +375,8 @@ export default function StrategyDetailPage() { {/* Content */}
- {tab === "signals" && } - {tab === "paper" && } + {tab === "signals" && } + {tab === "paper" && } {tab === "config" && detail && } {tab === "config" && !detail && (
暂无配置信息