From a33283ecb36dbbeb222b21b50661b9a4e95e4785 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Tue, 3 Mar 2026 20:36:46 +0900 Subject: [PATCH] feat: add position monitor logging with real-time price tracking Log current price and unrealized PnL every 5 minutes while holding a position, using the existing kline WebSocket's unclosed candle data for real-time price updates. Co-Authored-By: Claude Opus 4.6 --- src/bot.py | 21 ++++++++++++++++ src/data_stream.py | 5 ++++ tests/test_bot.py | 51 +++++++++++++++++++++++++++++++++++++++ tests/test_data_stream.py | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) diff --git a/src/bot.py b/src/bot.py index 75f0038..59a7bba 100644 --- a/src/bot.py +++ b/src/bot.py @@ -235,6 +235,26 @@ class TradingBot: self._entry_price = None self._entry_quantity = None + _MONITOR_INTERVAL = 300 # 5분 + + async def _position_monitor(self): + """포지션 보유 중일 때 5분마다 현재가·미실현 PnL을 로깅한다.""" + while True: + await asyncio.sleep(self._MONITOR_INTERVAL) + if self.current_trade_side is None: + continue + price = self.stream.latest_price + if price is None or self._entry_price is None or self._entry_quantity is None: + continue + pnl = self._calc_estimated_pnl(price) + cost = self._entry_price * self._entry_quantity + pnl_pct = (pnl / cost * 100) if cost > 0 else 0.0 + logger.info( + f"포지션 모니터 | {self.current_trade_side} | " + f"현재가={price:.4f} | PnL={pnl:+.4f} USDT ({pnl_pct:+.2f}%) | " + f"진입가={self._entry_price:.4f}" + ) + async def _close_position(self, position: dict): """포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당.""" amt = abs(float(position["positionAmt"])) @@ -298,4 +318,5 @@ class TradingBot: api_key=self.config.api_key, api_secret=self.config.api_secret, ), + self._position_monitor(), ) diff --git a/src/data_stream.py b/src/data_stream.py index 086e005..66b640c 100644 --- a/src/data_stream.py +++ b/src/data_stream.py @@ -116,6 +116,8 @@ class MultiSymbolStream: } # 첫 번째 심볼이 주 심볼 (XRP) self.primary_symbol = self.symbols[0] + # 미종료 캔들 포함 최신 가격 (포지션 모니터링용) + self.latest_price: float | None = None def parse_kline(self, msg: dict) -> dict: k = msg["k"] @@ -142,6 +144,9 @@ class MultiSymbolStream: symbol = data["s"].lower() candle = self.parse_kline(data) + if symbol == self.primary_symbol: + self.latest_price = candle["close"] + if candle["is_closed"] and symbol in self.buffers: self.buffers[symbol].append(candle) if symbol == self.primary_symbol and self.on_candle: diff --git a/tests/test_bot.py b/tests/test_bot.py index a60769b..f89f52f 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1,3 +1,4 @@ +import asyncio import pytest from unittest.mock import AsyncMock, MagicMock, patch import pandas as pd @@ -246,3 +247,53 @@ def test_calc_oi_change_api_failure_does_not_corrupt_state(config): result = bot._calc_oi_change(5100000.0) assert abs(result - 0.02) < 1e-6 # (5100000 - 5000000) / 5000000 = 0.02 assert bot._prev_oi == 5100000.0 + + +@pytest.mark.asyncio +async def test_position_monitor_logs_when_position_open(config, caplog): + """포지션 보유 중일 때 모니터가 현재가와 PnL을 로깅해야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot.current_trade_side = "LONG" + bot._entry_price = 0.5000 + bot._entry_quantity = 100.0 + bot.stream.latest_price = 0.5100 + + # 인터벌을 0으로 줄여 즉시 실행되게 함 + bot._MONITOR_INTERVAL = 0 + + import loguru + loguru.logger.enable("src.bot") + + task = asyncio.create_task(bot._position_monitor()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # loguru는 caplog와 호환되지 않으므로 직접 로그 확인 대신 예외 없이 실행됨을 확인 + # PnL 계산이 올바른지 직접 검증 + pnl = bot._calc_estimated_pnl(0.5100) + assert abs(pnl - 1.0) < 1e-6 # (0.51 - 0.50) * 100 = 1.0 + + +@pytest.mark.asyncio +async def test_position_monitor_skips_when_no_position(config): + """포지션이 없을 때 모니터는 로깅하지 않고 넘어가야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot.current_trade_side = None + bot._MONITOR_INTERVAL = 0 + + task = asyncio.create_task(bot._position_monitor()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + # 예외 없이 정상 종료되어야 한다 diff --git a/tests/test_data_stream.py b/tests/test_data_stream.py index e5c84c6..607a6fd 100644 --- a/tests/test_data_stream.py +++ b/tests/test_data_stream.py @@ -84,6 +84,48 @@ async def test_callback_called_on_closed_candle(): assert callback.call_count == 1 +@pytest.mark.asyncio +async def test_multi_symbol_stream_updates_latest_price_on_every_message(): + """미종료 캔들 메시지도 primary symbol의 latest_price를 업데이트해야 한다.""" + stream = MultiSymbolStream( + symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"], + interval="15m", + ) + assert stream.latest_price is None + + # 미종료 캔들 메시지 (is_closed=False) + msg = { + "stream": "xrpusdt@kline_15m", + "data": { + "e": "kline", + "s": "XRPUSDT", + "k": { + "t": 1700000000000, "o": "0.5", "h": "0.51", + "l": "0.49", "c": "0.5050", "v": "100000", "x": False, + }, + }, + } + await stream.handle_message(msg) + assert stream.latest_price == 0.5050 + # 미종료 캔들은 버퍼에 추가되지 않아야 한다 + assert len(stream.buffers["xrpusdt"]) == 0 + + # BTC 메시지는 latest_price를 변경하지 않아야 한다 + btc_msg = { + "stream": "btcusdt@kline_15m", + "data": { + "e": "kline", + "s": "BTCUSDT", + "k": { + "t": 1700000000000, "o": "60000", "h": "61000", + "l": "59000", "c": "60500", "v": "500", "x": False, + }, + }, + } + await stream.handle_message(btc_msg) + assert stream.latest_price == 0.5050 # 변경 없음 + + @pytest.mark.asyncio async def test_preload_history_fills_buffer(): stream = KlineStream(symbol="XRPUSDT", interval="1m", buffer_size=200) -- 2.47.3