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
This commit is contained in:
root 2026-03-01 09:29:32 +00:00
parent d8ad87958a
commit 45bad25156
4 changed files with 116 additions and 33 deletions

View File

@ -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}"

View File

@ -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:
# 扣手续费

View File

@ -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:

61
docs/V52-TODO.md Normal file
View File

@ -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小时数据 |