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 <noreply@anthropic.com>
This commit is contained in:
21
src/bot.py
21
src/bot.py
@@ -235,6 +235,26 @@ class TradingBot:
|
|||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = 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):
|
async def _close_position(self, position: dict):
|
||||||
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
|
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
|
||||||
amt = abs(float(position["positionAmt"]))
|
amt = abs(float(position["positionAmt"]))
|
||||||
@@ -298,4 +318,5 @@ class TradingBot:
|
|||||||
api_key=self.config.api_key,
|
api_key=self.config.api_key,
|
||||||
api_secret=self.config.api_secret,
|
api_secret=self.config.api_secret,
|
||||||
),
|
),
|
||||||
|
self._position_monitor(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ class MultiSymbolStream:
|
|||||||
}
|
}
|
||||||
# 첫 번째 심볼이 주 심볼 (XRP)
|
# 첫 번째 심볼이 주 심볼 (XRP)
|
||||||
self.primary_symbol = self.symbols[0]
|
self.primary_symbol = self.symbols[0]
|
||||||
|
# 미종료 캔들 포함 최신 가격 (포지션 모니터링용)
|
||||||
|
self.latest_price: float | None = None
|
||||||
|
|
||||||
def parse_kline(self, msg: dict) -> dict:
|
def parse_kline(self, msg: dict) -> dict:
|
||||||
k = msg["k"]
|
k = msg["k"]
|
||||||
@@ -142,6 +144,9 @@ class MultiSymbolStream:
|
|||||||
symbol = data["s"].lower()
|
symbol = data["s"].lower()
|
||||||
candle = self.parse_kline(data)
|
candle = self.parse_kline(data)
|
||||||
|
|
||||||
|
if symbol == self.primary_symbol:
|
||||||
|
self.latest_price = candle["close"]
|
||||||
|
|
||||||
if candle["is_closed"] and symbol in self.buffers:
|
if candle["is_closed"] and symbol in self.buffers:
|
||||||
self.buffers[symbol].append(candle)
|
self.buffers[symbol].append(candle)
|
||||||
if symbol == self.primary_symbol and self.on_candle:
|
if symbol == self.primary_symbol and self.on_candle:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import pandas as pd
|
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)
|
result = bot._calc_oi_change(5100000.0)
|
||||||
assert abs(result - 0.02) < 1e-6 # (5100000 - 5000000) / 5000000 = 0.02
|
assert abs(result - 0.02) < 1e-6 # (5100000 - 5000000) / 5000000 = 0.02
|
||||||
assert bot._prev_oi == 5100000.0
|
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
|
||||||
|
# 예외 없이 정상 종료되어야 한다
|
||||||
|
|||||||
@@ -84,6 +84,48 @@ async def test_callback_called_on_closed_candle():
|
|||||||
assert callback.call_count == 1
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_preload_history_fills_buffer():
|
async def test_preload_history_fills_buffer():
|
||||||
stream = KlineStream(symbol="XRPUSDT", interval="1m", buffer_size=200)
|
stream = KlineStream(symbol="XRPUSDT", interval="1m", buffer_size=200)
|
||||||
|
|||||||
Reference in New Issue
Block a user