fix: address critical code review issues (PnL double recording, sync HTTP, race conditions)
- fix(bot): prevent PnL double recording in _close_and_reenter using asyncio.Event - fix(bot): prevent SYNC detection PnL duplication with _close_handled_by_sync flag - fix(notifier): move sync HTTP call to background thread via run_in_executor - fix(risk_manager): make is_trading_allowed async with lock for thread safety - fix(exchange): cache exchange info at class level (1 API call for all symbols) - fix(exchange): use `is not None` instead of truthy check for price/stop_price - refactor(backtester): extract _calc_trade_stats to eliminate code duplication - fix(ml_features): apply rolling z-score to OI/funding rate in serving (train-serve skew) - fix(bot): use config.correlation_symbols instead of hardcoded BTCUSDT/ETHUSDT - fix(bot): expand OI/funding history deque to 96 for z-score window - cleanup(config): remove unused stop_loss_pct, take_profit_pct, trailing_stop_pct fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,73 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def _calc_trade_stats(trades: list[dict], initial_balance: float) -> dict:
|
||||
"""거래 리스트에서 통계 요약을 계산한다. Backtester와 WalkForward 공통 사용."""
|
||||
if not trades:
|
||||
return {
|
||||
"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0,
|
||||
"win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
|
||||
"payoff_ratio": 0.0, "max_consecutive_losses": 0,
|
||||
"profit_factor": 0.0, "max_drawdown_pct": 0.0,
|
||||
"sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {},
|
||||
}
|
||||
|
||||
pnls = [t["net_pnl"] for t in trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
|
||||
total_pnl = sum(pnls)
|
||||
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in trades)
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
cumulative = np.cumsum(pnls)
|
||||
equity = initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
if len(pnls) > 1:
|
||||
pnl_arr = np.array(pnls)
|
||||
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
avg_w = float(np.mean(wins)) if wins else 0.0
|
||||
avg_l = float(np.mean(losses)) if losses else 0.0
|
||||
payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf")
|
||||
|
||||
max_consec_loss = 0
|
||||
cur_streak = 0
|
||||
for p in pnls:
|
||||
if p <= 0:
|
||||
cur_streak += 1
|
||||
max_consec_loss = max(max_consec_loss, cur_streak)
|
||||
else:
|
||||
cur_streak = 0
|
||||
|
||||
reasons = {}
|
||||
for t in trades:
|
||||
r = t["close_reason"]
|
||||
reasons[r] = reasons.get(r, 0) + 1
|
||||
|
||||
return {
|
||||
"total_trades": len(trades),
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"return_pct": round(total_pnl / initial_balance * 100, 2),
|
||||
"win_rate": round(len(wins) / len(trades) * 100, 2),
|
||||
"avg_win": round(avg_w, 4),
|
||||
"avg_loss": round(avg_l, 4),
|
||||
"payoff_ratio": payoff_ratio,
|
||||
"max_consecutive_losses": max_consec_loss,
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"max_drawdown_pct": round(mdd, 2),
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"total_fees": round(total_fees, 4),
|
||||
"close_reasons": reasons,
|
||||
}
|
||||
|
||||
import warnings
|
||||
|
||||
import joblib
|
||||
@@ -524,80 +591,7 @@ class Backtester:
|
||||
}
|
||||
|
||||
def _calc_summary(self) -> dict:
|
||||
if not self.trades:
|
||||
return {
|
||||
"total_trades": 0,
|
||||
"total_pnl": 0.0,
|
||||
"return_pct": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"profit_factor": 0.0,
|
||||
"max_drawdown_pct": 0.0,
|
||||
"sharpe_ratio": 0.0,
|
||||
"total_fees": 0.0,
|
||||
"close_reasons": {},
|
||||
}
|
||||
|
||||
pnls = [t["net_pnl"] for t in self.trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
|
||||
total_pnl = sum(pnls)
|
||||
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in self.trades)
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
# MDD 계산
|
||||
cumulative = np.cumsum(pnls)
|
||||
equity = self.cfg.initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
# 샤프비율 (연율화, 15분봉 기준: 252일 * 96봉 = 24192)
|
||||
if len(pnls) > 1:
|
||||
pnl_arr = np.array(pnls)
|
||||
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
# 손익비 (avg_win / |avg_loss|)
|
||||
avg_w = float(np.mean(wins)) if wins else 0.0
|
||||
avg_l = float(np.mean(losses)) if losses else 0.0
|
||||
payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf")
|
||||
|
||||
# 최대 연속 손실 횟수
|
||||
max_consec_loss = 0
|
||||
cur_streak = 0
|
||||
for p in pnls:
|
||||
if p <= 0:
|
||||
cur_streak += 1
|
||||
max_consec_loss = max(max_consec_loss, cur_streak)
|
||||
else:
|
||||
cur_streak = 0
|
||||
|
||||
# 청산 사유별 비율
|
||||
reasons = {}
|
||||
for t in self.trades:
|
||||
r = t["close_reason"]
|
||||
reasons[r] = reasons.get(r, 0) + 1
|
||||
|
||||
return {
|
||||
"total_trades": len(self.trades),
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2),
|
||||
"win_rate": round(len(wins) / len(self.trades) * 100, 2) if self.trades else 0.0,
|
||||
"avg_win": round(avg_w, 4),
|
||||
"avg_loss": round(avg_l, 4),
|
||||
"payoff_ratio": payoff_ratio,
|
||||
"max_consecutive_losses": max_consec_loss,
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"max_drawdown_pct": round(mdd, 2),
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"total_fees": round(total_fees, 4),
|
||||
"close_reasons": reasons,
|
||||
}
|
||||
return _calc_trade_stats(self.trades, self.cfg.initial_balance)
|
||||
|
||||
|
||||
# ── Walk-Forward 백테스트 ─────────────────────────────────────────────
|
||||
@@ -810,70 +804,7 @@ class WalkForwardBacktester:
|
||||
"""폴드별 결과를 합산하여 전체 Walk-Forward 결과 생성."""
|
||||
from src.backtest_validator import validate
|
||||
|
||||
# 전체 통계 계산
|
||||
if not all_trades:
|
||||
summary = {"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0,
|
||||
"win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
|
||||
"payoff_ratio": 0.0, "max_consecutive_losses": 0,
|
||||
"profit_factor": 0.0, "max_drawdown_pct": 0.0,
|
||||
"sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {}}
|
||||
else:
|
||||
pnls = [t["net_pnl"] for t in all_trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
total_pnl = sum(pnls)
|
||||
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in all_trades)
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
cumulative = np.cumsum(pnls)
|
||||
equity = self.cfg.initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
if len(pnls) > 1:
|
||||
pnl_arr = np.array(pnls)
|
||||
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
# 손익비 (avg_win / |avg_loss|)
|
||||
avg_w = float(np.mean(wins)) if wins else 0.0
|
||||
avg_l = float(np.mean(losses)) if losses else 0.0
|
||||
payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf")
|
||||
|
||||
# 최대 연속 손실 횟수
|
||||
max_consec_loss = 0
|
||||
cur_streak = 0
|
||||
for p in pnls:
|
||||
if p <= 0:
|
||||
cur_streak += 1
|
||||
max_consec_loss = max(max_consec_loss, cur_streak)
|
||||
else:
|
||||
cur_streak = 0
|
||||
|
||||
reasons = {}
|
||||
for t in all_trades:
|
||||
r = t["close_reason"]
|
||||
reasons[r] = reasons.get(r, 0) + 1
|
||||
|
||||
summary = {
|
||||
"total_trades": len(all_trades),
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2),
|
||||
"win_rate": round(len(wins) / len(all_trades) * 100, 2),
|
||||
"avg_win": round(avg_w, 4),
|
||||
"avg_loss": round(avg_l, 4),
|
||||
"payoff_ratio": payoff_ratio,
|
||||
"max_consecutive_losses": max_consec_loss,
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"max_drawdown_pct": round(mdd, 2),
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"total_fees": round(total_fees, 4),
|
||||
"close_reasons": reasons,
|
||||
}
|
||||
|
||||
summary = _calc_trade_stats(all_trades, self.cfg.initial_balance)
|
||||
validation = validate(all_trades, summary, self.cfg)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user