fix: critical bugs — double fee, SL/TP atomicity, PnL race, graceful shutdown

C5: Remove duplicate entry_fee deduction in backtester (balance and net_pnl)
C1: Add SL/TP retry (3x) with emergency market close on final failure
C3: Add _close_lock to prevent PnL double recording between callback and monitor
C8: Add SIGTERM/SIGINT handler with per-symbol order cancellation before exit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-19 23:55:14 +09:00
parent e648ae7ca0
commit f14c521302
5 changed files with 265 additions and 102 deletions

View File

@@ -141,3 +141,4 @@ All design documents and implementation plans are stored in `docs/plans/` with t
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
| 2026-03-07 | `weekly-report` (plan) | Completed |
| 2026-03-07 | `code-review-improvements` | Partial (#1,#2,#4,#5,#6,#8 완료) |
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |

View File

@@ -0,0 +1,53 @@
# Critical Bugfixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 4 critical bugs identified in code review (C5, C1, C3, C8)
**Architecture:** Direct fixes to backtester.py, bot.py, main.py — no new files needed
**Tech Stack:** Python asyncio, signal handling
---
## Task 1: C5 — Backtester double fee deduction + atr≤0 fee leak
**Files:**
- Modify: `src/backtester.py:494-501`
- [x] Remove `self.balance -= entry_fee` at L496. The fee is already deducted in `_close_position` via `net_pnl = gross_pnl - entry_fee - exit_fee`.
- [x] This also fixes the atr≤0 early return bug — since balance is no longer modified before ATR check, early return doesn't leak fees.
## Task 2: C1 — SL/TP atomicity with retry and emergency close
**Files:**
- Modify: `src/bot.py:461-475`
- [x] Wrap SL/TP placement in `_place_sl_tp_with_retry()` with 3 retries and 1s backoff
- [x] Track `sl_placed` and `tp_placed` independently to avoid re-placing successful orders
- [x] On final failure, call `_emergency_close()` which market-closes the position and notifies via Discord
- [x] `_emergency_close` also handles its own failure with critical log + Discord alert
## Task 3: C3 — PnL double recording race condition
**Files:**
- Modify: `src/bot.py` (init, _on_position_closed, _position_monitor)
- [x] Add `self._close_lock = asyncio.Lock()` to `__init__`
- [x] Wrap `_on_position_closed` body with `async with self._close_lock`
- [x] Wrap SYNC path in `_position_monitor` with `async with self._close_lock`
- [x] Add double-check after lock acquisition in monitor (callback may have already processed)
## Task 4: C8 — Graceful shutdown with signal handler
**Files:**
- Modify: `main.py`
- [x] Add `signal.SIGTERM` and `signal.SIGINT` handlers via `loop.add_signal_handler()`
- [x] Use `asyncio.Event` + `asyncio.wait(FIRST_COMPLETED)` pattern
- [x] `_graceful_shutdown()`: cancel all open orders per bot (with 5s timeout), then cancel tasks
- [x] Log shutdown progress for each symbol
## Verification
- [x] All 138 existing tests pass (0 failures)

50
main.py
View File

@@ -1,4 +1,5 @@
import asyncio
import signal
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
from loguru import logger
@@ -21,6 +22,25 @@ async def _daily_reset_loop(risk: RiskManager):
risk.reset_daily()
async def _graceful_shutdown(bots: list[TradingBot], tasks: list[asyncio.Task]):
"""모든 봇의 오픈 주문 취소 후 태스크를 정리한다."""
logger.info("Graceful shutdown 시작 — 오픈 주문 취소 중...")
for bot in bots:
try:
await asyncio.wait_for(bot.exchange.cancel_all_orders(), timeout=5)
logger.info(f"[{bot.symbol}] 오픈 주문 취소 완료")
except Exception as e:
logger.warning(f"[{bot.symbol}] 오픈 주문 취소 실패 (무시): {e}")
for task in tasks:
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception) and not isinstance(r, asyncio.CancelledError):
logger.warning(f"태스크 종료 중 예외: {r}")
logger.info("Graceful shutdown 완료")
async def main():
setup_logger(log_level="INFO")
config = Config()
@@ -39,11 +59,35 @@ async def main():
bots.append(bot)
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
await asyncio.gather(
*[bot.run() for bot in bots],
_daily_reset_loop(risk),
# 시그널 핸들러 등록
loop = asyncio.get_running_loop()
shutdown_event = asyncio.Event()
def _signal_handler():
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
shutdown_event.set()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _signal_handler)
tasks = [
asyncio.create_task(bot.run(), name=f"bot-{bot.symbol}")
for bot in bots
]
tasks.append(asyncio.create_task(_daily_reset_loop(risk), name="daily-reset"))
# 종료 시그널 대기 vs 태스크 완료 (먼저 발생하는 쪽)
shutdown_task = asyncio.create_task(shutdown_event.wait(), name="shutdown-wait")
done, pending = await asyncio.wait(
tasks + [shutdown_task],
return_when=asyncio.FIRST_COMPLETED,
)
# 시그널이든 태스크 종료든 graceful shutdown 수행
shutdown_task.cancel()
await _graceful_shutdown(bots, tasks)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -491,9 +491,8 @@ class Backtester:
buy_side = "BUY" if signal == "LONG" else "SELL"
entry_price = _apply_slippage(price, buy_side, self.cfg.slippage_pct)
# 수수료
# 수수료 (청산 시 net_pnl에서 차감하므로 여기서 balance 차감하지 않음)
entry_fee = _calc_fee(entry_price, quantity, self.cfg.fee_pct)
self.balance -= entry_fee
# SL/TP 계산
atr = float(row.get("atr", 0))

View File

@@ -77,6 +77,7 @@ class TradingBot:
self._entry_quantity: float | None = None
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
self._close_event = asyncio.Event() # 콜백 청산 완료 대기용
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
self._oi_history: deque = deque(maxlen=96) # z-score 윈도우(96=1일분 15분봉)
self._funding_history: deque = deque(maxlen=96)
@@ -459,20 +460,80 @@ class TradingBot:
)
sl_side = "SELL" if signal == "LONG" else "BUY"
await self.exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="STOP_MARKET",
stop_price=self.exchange._round_price(stop_loss),
reduce_only=True,
)
await self.exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="TAKE_PROFIT_MARKET",
stop_price=self.exchange._round_price(take_profit),
reduce_only=True,
)
try:
await self._place_sl_tp_with_retry(
sl_side, quantity, stop_loss, take_profit
)
except Exception as e:
logger.error(
f"[{self.symbol}] SL/TP 배치 최종 실패 — 긴급 청산: {e}"
)
await self._emergency_close(side, quantity)
_SL_TP_MAX_RETRIES = 3
async def _place_sl_tp_with_retry(
self, sl_side: str, quantity: float, stop_loss: float, take_profit: float
) -> None:
"""SL/TP 주문을 재시도 로직과 함께 배치한다. 최종 실패 시 예외를 raise."""
sl_placed = False
tp_placed = False
last_error = None
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
try:
if not sl_placed:
await self.exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="STOP_MARKET",
stop_price=self.exchange._round_price(stop_loss),
reduce_only=True,
)
sl_placed = True
if not tp_placed:
await self.exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="TAKE_PROFIT_MARKET",
stop_price=self.exchange._round_price(take_profit),
reduce_only=True,
)
tp_placed = True
return # 둘 다 성공
except Exception as e:
last_error = e
logger.warning(
f"[{self.symbol}] SL/TP 배치 실패 (시도 {attempt}/{self._SL_TP_MAX_RETRIES}): {e}"
)
if attempt < self._SL_TP_MAX_RETRIES:
await asyncio.sleep(1)
raise last_error # 모든 재시도 실패
async def _emergency_close(self, entry_side: str, quantity: float) -> None:
"""SL/TP 배치 실패 시 포지션을 긴급 시장가 청산한다."""
try:
close_side = "SELL" if entry_side == "BUY" else "BUY"
await self.exchange.cancel_all_orders()
await self.exchange.place_order(
side=close_side, quantity=quantity, reduce_only=True
)
await self.risk.close_position(self.symbol, 0.0)
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
self.notifier.notify_info(
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
)
logger.warning(f"[{self.symbol}] 긴급 청산 완료")
except Exception as e:
logger.critical(
f"[{self.symbol}] 긴급 청산마저 실패! 수동 개입 필요: {e}"
)
self.notifier.notify_info(
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
)
def _calc_estimated_pnl(self, exit_price: float) -> float:
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
@@ -489,47 +550,48 @@ class TradingBot:
exit_price: float,
) -> None:
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
# 이미 Flat 상태면 중복 처리 방지 (SYNC 또는 process_candle에서 먼저 처리됨)
if self.current_trade_side is None and not self._is_reentering:
logger.debug(f"[{self.symbol}] 이미 Flat 상태 — 콜백 건너뜀")
async with self._close_lock:
# 이미 Flat 상태면 중복 처리 방지 (SYNC 또는 process_candle에서 먼저 처리됨)
if self.current_trade_side is None and not self._is_reentering:
logger.debug(f"[{self.symbol}] 이미 Flat 상태 — 콜백 건너뜀")
self._close_event.set()
return
estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl
await self.risk.close_position(self.symbol, net_pnl)
self.notifier.notify_close(
symbol=self.symbol,
side=self.current_trade_side or "UNKNOWN",
close_reason=close_reason,
exit_price=exit_price,
estimated_pnl=estimated_pnl,
net_pnl=net_pnl,
diff=diff,
)
logger.success(
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# 거래 기록 저장 + 킬스위치 검사 (청산 후 항상 수행)
self._append_trade(net_pnl, close_reason)
self._check_kill_switch()
# _close_and_reenter 대기 해제
self._close_event.set()
return
estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
if self._is_reentering:
return
await self.risk.close_position(self.symbol, net_pnl)
self.notifier.notify_close(
symbol=self.symbol,
side=self.current_trade_side or "UNKNOWN",
close_reason=close_reason,
exit_price=exit_price,
estimated_pnl=estimated_pnl,
net_pnl=net_pnl,
diff=diff,
)
logger.success(
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# 거래 기록 저장 + 킬스위치 검사 (청산 후 항상 수행)
self._append_trade(net_pnl, close_reason)
self._check_kill_switch()
# _close_and_reenter 대기 해제
self._close_event.set()
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
if self._is_reentering:
return
# Flat 상태로 초기화
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
# Flat 상태로 초기화
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
_MONITOR_INTERVAL = 300 # 5분
@@ -544,52 +606,56 @@ class TradingBot:
try:
actual_pos = await self.exchange.get_position()
if actual_pos is None:
logger.warning(
f"[{self.symbol}] 포지션 불일치 감지: "
f"봇={self.current_trade_side}, 바이낸스=포지션 없음 — 상태 동기화"
)
# Binance income API에서 실제 PnL 조회
realized_pnl = 0.0
commission = 0.0
exit_price = 0.0
try:
pnl_rows, comm_rows = await self.exchange.get_recent_income(limit=10)
if pnl_rows:
realized_pnl = sum(float(r.get("income", "0")) for r in pnl_rows)
if comm_rows:
commission = sum(abs(float(r.get("income", "0"))) for r in comm_rows)
except Exception:
pass
net_pnl = realized_pnl - commission
# exit_price 추정: 진입가 + PnL/수량
if self._entry_quantity and self._entry_quantity > 0 and self._entry_price:
if self.current_trade_side == "LONG":
exit_price = self._entry_price + realized_pnl / self._entry_quantity
else:
exit_price = self._entry_price - realized_pnl / self._entry_quantity
async with self._close_lock:
# Lock 획득 후 재확인 (콜백이 먼저 처리했을 수 있음)
if self.current_trade_side is None:
continue
logger.warning(
f"[{self.symbol}] 포지션 불일치 감지: "
f"봇={self.current_trade_side}, 바이낸스=포지션 없음 — 상태 동기화"
)
# Binance income API에서 실제 PnL 조회
realized_pnl = 0.0
commission = 0.0
exit_price = 0.0
try:
pnl_rows, comm_rows = await self.exchange.get_recent_income(limit=10)
if pnl_rows:
realized_pnl = sum(float(r.get("income", "0")) for r in pnl_rows)
if comm_rows:
commission = sum(abs(float(r.get("income", "0"))) for r in comm_rows)
except Exception:
pass
net_pnl = realized_pnl - commission
# exit_price 추정: 진입가 + PnL/수량
if self._entry_quantity and self._entry_quantity > 0 and self._entry_price:
if self.current_trade_side == "LONG":
exit_price = self._entry_price + realized_pnl / self._entry_quantity
else:
exit_price = self._entry_price - realized_pnl / self._entry_quantity
await self.risk.close_position(self.symbol, net_pnl)
self.notifier.notify_close(
symbol=self.symbol,
side=self.current_trade_side,
close_reason="SYNC",
exit_price=exit_price,
estimated_pnl=realized_pnl,
net_pnl=net_pnl,
diff=net_pnl - realized_pnl,
)
logger.info(
f"[{self.symbol}] 청산 감지(SYNC): exit={exit_price:.4f}, "
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}"
)
self._append_trade(net_pnl, "SYNC")
self._check_kill_switch()
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
self._close_event.set()
continue
await self.risk.close_position(self.symbol, net_pnl)
self.notifier.notify_close(
symbol=self.symbol,
side=self.current_trade_side,
close_reason="SYNC",
exit_price=exit_price,
estimated_pnl=realized_pnl,
net_pnl=net_pnl,
diff=net_pnl - realized_pnl,
)
logger.info(
f"[{self.symbol}] 청산 감지(SYNC): exit={exit_price:.4f}, "
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}"
)
self._append_trade(net_pnl, "SYNC")
self._check_kill_switch()
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
self._close_event.set()
continue
except Exception as e:
logger.debug(f"[{self.symbol}] 포지션 동기화 확인 실패 (무시): {e}")