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:
parent
d8ad87958a
commit
45bad25156
@ -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}"
|
||||
|
||||
@ -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:
|
||||
# 扣手续费
|
||||
|
||||
@ -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
61
docs/V52-TODO.md
Normal 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小时数据 |
|
||||
Loading…
Reference in New Issue
Block a user