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:
21in7
2026-03-03 20:36:46 +09:00
parent 292ecc3e33
commit a33283ecb3
4 changed files with 119 additions and 0 deletions

View File

@@ -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
# 예외 없이 정상 종료되어야 한다

View File

@@ -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)