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:
21in7
2026-03-19 23:03:52 +09:00
parent 24ed7ddec0
commit 181f82d3c0
9 changed files with 189 additions and 182 deletions

View File

@@ -84,7 +84,7 @@ async def test_bot_processes_signal(config, sample_df):
bot.exchange.MIN_NOTIONAL = 5.0
bot.risk = MagicMock()
bot.risk.is_trading_allowed.return_value = True
bot.risk.is_trading_allowed = AsyncMock(return_value=True)
bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.register_position = AsyncMock()
bot.risk.get_dynamic_margin_ratio.return_value = 0.50
@@ -108,10 +108,14 @@ async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df):
bot._open_position = AsyncMock()
bot.risk = MagicMock()
bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.close_position = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = True
# 콜백 대기를 건너뛰도록 Event 미리 설정
bot._close_event.set()
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
@@ -129,10 +133,13 @@ async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df):
bot._open_position = AsyncMock()
bot.risk = MagicMock()
bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.close_position = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = False
bot._close_event.set()
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
@@ -150,6 +157,9 @@ async def test_close_and_reenter_skips_open_when_max_positions_reached(config, s
bot._open_position = AsyncMock()
bot.risk = MagicMock()
bot.risk.can_open_new_position = AsyncMock(return_value=False)
bot.risk.close_position = AsyncMock()
bot._close_event.set()
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
@@ -234,7 +244,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
bot.risk = MagicMock()
bot.risk.is_trading_allowed.return_value = True
bot.risk.is_trading_allowed = AsyncMock(return_value=True)
bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.register_position = AsyncMock()
bot.risk.get_dynamic_margin_ratio.return_value = 0.50
@@ -266,7 +276,7 @@ def test_bot_has_oi_history_deque(config):
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
assert isinstance(bot._oi_history, deque)
assert bot._oi_history.maxlen == 5
assert bot._oi_history.maxlen == 96
@pytest.mark.asyncio

View File

@@ -15,18 +15,20 @@ def config():
return Config()
def test_max_drawdown_check(config):
@pytest.mark.asyncio
async def test_max_drawdown_check(config):
rm = RiskManager(config, max_daily_loss_pct=0.05)
rm.daily_pnl = -60.0
rm.initial_balance = 1000.0
assert rm.is_trading_allowed() is False
assert await rm.is_trading_allowed() is False
def test_trading_allowed_normal(config):
@pytest.mark.asyncio
async def test_trading_allowed_normal(config):
rm = RiskManager(config, max_daily_loss_pct=0.05)
rm.daily_pnl = -10.0
rm.initial_balance = 1000.0
assert rm.is_trading_allowed() is True
assert await rm.is_trading_allowed() is True
@pytest.mark.asyncio