From a7600e8db13363801c0ad1f3d0f83f360d39ef71 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 11:54:54 +0000 Subject: [PATCH 1/7] Add V5.2 strategy configuration files --- backend/strategies/v51_baseline.json | 19 +++++++++++++++++++ backend/strategies/v52_8signals.json | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 backend/strategies/v51_baseline.json create mode 100644 backend/strategies/v52_8signals.json diff --git a/backend/strategies/v51_baseline.json b/backend/strategies/v51_baseline.json new file mode 100644 index 0000000..814d6bf --- /dev/null +++ b/backend/strategies/v51_baseline.json @@ -0,0 +1,19 @@ +{ + "name": "v51_baseline", + "version": "5.1", + "threshold": 75, + "weights": { + "direction": 45, + "crowding": 20, + "environment": 15, + "confirmation": 15, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"] +} diff --git a/backend/strategies/v52_8signals.json b/backend/strategies/v52_8signals.json new file mode 100644 index 0000000..5589f92 --- /dev/null +++ b/backend/strategies/v52_8signals.json @@ -0,0 +1,19 @@ +{ + "name": "v52_8signals", + "version": "5.2", + "threshold": 75, + "weights": { + "direction": 40, + "crowding": 25, + "environment": 15, + "confirmation": 20, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"] +} From 732b01691bbc710594a713bbeda60036b2e551ba Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 11:54:58 +0000 Subject: [PATCH 2/7] Implement V5.2 FR/liquidation scoring and strategy AB loop --- backend/signal_engine.py | 399 +++++++++++++++++++++++++++++++-------- 1 file changed, 318 insertions(+), 81 deletions(-) diff --git a/backend/signal_engine.py b/backend/signal_engine.py index cc7dde5..dff9345 100644 --- a/backend/signal_engine.py +++ b/backend/signal_engine.py @@ -18,6 +18,7 @@ signal_engine.py — V5 短线交易信号引擎(PostgreSQL版) import logging import os import time +import json from collections import deque from datetime import datetime, timezone from typing import Any, Optional @@ -36,6 +37,34 @@ logger = logging.getLogger("signal-engine") SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] LOOP_INTERVAL = 15 # 秒(从5改15,CPU降60%,信号质量无影响) +STRATEGY_DIR = os.path.join(os.path.dirname(__file__), "strategies") +DEFAULT_STRATEGY_FILES = ["v51_baseline.json", "v52_8signals.json"] + + +def load_strategy_configs() -> list[dict]: + configs = [] + for filename in DEFAULT_STRATEGY_FILES: + path = os.path.join(STRATEGY_DIR, filename) + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + if isinstance(cfg, dict) and cfg.get("name"): + configs.append(cfg) + except FileNotFoundError: + logger.warning(f"策略配置缺失: {path}") + except Exception as e: + logger.error(f"策略配置加载失败 {path}: {e}") + if not configs: + logger.warning("未加载到策略配置,回退到v51_baseline默认配置") + configs.append( + { + "name": "v51_baseline", + "threshold": 75, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"], + "tp_sl": {"sl_multiplier": 2.0, "tp1_multiplier": 1.5, "tp2_multiplier": 3.0}, + } + ) + return configs # ─── 模拟盘配置 ─────────────────────────────────────────────────── PAPER_TRADING_ENABLED = False # 开关(范总确认后通过API开启) @@ -85,7 +114,7 @@ def fetch_market_indicators(symbol: str) -> dict: with get_sync_conn() as conn: with conn.cursor() as cur: indicators = {} - for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium"]: + for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]: cur.execute( "SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1", (symbol, ind_type), @@ -111,6 +140,8 @@ def fetch_market_indicators(symbol: str) -> dict: indicators[ind_type] = float(val.get("sumOpenInterestValue", 0)) elif ind_type == "coinbase_premium": indicators[ind_type] = float(val.get("premium_pct", 0)) + elif ind_type == "funding_rate": + indicators[ind_type] = float(val.get("lastFundingRate", 0)) return indicators @@ -227,8 +258,8 @@ class SymbolState: self.prev_cvd_fast_slope = 0.0 self.prev_oi_value = 0.0 self.market_indicators = fetch_market_indicators(symbol) - self.last_signal_ts = 0 - self.last_signal_dir = "" + self.last_signal_ts: dict[str, int] = {} + self.last_signal_dir: dict[str, str] = {} self.recent_large_trades: deque = deque() def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int): @@ -268,9 +299,10 @@ class SymbolState: self.recent_large_trades.append((t[0], t[1], t[3])) seen.add(t[0]) - def evaluate_signal(self, now_ms: int) -> dict: + def build_evaluation_snapshot(self, now_ms: int) -> dict: cvd_fast = self.win_fast.cvd cvd_mid = self.win_mid.cvd + cvd_day = self.win_day.cvd vwap = self.win_vwap.vwap atr = self.atr_calc.atr atr_pct = self.atr_calc.atr_percentile @@ -282,11 +314,94 @@ class SymbolState: self.prev_cvd_fast = cvd_fast self.prev_cvd_fast_slope = cvd_fast_slope - result = { - "cvd_fast": cvd_fast, "cvd_mid": cvd_mid, "cvd_day": self.win_day.cvd, + oi_value = to_float(self.market_indicators.get("open_interest_hist")) + if oi_value is None or self.prev_oi_value == 0: + oi_change = 0.0 + environment_score = 10 + else: + oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0.0 + if oi_change >= 0.03: + environment_score = 15 + elif oi_change > 0: + environment_score = 10 + else: + environment_score = 5 + if oi_value is not None and oi_value > 0: + self.prev_oi_value = oi_value + + return { + "cvd_fast": cvd_fast, + "cvd_mid": cvd_mid, + "cvd_day": cvd_day, + "vwap": vwap, + "atr": atr, + "atr_pct": atr_pct, + "p95": p95, + "p99": p99, + "price": price, "cvd_fast_slope": cvd_fast_slope, - "atr": atr, "atr_pct": atr_pct, "vwap": vwap, "price": price, - "p95": p95, "p99": p99, "signal": None, "direction": None, "score": 0, + "cvd_fast_accel": cvd_fast_accel, + "oi_change": oi_change, + "environment_score": environment_score, + "oi_value": oi_value, + } + + def fetch_recent_liquidations(self, window_ms: int = 300000): + """Fetch last 5min liquidation totals from liquidations table""" + now_ms = int(time.time() * 1000) + cutoff = now_ms - window_ms + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + COALESCE(SUM(CASE WHEN side='SELL' THEN usd_value ELSE 0 END), 0) as long_liq, + COALESCE(SUM(CASE WHEN side='BUY' THEN usd_value ELSE 0 END), 0) as short_liq + FROM liquidations + WHERE symbol=%s AND trade_time >= %s + """, + (self.symbol, cutoff), + ) + row = cur.fetchone() + if row: + return {"long_usd": row[0], "short_usd": row[1]} + return None + + def evaluate_signal(self, now_ms: int, strategy_cfg: Optional[dict] = None, snapshot: Optional[dict] = None) -> dict: + strategy_cfg = strategy_cfg or {} + strategy_name = strategy_cfg.get("name", "v51_baseline") + strategy_threshold = int(strategy_cfg.get("threshold", 75)) + enabled_signals = set(strategy_cfg.get("signals", [])) + + snap = snapshot or self.build_evaluation_snapshot(now_ms) + cvd_fast = snap["cvd_fast"] + cvd_mid = snap["cvd_mid"] + vwap = snap["vwap"] + atr = snap["atr"] + atr_pct = snap["atr_pct"] + p95 = snap["p95"] + p99 = snap["p99"] + price = snap["price"] + cvd_fast_slope = snap["cvd_fast_slope"] + cvd_fast_accel = snap["cvd_fast_accel"] + oi_change = snap["oi_change"] + environment_score = snap["environment_score"] + + result = { + "strategy": strategy_name, + "cvd_fast": cvd_fast, + "cvd_mid": cvd_mid, + "cvd_day": snap["cvd_day"], + "cvd_fast_slope": cvd_fast_slope, + "atr": atr, + "atr_pct": atr_pct, + "vwap": vwap, + "price": price, + "p95": p95, + "p99": p99, + "signal": None, + "direction": None, + "score": 0, "tier": None, "factors": {}, } @@ -296,7 +411,8 @@ class SymbolState: # 判断倾向方向(用于评分展示,即使冷却或方向不一致也计算) no_direction = False - in_cooldown = (now_ms - self.last_signal_ts < COOLDOWN_MS) + last_signal_ts = self.last_signal_ts.get(strategy_name, 0) + in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS if cvd_fast > 0 and cvd_mid > 0: direction = "LONG" @@ -326,8 +442,10 @@ class SymbolState: elif not has_adverse_p99: direction_score += 10 accel_bonus = 0 - if (direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0): - accel_bonus = 5 + if "accel" in enabled_signals and ( + (direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0) + ): + accel_bonus = int(strategy_cfg.get("accel_bonus", 5)) # 2) 拥挤层(20分)- market_indicators缺失时给中间分 long_short_ratio = to_float(self.market_indicators.get("long_short_ratio")) @@ -358,24 +476,53 @@ class SymbolState: top_trader_score = 5 crowding_score = ls_score + top_trader_score - # 3) 环境层(15分)— OI变化率 - oi_value = to_float(self.market_indicators.get("open_interest_hist")) - if oi_value is None or self.prev_oi_value == 0: - environment_score = 10 - oi_change = 0.0 - else: - oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0 - if oi_change >= 0.03: - environment_score = 15 - elif oi_change > 0: - environment_score = 10 - else: - environment_score = 5 - if oi_value is not None and oi_value > 0: - self.prev_oi_value = oi_value + # Funding Rate scoring (拥挤层加分) + # Read from market_indicators table + funding_rate = to_float(self.market_indicators.get("funding_rate")) + fr_score = 0 + if "funding_rate" in enabled_signals and funding_rate is not None: + fr_abs = abs(funding_rate) + if fr_abs >= 0.001: # extreme ±0.1% + # Extreme: penalize if going WITH the crowd + if (direction == "LONG" and funding_rate > 0.001) or (direction == "SHORT" and funding_rate < -0.001): + fr_score = -5 + else: + fr_score = 5 + elif fr_abs >= 0.0003: # moderate ±0.03% + # Moderate: reward going AGAINST the crowd + if (direction == "LONG" and funding_rate < -0.0003) or (direction == "SHORT" and funding_rate > 0.0003): + fr_score = 5 + else: + fr_score = 0 # 4) 确认层(15分) - confirmation_score = 15 if ((direction == "LONG" and cvd_fast > 0 and cvd_mid > 0) or (direction == "SHORT" and cvd_fast < 0 and cvd_mid < 0)) else 0 + confirmation_score = 15 if ( + (direction == "LONG" and cvd_fast > 0 and cvd_mid > 0) + or (direction == "SHORT" and cvd_fast < 0 and cvd_mid < 0) + ) else 0 + + # Liquidation scoring (确认层加分) + liq_score = 0 + liq_data = None + if "liquidation" in enabled_signals: + liq_data = self.fetch_recent_liquidations() + if liq_data: + liq_long_usd = liq_data.get("long_usd", 0) + liq_short_usd = liq_data.get("short_usd", 0) + thresholds = {"BTCUSDT": 500000, "ETHUSDT": 200000, "XRPUSDT": 100000, "SOLUSDT": 100000} + threshold = thresholds.get(self.symbol, 100000) + total = liq_long_usd + liq_short_usd + if total >= threshold: + if liq_short_usd > 0 and liq_long_usd > 0: + ratio = liq_short_usd / liq_long_usd + elif liq_short_usd > 0: + ratio = float("inf") + else: + ratio = 0 + if ratio >= 2.0 and direction == "LONG": + liq_score = 5 + elif ratio <= 0.5 and direction == "SHORT": + liq_score = 5 # 5) 辅助层(5分) coinbase_premium = to_float(self.market_indicators.get("coinbase_premium")) @@ -388,7 +535,7 @@ class SymbolState: else: aux_score = 0 - total_score = direction_score + accel_bonus + crowding_score + environment_score + confirmation_score + aux_score + total_score = direction_score + accel_bonus + crowding_score + fr_score + environment_score + confirmation_score + liq_score + aux_score result["score"] = total_score result["direction"] = direction result["factors"] = { @@ -403,27 +550,31 @@ class SymbolState: "environment": {"score": environment_score, "open_interest_hist": oi_change}, "confirmation": {"score": confirmation_score}, "auxiliary": {"score": aux_score, "coinbase_premium": coinbase_premium}, + "funding_rate": {"score": fr_score, "value": funding_rate}, + "liquidation": { + "score": liq_score, + "long_usd": liq_data.get("long_usd", 0) if liq_data else 0, + "short_usd": liq_data.get("short_usd", 0) if liq_data else 0, + }, } # 始终输出direction供反向平仓判断(不受冷却限制) result["direction"] = direction if not no_direction else None - if total_score >= 85 and not no_direction and not in_cooldown: + heavy_threshold = max(strategy_threshold + 10, 85) + if total_score >= heavy_threshold and not no_direction and not in_cooldown: result["signal"] = direction result["tier"] = "heavy" - elif total_score >= 75 and not no_direction and not in_cooldown: + elif total_score >= strategy_threshold and not no_direction and not in_cooldown: result["signal"] = direction result["tier"] = "standard" - elif total_score >= 60 and not no_direction and not in_cooldown: - result["signal"] = direction - result["tier"] = "light" else: result["signal"] = None result["tier"] = None if result["signal"]: - self.last_signal_ts = now_ms - self.last_signal_dir = direction + self.last_signal_ts[strategy_name] = now_ms + self.last_signal_dir[strategy_name] = direction return result @@ -499,31 +650,60 @@ def save_indicator_1m(ts: int, symbol: str, result: dict): # ─── 模拟盘 ────────────────────────────────────────────────────── -def paper_open_trade(symbol: str, direction: str, price: float, score: int, tier: str, atr: float, now_ms: int, factors: dict = None): +def paper_open_trade( + symbol: str, + direction: str, + price: float, + score: int, + tier: str, + atr: float, + now_ms: int, + factors: dict = None, + strategy: str = "v51_baseline", + tp_sl: Optional[dict] = None, +): """模拟开仓""" import json as _json3 risk_atr = 0.7 * atr if risk_atr <= 0: return + sl_multiplier = float((tp_sl or {}).get("sl_multiplier", 2.0)) + tp1_multiplier = float((tp_sl or {}).get("tp1_multiplier", 1.5)) + tp2_multiplier = float((tp_sl or {}).get("tp2_multiplier", 3.0)) if direction == "LONG": - sl = price - 2.0 * risk_atr - tp1 = price + 1.5 * risk_atr - tp2 = price + 3.0 * risk_atr + sl = price - sl_multiplier * risk_atr + tp1 = price + tp1_multiplier * risk_atr + tp2 = price + tp2_multiplier * risk_atr else: - sl = price + 2.0 * risk_atr - tp1 = price - 1.5 * risk_atr - tp2 = price - 3.0 * risk_atr + sl = price + sl_multiplier * risk_atr + tp1 = price - tp1_multiplier * risk_atr + tp2 = price - tp2_multiplier * 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,score_factors) " - "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", - (symbol, direction, score, tier, price, now_ms, tp1, tp2, sl, atr, - _json3.dumps(factors) if factors else None) + "INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + symbol, + direction, + score, + tier, + price, + now_ms, + tp1, + tp2, + sl, + atr, + _json3.dumps(factors) if factors else None, + strategy, + ), ) conn.commit() - logger.info(f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}") + logger.info( + f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} strategy={strategy} " + f"TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}" + ) def paper_check_positions(symbol: str, current_price: float, now_ms: int): @@ -620,32 +800,54 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int): conn.commit() -def paper_has_active_position(symbol: str) -> bool: +def paper_has_active_position(symbol: str, strategy: Optional[str] = None) -> 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,)) + if strategy: + cur.execute( + "SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')", + (symbol, strategy), + ) + else: + cur.execute("SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", (symbol,)) return cur.fetchone()[0] > 0 -def paper_get_active_direction(symbol: str) -> str | None: +def paper_get_active_direction(symbol: str, strategy: Optional[str] = None) -> str | None: """获取该币种活跃持仓的方向,无持仓返回None""" with get_sync_conn() as conn: with conn.cursor() as cur: - cur.execute("SELECT direction FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') LIMIT 1", (symbol,)) + if strategy: + cur.execute( + "SELECT direction FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit') LIMIT 1", + (symbol, strategy), + ) + else: + cur.execute( + "SELECT direction FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') LIMIT 1", + (symbol,), + ) row = cur.fetchone() return row[0] if row else None -def paper_close_by_signal(symbol: str, current_price: float, now_ms: int): +def paper_close_by_signal(symbol: str, current_price: float, now_ms: int, strategy: Optional[str] = None): """反向信号平仓:按当前价平掉该币种所有活跃仓位""" with get_sync_conn() as conn: with conn.cursor() as cur: - cur.execute( - "SELECT id, direction, entry_price, tp1_hit, atr_at_entry " - "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", - (symbol,) - ) + if strategy: + cur.execute( + "SELECT id, direction, entry_price, tp1_hit, atr_at_entry " + "FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')", + (symbol, strategy), + ) + else: + cur.execute( + "SELECT id, direction, entry_price, tp1_hit, atr_at_entry " + "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", + (symbol,), + ) positions = cur.fetchall() for pos in positions: pid, direction, entry_price, tp1_hit, atr_entry = pos @@ -661,7 +863,10 @@ def paper_close_by_signal(symbol: str, current_price: float, now_ms: int): "UPDATE paper_trades SET status='signal_flip', exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s", (current_price, now_ms, round(pnl_r, 4), pid) ) - logger.info(f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R") + logger.info( + f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R" + f"{f' strategy={strategy}' if strategy else ''}" + ) conn.commit() @@ -677,6 +882,11 @@ def paper_active_count() -> int: def main(): init_schema() + strategy_configs = load_strategy_configs() + strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs] + logger.info(f"已加载策略配置: {', '.join(strategy_names)}") + primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0] + states = {sym: SymbolState(sym) for sym in SYMBOLS} for sym, state in states.items(): @@ -699,36 +909,63 @@ def main(): state.process_trade(t["agg_id"], t["time_ms"], t["price"], t["qty"], t["is_buyer_maker"]) state.market_indicators = fetch_market_indicators(sym) - result = state.evaluate_signal(now_ms) - save_indicator(now_ms, sym, result) + snapshot = state.build_evaluation_snapshot(now_ms) + strategy_results: list[tuple[dict, dict]] = [] + for strategy_cfg in strategy_configs: + strategy_result = state.evaluate_signal(now_ms, strategy_cfg=strategy_cfg, snapshot=snapshot) + strategy_results.append((strategy_cfg, strategy_result)) + + primary_result = strategy_results[0][1] + for strategy_cfg, strategy_result in strategy_results: + if strategy_cfg.get("name") == primary_strategy_name: + primary_result = strategy_result + break + + save_indicator(now_ms, sym, primary_result) bar_1m = (now_ms // 60000) * 60000 if last_1m_save.get(sym) != bar_1m: - save_indicator_1m(now_ms, sym, result) + save_indicator_1m(now_ms, sym, primary_result) last_1m_save[sym] = bar_1m - # 反向信号平仓:基于direction(不受冷却限制),score>=60才触发 + # 反向信号平仓:按策略独立判断,score>=75才触发 if PAPER_TRADING_ENABLED and warmup_cycles <= 0: - eval_dir = result.get("direction") - existing_dir = paper_get_active_direction(sym) - if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60: - paper_close_by_signal(sym, result["price"], now_ms) - logger.info(f"[{sym}] 📝 反向信号平仓: {existing_dir} → {eval_dir} (score={result['score']})") + for strategy_cfg, result in strategy_results: + strategy_name = strategy_cfg.get("name", "v51_baseline") + eval_dir = result.get("direction") + existing_dir = paper_get_active_direction(sym, strategy_name) + if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75: + paper_close_by_signal(sym, result["price"], now_ms, strategy_name) + logger.info( + f"[{sym}] 📝 反向信号平仓[{strategy_name}]: {existing_dir} → {eval_dir} " + f"(score={result['score']})" + ) - if result.get("signal"): - logger.info(f"[{sym}] 🚨 信号: {result['signal']} score={result['score']} price={result['price']:.1f}") - # 模拟盘开仓(需开关开启 + 跳过冷启动) - if PAPER_TRADING_ENABLED and warmup_cycles <= 0: - if not paper_has_active_position(sym): - active_count = paper_active_count() - if active_count < PAPER_MAX_POSITIONS: - tier = result.get("tier", "standard") - paper_open_trade( - sym, result["signal"], result["price"], - result["score"], tier, - result["atr"], now_ms, - factors=result.get("factors") - ) + for strategy_cfg, result in strategy_results: + strategy_name = strategy_cfg.get("name", "v51_baseline") + if result.get("signal"): + logger.info( + f"[{sym}] 🚨 信号[{strategy_name}]: {result['signal']} " + f"score={result['score']} price={result['price']:.1f}" + ) + # 模拟盘开仓(需开关开启 + 跳过冷启动) + if PAPER_TRADING_ENABLED and warmup_cycles <= 0: + if not paper_has_active_position(sym, strategy_name): + active_count = paper_active_count() + if active_count < PAPER_MAX_POSITIONS: + tier = result.get("tier", "standard") + paper_open_trade( + sym, + result["signal"], + result["price"], + result["score"], + tier, + result["atr"], + now_ms, + factors=result.get("factors"), + strategy=strategy_name, + tp_sl=strategy_cfg.get("tp_sl"), + ) # 模拟盘持仓检查由paper_monitor.py通过WebSocket实时处理,这里不再检查 From f6156a2cfebf178315c1c552ec11ef674be72f29 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 11:55:00 +0000 Subject: [PATCH 3/7] Add strategy-aware paper trade schema and API endpoints --- backend/db.py | 4 +++ backend/main.py | 72 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/backend/db.py b/backend/db.py index 5a0df3a..041155b 100644 --- a/backend/db.py +++ b/backend/db.py @@ -355,5 +355,9 @@ def init_schema(): conn.rollback() # 忽略已存在错误 continue + cur.execute( + "ALTER TABLE paper_trades " + "ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'" + ) conn.commit() ensure_partitions() diff --git a/backend/main.py b/backend/main.py index 7baa144..2cf849f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -442,7 +442,7 @@ async def get_market_indicators(user: dict = Depends(get_current_user)): result = {} for sym in SYMBOLS: indicators = {} - for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium"]: + for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]: row = await async_fetchrow( "SELECT value, timestamp_ms FROM market_indicators WHERE symbol = $1 AND indicator_type = $2 ORDER BY timestamp_ms DESC LIMIT 1", sym, @@ -568,8 +568,8 @@ async def paper_summary(user: dict = Depends(get_current_user)): 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 " + "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" ) # 从币安API获取实时价格 @@ -624,6 +624,7 @@ async def paper_positions(user: dict = Depends(get_current_user)): async def paper_trades( symbol: str = "all", result: str = "all", + strategy: str = "all", limit: int = 100, user: dict = Depends(get_current_user), ): @@ -642,11 +643,16 @@ async def paper_trades( elif result == "loss": conditions.append("pnl_r <= 0") + if strategy != "all": + conditions.append(f"strategy = ${idx}") + params.append(strategy) + idx += 1 + 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"SELECT id, symbol, direction, score, tier, strategy, 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 ) @@ -788,6 +794,62 @@ async def paper_stats(user: dict = Depends(get_current_user)): } +@app.get("/api/paper/stats-by-strategy") +async def paper_stats_by_strategy(user: dict = Depends(get_current_user)): + """按策略聚合模拟盘表现""" + rows = await async_fetch( + "SELECT strategy, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" + ) + active_rows = await async_fetch( + "SELECT strategy, COUNT(*) AS active_count FROM paper_trades " + "WHERE status IN ('active','tp1_hit') GROUP BY strategy" + ) + if not rows and not active_rows: + return {"data": []} + + active_map = {r["strategy"] or "v51_baseline": int(r["active_count"]) for r in active_rows} + by_strategy: dict[str, list[float]] = {} + for row in rows: + strategy = row["strategy"] or "v51_baseline" + by_strategy.setdefault(strategy, []).append(float(row["pnl_r"])) + + stats = [] + for strategy, pnls in by_strategy.items(): + total = len(pnls) + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p <= 0] + avg_win = sum(wins) / len(wins) if wins else 0 + avg_loss = abs(sum(losses) / len(losses)) if losses else 0 + stats.append( + { + "strategy": strategy, + "total": total, + "win_rate": round((len(wins) / total) * 100, 1) if total else 0, + "total_pnl": round(sum(pnls), 2), + "avg_win": round(avg_win, 2), + "avg_loss": round(avg_loss, 2), + "active_positions": active_map.get(strategy, 0), + } + ) + + for strategy, active_count in active_map.items(): + if strategy not in by_strategy: + stats.append( + { + "strategy": strategy, + "total": 0, + "win_rate": 0, + "total_pnl": 0, + "avg_win": 0, + "avg_loss": 0, + "active_positions": active_count, + } + ) + + stats.sort(key=lambda x: x["strategy"]) + return {"data": stats} + + # ─── 服务器状态监控 ─────────────────────────────────────────────── import shutil, subprocess, psutil From 7ba53a50052c2364dd19683c43d5adf43b3d49fc Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 11:55:03 +0000 Subject: [PATCH 4/7] Update paper UI for strategy filters and FR/liquidation details --- frontend/app/paper/page.tsx | 147 ++++++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 21 deletions(-) diff --git a/frontend/app/paper/page.tsx b/frontend/app/paper/page.tsx index c7b6a14..14e88e7 100644 --- a/frontend/app/paper/page.tsx +++ b/frontend/app/paper/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { authFetch, useAuth } from "@/lib/auth"; -import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts"; +import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts"; // ─── 工具函数 ──────────────────────────────────────────────────── @@ -15,6 +15,24 @@ function fmtPrice(p: number) { return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } +function parseFactors(raw: any) { + if (!raw) return null; + if (typeof raw === "string") { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + return raw; +} + +function strategyName(strategy: string | null | undefined) { + if (strategy === "v52_8signals") return "V5.2"; + if (strategy === "v51_baseline") return "V5.1"; + return strategy || "V5.1"; +} + // ─── 控制面板(开关+配置)────────────────────────────────────── function ControlPanel() { @@ -210,6 +228,9 @@ function ActivePositions() { 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 factors = parseFactors(p.score_factors); + const frScore = factors?.funding_rate?.score ?? 0; + const liqScore = factors?.liquidation?.score ?? 0; const entry = p.entry_price || 0; const atr = p.atr_at_entry || 1; const riskDist = 2.0 * 0.7 * atr; @@ -225,7 +246,9 @@ function ActivePositions() { {p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction} - 评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"} + + {strategyName(p.strategy)} · 评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"} +
= 0 ? "text-emerald-600" : "text-red-500"}`}> @@ -243,6 +266,8 @@ function ActivePositions() { 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}
); @@ -287,21 +312,23 @@ function EquityCurve() { type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL"; type FilterResult = "all" | "win" | "loss"; +type FilterStrategy = "all" | "v51_baseline" | "v52_8signals"; function TradeHistory() { 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}&limit=50`); + const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${strategy}&limit=50`); if (r.ok) { const j = await r.json(); setTrades(j.data || []); } } catch {} }; f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); - }, [symbol, result]); + }, [symbol, result, strategy]); return (
@@ -321,6 +348,13 @@ function TradeHistory() { {r === "all" ? "全部" : r === "win" ? "盈利" : "亏损"} ))} + | + {(["all", "v51_baseline", "v52_8signals"] as FilterStrategy[]).map(s => ( + + ))}
@@ -331,6 +365,7 @@ function TradeHistory() { 币种 + 策略 方向 入场 出场 @@ -343,9 +378,19 @@ function TradeHistory() { {trades.map((t: any) => { const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0; + const factors = parseFactors(t.score_factors); + const frScore = factors?.funding_rate?.score ?? 0; + const liqScore = factors?.liquidation?.score ?? 0; return ( {t.symbol?.replace("USDT", "")} + + + {strategyName(t.strategy)} + + {t.direction === "LONG" ? "🟢" : "🔴"} {t.direction} @@ -365,7 +410,10 @@ function TradeHistory() { {t.status === "tp" ? "止盈" : t.status === "sl" ? "止损" : t.status === "sl_be" ? "保本" : t.status === "timeout" ? "超时" : t.status === "signal_flip" ? "翻转" : t.status} - {t.score} + +
{t.score}
+
FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}
+ {holdMin}m ); @@ -383,8 +431,22 @@ function TradeHistory() { function StatsPanel() { 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 r = await authFetch("/api/paper/stats"); if (r.ok) setData(await r.json()); } catch {} }; + 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 || []); + } + } catch {} + }; f(); const iv = setInterval(f, 30000); return () => clearInterval(iv); }, []); @@ -392,6 +454,20 @@ function StatsPanel() { 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 (
@@ -406,20 +482,49 @@ function StatsPanel() {
{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}笔)

- ))} +
+
+
胜率

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

+
+ ) : ( +
暂无策略统计
+ )} +
) : (
该币种暂无数据
@@ -447,7 +552,7 @@ export default function PaperTradingPage() {

📊 模拟盘

-

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

+

V5.2策略AB测试 · 实时追踪 · 数据驱动优化

From 778cf8cce13d9de5434e378aa2ad2c4a6ed1a806 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 12:21:19 +0000 Subject: [PATCH 5/7] 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", From ee90b8dcfab74a110116c0d3b3b539653d62acd3 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 12:25:40 +0000 Subject: [PATCH 6/7] feat: sidebar navigation with V5.1/V5.2 separate entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar: 信号/模拟盘 section headers - Three paper trade entries: 全部持仓, V5.1模拟盘, V5.2模拟盘 (NEW badge) - Paper page reads strategy from URL query params - Suspense boundary for useSearchParams --- V52_TASK.md | 224 ++++++++++++++++++++++++++++++++ frontend/app/paper/page.tsx | 24 +++- frontend/components/Sidebar.tsx | 40 ++++-- signal-engine.log | 0 4 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 V52_TASK.md create mode 100644 signal-engine.log diff --git a/V52_TASK.md b/V52_TASK.md new file mode 100644 index 0000000..0e79d4a --- /dev/null +++ b/V52_TASK.md @@ -0,0 +1,224 @@ +# V5.2 Development Task + +## Context +You are working on the `dev` branch of the ArbitrageEngine project. +This is a quantitative trading signal system with: +- Backend: Python (FastAPI + PostgreSQL) +- Frontend: Next.js + shadcn/ui + Tailwind + +## Database Connection +- Host: 34.85.117.248 (Cloud SQL) +- Port: 5432, DB: arb_engine, User: arb, Password: arb_engine_2026 + +## What to Build (V5.2) + +### 1. Strategy Configuration Framework +Create `backend/strategies/` directory with JSON configs: + +**backend/strategies/v51_baseline.json:** +```json +{ + "name": "v51_baseline", + "version": "5.1", + "threshold": 75, + "weights": { + "direction": 45, + "crowding": 20, + "environment": 15, + "confirmation": 15, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"] +} +``` + +**backend/strategies/v52_8signals.json:** +```json +{ + "name": "v52_8signals", + "version": "5.2", + "threshold": 75, + "weights": { + "direction": 40, + "crowding": 25, + "environment": 15, + "confirmation": 20, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"] +} +``` + +### 2. Signal Engine Changes (signal_engine.py) + +#### 2a. Add FR scoring to evaluate_signal() +After the crowding section, add funding_rate scoring: + +```python +# Funding Rate scoring (拥挤层加分) +# Read from market_indicators table +funding_rate = to_float(self.market_indicators.get("funding_rate")) +fr_score = 0 +if funding_rate is not None: + fr_abs = abs(funding_rate) + if fr_abs >= 0.001: # extreme ±0.1% + # Extreme: penalize if going WITH the crowd + if (direction == "LONG" and funding_rate > 0.001) or \ + (direction == "SHORT" and funding_rate < -0.001): + fr_score = -5 + else: + fr_score = 5 + elif fr_abs >= 0.0003: # moderate ±0.03% + # Moderate: reward going AGAINST the crowd + if (direction == "LONG" and funding_rate < -0.0003) or \ + (direction == "SHORT" and funding_rate > 0.0003): + fr_score = 5 + else: + fr_score = 0 +``` + +#### 2b. Add liquidation scoring +```python +# Liquidation scoring (确认层加分) +liq_score = 0 +liq_data = self.fetch_recent_liquidations() # new method +if liq_data: + liq_long_usd = liq_data.get("long_usd", 0) + liq_short_usd = liq_data.get("short_usd", 0) + # Thresholds by symbol + thresholds = {"BTCUSDT": 500000, "ETHUSDT": 200000, "XRPUSDT": 100000, "SOLUSDT": 100000} + threshold = thresholds.get(self.symbol, 100000) + total = liq_long_usd + liq_short_usd + if total >= threshold: + if liq_short_usd > 0 and liq_long_usd > 0: + ratio = liq_short_usd / liq_long_usd + elif liq_short_usd > 0: + ratio = float('inf') + else: + ratio = 0 + if ratio >= 2.0 and direction == "LONG": + liq_score = 5 # shorts getting liquidated, price going up + elif ratio <= 0.5 and direction == "SHORT": + liq_score = 5 # longs getting liquidated, price going down +``` + +#### 2c. Add fetch_recent_liquidations method to SymbolState +```python +def fetch_recent_liquidations(self, window_ms=300000): + """Fetch last 5min liquidation totals from liquidations table""" + now_ms = int(time.time() * 1000) + cutoff = now_ms - window_ms + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute(""" + SELECT + COALESCE(SUM(CASE WHEN side='SELL' THEN usd_value ELSE 0 END), 0) as long_liq, + COALESCE(SUM(CASE WHEN side='BUY' THEN usd_value ELSE 0 END), 0) as short_liq + FROM liquidations + WHERE symbol=%s AND trade_time >= %s + """, (self.symbol, cutoff)) + row = cur.fetchone() + if row: + return {"long_usd": row[0], "short_usd": row[1]} + return None +``` + +#### 2d. Add funding_rate to fetch_market_indicators +Add "funding_rate" to the indicator types: +```python +for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]: +``` +And the extraction: +```python +elif ind_type == "funding_rate": + indicators[ind_type] = float(val.get("lastFundingRate", 0)) +``` + +#### 2e. Update total_score calculation +Currently: +```python +total_score = direction_score + accel_bonus + crowding_score + environment_score + confirmation_score + aux_score +``` +Change to: +```python +total_score = direction_score + accel_bonus + crowding_score + fr_score + environment_score + confirmation_score + liq_score + aux_score +``` + +#### 2f. Update factors dict +Add fr_score and liq_score to the factors: +```python +result["factors"] = { + ...existing factors..., + "funding_rate": {"score": fr_score, "value": funding_rate}, + "liquidation": {"score": liq_score, "long_usd": liq_data.get("long_usd", 0) if liq_data else 0, "short_usd": liq_data.get("short_usd", 0) if liq_data else 0}, +} +``` + +#### 2g. Change threshold from 60 to 75 +In evaluate_signal, change: +```python +# OLD +elif total_score >= 60 and not no_direction and not in_cooldown: + result["signal"] = direction + result["tier"] = "light" +# NEW: remove the 60 tier entirely, minimum is 75 +``` + +Also update reverse signal threshold from 60 to 75: +In main() loop: +```python +# OLD +if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60: +# NEW +if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75: +``` + +### 3. Strategy field in paper_trades +Add SQL migration at top of init_schema() or in a migration: +```sql +ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'; +``` + +### 4. AB Test: Both strategies evaluate each cycle +In the main loop, evaluate signal twice (once per strategy config) and potentially open trades for both. Each trade records which strategy triggered it. + +### 5. Frontend: Update paper/page.tsx +- Show strategy column in trade history table +- Show FR and liquidation scores in signal details +- Add strategy filter/tab (v51 vs v52) + +### 6. API: Add strategy stats endpoint +In main.py, add `/api/paper/stats-by-strategy` that groups stats by strategy field. + +## Important Notes +- Keep ALL existing functionality working +- Don't break the existing V5.1 scoring - it should still work as strategy "v51_baseline" +- The FR data is already in market_indicators table (collected every 5min) +- The liquidation data is already in liquidations table +- Test with: `cd frontend && npm run build` to verify no frontend errors +- Test backend: `python3 -c "from signal_engine import *; print('OK')"` to verify imports +- Port for dev testing: API=8100, Frontend=3300 +- Total score CAN exceed 100 (that's by design) + +## Files to modify: +1. `backend/signal_engine.py` - core scoring changes +2. `backend/main.py` - new API endpoints +3. `backend/db.py` - add strategy column migration +4. `frontend/app/paper/page.tsx` - UI updates +5. NEW: `backend/strategies/v51_baseline.json` +6. NEW: `backend/strategies/v52_8signals.json` + +When completely finished, run this command to notify me: +openclaw system event --text "Done: V5.2 core implementation complete - FR+liquidation scoring, threshold 75, strategy configs, AB test framework" --mode now diff --git a/frontend/app/paper/page.tsx b/frontend/app/paper/page.tsx index b6bfeca..fceecb8 100644 --- a/frontend/app/paper/page.tsx +++ b/frontend/app/paper/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; import Link from "next/link"; import { authFetch, useAuth } from "@/lib/auth"; import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts"; @@ -657,9 +658,18 @@ function StatsPanel({ strategy }: { strategy: StrategyFilter }) { // ─── 主页面 ────────────────────────────────────────────────────── -export default function PaperTradingPage() { +function PaperTradingPageInner() { const { isLoggedIn, loading } = useAuth(); - const [strategyTab, setStrategyTab] = useState("all"); + const searchParams = useSearchParams(); + const urlStrategy = searchParams.get("strategy"); + const [strategyTab, setStrategyTab] = useState(() => normalizeStrategy(urlStrategy)); + + // URL参数变化时同步 + useEffect(() => { + if (urlStrategy) { + setStrategyTab(normalizeStrategy(urlStrategy)); + } + }, [urlStrategy]); if (loading) return
加载中...
; @@ -711,3 +721,11 @@ export default function PaperTradingPage() {
); } + +export default function PaperTradingPage() { + return ( + 加载中...
}> + + + ); +} diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 4764b85..33be8e5 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -7,14 +7,16 @@ import { useAuth } from "@/lib/auth"; import { LayoutDashboard, Info, Menu, X, Zap, LogIn, UserPlus, - ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart + ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles, FlaskConical } 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: "/signals", label: "信号引擎", icon: Crosshair, section: "信号" }, + { href: "/paper?strategy=all", label: "全部持仓", icon: LineChart, section: "模拟盘" }, + { href: "/paper?strategy=v51_baseline", label: "V5.1 模拟盘", icon: FlaskConical }, + { href: "/paper?strategy=v52_8signals", label: "V5.2 模拟盘", icon: Sparkles, badge: "NEW" }, { href: "/server", label: "服务器", icon: Monitor }, { href: "/about", label: "说明", icon: Info }, ]; @@ -37,17 +39,29 @@ export default function Sidebar() { {/* Nav */}