From 45bad2515602d8c720300752a04c6f1da11231a5 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 09:29:32 +0000 Subject: [PATCH] fix(P0): pnl_r calculation + cooldown bypass + partition month bug P0-1: Reverse signal now bypasses cooldown - evaluate_signal always outputs direction, main loop checks direction+score>=60 for closing positions against trend (not blocked by COOLDOWN_MS) P0-2: pnl_r unified to (exit-entry)/risk_distance across all exit scenarios (tp, sl, sl_be, timeout) in both paper_monitor.py and signal_engine.py. Old hardcoded values (1.5R/2.25R) were ~2x too high vs actual risk_distance basis. P1-1: ensure_partitions month calculation fixed from timedelta(30d) to proper month arithmetic. Also fixed UTC timezone for partition boundaries. docs: V52-TODO.md with full audit backlog for V5.2 --- backend/db.py | 14 +++++---- backend/paper_monitor.py | 26 +++++++++++------ backend/signal_engine.py | 48 ++++++++++++++++++------------- docs/V52-TODO.md | 61 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 docs/V52-TODO.md diff --git a/backend/db.py b/backend/db.py index e9375d1..5a0df3a 100644 --- a/backend/db.py +++ b/backend/db.py @@ -312,20 +312,22 @@ def ensure_partitions(): now = datetime.datetime.utcnow() months = [] for delta in range(0, 3): # 当月+下2个月 - d = now + datetime.timedelta(days=delta * 30) - months.append(d.strftime("%Y%m")) + m = now.month + delta + y = now.year + (m - 1) // 12 + m = ((m - 1) % 12) + 1 + months.append(f"{y}{m:02d}") with get_sync_conn() as conn: with conn.cursor() as cur: for m in set(months): year = int(m[:4]) month = int(m[4:]) - # 计算分区范围(毫秒时间戳) - start = datetime.datetime(year, month, 1) + # 计算分区范围(UTC毫秒时间戳) + start = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) if month == 12: - end = datetime.datetime(year + 1, 1, 1) + end = datetime.datetime(year + 1, 1, 1, tzinfo=datetime.timezone.utc) else: - end = datetime.datetime(year, month + 1, 1) + end = datetime.datetime(year, month + 1, 1, tzinfo=datetime.timezone.utc) start_ms = int(start.timestamp() * 1000) end_ms = int(end.timestamp() * 1000) part_name = f"agg_trades_{m}" diff --git a/backend/paper_monitor.py b/backend/paper_monitor.py index 1bb2672..a85a241 100644 --- a/backend/paper_monitor.py +++ b/backend/paper_monitor.py @@ -61,15 +61,20 @@ def check_and_close(symbol_upper: str, price: float): new_status = None pnl_r = 0.0 + # 统一计算risk_distance (1R基准距离) + risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1 + if direction == "LONG": if price <= sl: closed = True if tp1_hit: new_status = "sl_be" - pnl_r = 0.5 * 1.5 + # TP1半仓已锁定盈利 = 0.5 * (tp1 - entry) / risk_distance + tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r else: new_status = "sl" - pnl_r = -1.0 + pnl_r = (price - entry_price) / risk_distance if risk_distance > 0 else -1.0 elif not tp1_hit and price >= tp1: new_sl = entry_price * 1.0005 cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", @@ -78,16 +83,19 @@ def check_and_close(symbol_upper: str, price: float): elif tp1_hit and price >= tp2: closed = True new_status = "tp" - pnl_r = 2.25 + tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0 + tp2_r = (tp2 - entry_price) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r + 0.5 * tp2_r else: # SHORT if price >= sl: closed = True if tp1_hit: new_status = "sl_be" - pnl_r = 0.5 * 1.5 + tp1_r = (entry_price - tp1) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r else: new_status = "sl" - pnl_r = -1.0 + pnl_r = (entry_price - price) / risk_distance if risk_distance > 0 else -1.0 elif not tp1_hit and price <= tp1: new_sl = entry_price * 0.9995 cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", @@ -96,20 +104,22 @@ def check_and_close(symbol_upper: str, price: float): elif tp1_hit and price <= tp2: closed = True new_status = "tp" - pnl_r = 2.25 + tp1_r = (entry_price - tp1) / risk_distance if risk_distance > 0 else 0 + tp2_r = (entry_price - tp2) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r + 0.5 * tp2_r # 时间止损:60分钟 if not closed and (now_ms - entry_ts > 60 * 60 * 1000): closed = True new_status = "timeout" - risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1 if direction == "LONG": move = price - entry_price else: move = entry_price - price pnl_r = move / risk_distance if risk_distance > 0 else 0 if tp1_hit: - pnl_r = max(pnl_r, 0.5 * 1.5) + tp1_r = abs(tp1 - entry_price) / risk_distance if risk_distance > 0 else 0 + pnl_r = max(pnl_r, 0.5 * tp1_r) if closed: # 扣手续费 diff --git a/backend/signal_engine.py b/backend/signal_engine.py index 8b6c40a..7e83c89 100644 --- a/backend/signal_engine.py +++ b/backend/signal_engine.py @@ -405,6 +405,9 @@ class SymbolState: "auxiliary": {"score": aux_score, "coinbase_premium": coinbase_premium}, } + # 始终输出direction供反向平仓判断(不受冷却限制) + result["direction"] = direction if not no_direction else None + if total_score >= 85 and not no_direction and not in_cooldown: result["signal"] = direction result["tier"] = "heavy" @@ -539,16 +542,18 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int): closed = False new_status = None pnl_r = 0.0 + risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1 if direction == "LONG": if current_price <= sl: closed = True if tp1_hit: new_status = "sl_be" - pnl_r = 0.5 * 1.5 + tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r else: new_status = "sl" - pnl_r = -1.0 + pnl_r = (current_price - entry_price) / risk_distance if risk_distance > 0 else -1.0 elif not tp1_hit and current_price >= tp1: # TP1触发,移动止损到成本价 new_sl = entry_price * 1.0005 @@ -557,16 +562,19 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int): elif tp1_hit and current_price >= tp2: closed = True new_status = "tp" - pnl_r = 0.5 * 1.5 + 0.5 * 3.0 # 2.25R + tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0 + tp2_r = (tp2 - entry_price) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r + 0.5 * tp2_r else: # SHORT if current_price >= sl: closed = True if tp1_hit: new_status = "sl_be" - pnl_r = 0.5 * 1.5 + tp1_r = (entry_price - tp1) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r else: new_status = "sl" - pnl_r = -1.0 + pnl_r = (entry_price - current_price) / risk_distance if risk_distance > 0 else -1.0 elif not tp1_hit and current_price <= tp1: new_sl = entry_price * 0.9995 cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", (new_sl, pid)) @@ -574,20 +582,22 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int): elif tp1_hit and current_price <= tp2: closed = True new_status = "tp" - pnl_r = 2.25 + tp1_r = (entry_price - tp1) / risk_distance if risk_distance > 0 else 0 + tp2_r = (entry_price - tp2) / risk_distance if risk_distance > 0 else 0 + pnl_r = 0.5 * tp1_r + 0.5 * tp2_r # 时间止损:60分钟 if not closed and (now_ms - entry_ts > 60 * 60 * 1000): closed = True new_status = "timeout" if direction == "LONG": - move = (current_price - entry_price) / entry_price + move = current_price - entry_price else: - move = (entry_price - current_price) / entry_price - risk_pct = abs(sl - entry_price) / entry_price - pnl_r = move / risk_pct if risk_pct > 0 else 0 + move = entry_price - current_price + pnl_r = move / risk_distance if risk_distance > 0 else 0 if tp1_hit: - pnl_r = max(pnl_r, 0.5 * 1.5) + tp1_r = abs(tp1 - entry_price) / risk_distance if risk_distance > 0 else 0 + pnl_r = max(pnl_r, 0.5 * tp1_r) if closed: # 扣除手续费(开仓+平仓各Taker 0.05%) @@ -691,18 +701,18 @@ def main(): save_indicator_1m(now_ms, sym, result) last_1m_save[sym] = bar_1m + # 反向信号平仓:基于direction(不受冷却限制),score>=60才触发 + 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']})") + 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: - existing_dir = paper_get_active_direction(sym) - new_dir = result["signal"] - - if existing_dir and existing_dir != new_dir: - # 反向信号:先平掉现有仓位 - paper_close_by_signal(sym, result["price"], now_ms) - logger.info(f"[{sym}] 📝 反向信号平仓: {existing_dir} → {new_dir}") - if not paper_has_active_position(sym): active_count = paper_active_count() if active_count < PAPER_MAX_POSITIONS: diff --git a/docs/V52-TODO.md b/docs/V52-TODO.md new file mode 100644 index 0000000..10a8cf1 --- /dev/null +++ b/docs/V52-TODO.md @@ -0,0 +1,61 @@ +# V5.2 待修复清单 + +> 来源:Claude Code审阅报告 + 露露复查 +> 创建:2026-03-01 + +## 已在V5.1-hotfix中修复(P0) + +| ID | 问题 | 修复 | +|----|------|------| +| P0-1 | 冷却期阻断反向信号平仓 | evaluate_signal始终输出direction,主循环基于direction+score>=60触发反向平仓 | +| P0-2 | pnl_r TP场景虚高2倍 | paper_monitor+signal_engine统一用(exit-entry)/risk_distance计算 | +| P1-1 | 分区月份Bug(timedelta 30天) | 改为正确的月份加法 + UTC时区 | +| P2-2 | 分区边界用本地时区 | 改为datetime.timezone.utc | + +## V5.2 必须修复 + +### 后端 + +| ID | 优先级 | 文件 | 问题 | 建议修复 | +|----|--------|------|------|---------| +| P0-3 | P1 | signal_engine.py | 开仓价用30分VWAP而非实时价 | 改用win_fast.trades[-1][2]最新成交价 | +| P0-4 | P2 | signal_engine+paper_monitor | 双进程并发写竞态 | SELECT FOR UPDATE SKIP LOCKED | +| P1-2 | P2 | signal_engine.py | 浮点精度漂移(buy_vol/sell_vol) | 每N次trim后从deque重算sums | +| P1-3 | P1 | market_data_collector.py | 单连接无重连 | 改用db.get_sync_conn()连接池 | +| P1-4 | P3 | db.py | 连接池初始化线程不安全 | 加threading.Lock双重检查 | +| P2-1 | P2 | market_data_collector.py | XRP/SOL coinbase_premium KeyError | 不在pair_map中的跳过 | +| P2-3 | P2 | agg_trades_collector.py | flush_buffer每秒调ensure_partitions | 移到定时任务(每小时) | +| P2-4 | P3 | liquidation_collector.py | elif条件冗余 | 改为else | +| P2-5 | P2 | signal_engine.py | atr_percentile @property有写副作用 | 移到显式update_atr_history() | +| P2-6 | P2 | main.py | 1R=$200硬编码 | 从paper_config.json读取 | +| P3-1 | P2 | auth.py | JWT密钥硬编码默认值 | 启动时强制校验环境变量 | +| P3-2 | P3 | main.py | CORS allow_origins=["*"] | 限制为前端域名 | +| P3-3 | P3 | auth.py | refresh token刷新非原子 | UPDATE...RETURNING原子操作 | +| P3-4 | P3 | auth.py | 登录无频率限制 | slowapi或Redis计数器 | +| NEW | P1 | signal_engine.py | 冷启动warmup只有4小时 | 分批加载24小时数据,加载完再出信号 | + +### 前端 + +| ID | 优先级 | 文件 | 问题 | 建议修复 | +|----|--------|------|------|---------| +| FE-P1-1 | P1 | lib/auth.tsx | 并发401多次refresh竞态 | 单例Promise防并发刷新 | +| FE-P1-2 | P1 | lib/auth.tsx | 刷新失败AuthContext未同步 | 事件总线通知强制logout | +| FE-P1-3 | P1 | 所有页面 | catch{}静默吞掉API错误 | 加error state+用户提示 | +| FE-P1-4 | P2 | paper/page.tsx | LatestSignals串行4请求 | Promise.allSettled并行 | +| FE-P2-1 | P3 | app/page.tsx | MiniKChart每30秒销毁重建 | 只更新数据不重建chart | +| FE-P2-3 | P2 | paper/page.tsx | ControlPanel非admin可见 | 校验isAdmin | +| FE-P2-4 | P1 | paper/page.tsx | WebSocket无断线重连 | 指数退避重连+断线提示 | +| FE-P2-5 | P2 | paper/page.tsx | 1R=$200前端硬编码 | 从API读取配置 | +| FE-P2-6 | P2 | signals/page.tsx | 5秒轮询5分钟数据 | 改为300秒间隔 | +| FE-P2-8 | P3 | paper/signals | 大量any类型 | 定义TypeScript interface | +| FE-P3-1 | P3 | lib/auth.tsx | Token存localStorage | 评估httpOnly cookie | +| FE-P3-3 | P3 | app/page.tsx | Promise.all任一失败全丢 | 改Promise.allSettled | + +## V5.2 新功能(同步开发) + +| 功能 | 说明 | +|------|------| +| FR+清算加入评分 | 8信号源完整接入 | +| 策略配置化框架 | 一套代码多份配置 | +| AB测试 | V5.1 vs V5.2两套权重对比 | +| 24h warmup | 启动时分批加载24小时数据 |