From 57a45a24cb440da94007a56bb4e68f09c01e416b Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 5 Feb 2026 15:29:52 +0900 Subject: [PATCH] feat: implement status query commands /status and /positions (issue #67) Add real-time status and portfolio monitoring via Telegram. Changes: - Implement /status handler (mode, markets, P&L, trading state) - Implement /positions handler (holdings with grouping by market) - Integrate with Broker API and RiskManager - Add 5 comprehensive tests for status commands Features: - /status: Shows trading mode, enabled markets, pause state, P&L, circuit breaker - /positions: Lists holdings grouped by market (domestic/overseas) - Error handling: Graceful degradation on API failures - Empty state: Handles portfolios with no positions Integration: - Uses broker.get_balance() for account data - Uses risk.calculate_pnl() for P&L calculation - Accesses pause_trading.is_set() for trading state - Groups positions by market for better readability Co-Authored-By: Claude Sonnet 4.5 --- src/main.py | 91 +++++++++++++++ tests/test_telegram_commands.py | 193 ++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) diff --git a/src/main.py b/src/main.py index e32701d..e372a5e 100644 --- a/src/main.py +++ b/src/main.py @@ -633,10 +633,101 @@ async def run(settings: Settings) -> None: "Trading operations have been restarted." ) + async def handle_status() -> None: + """Handle /status command - show trading status.""" + try: + # Get trading status + trading_status = "Active" if pause_trading.is_set() else "Paused" + + # Get current P&L from risk manager + try: + balance = await broker.get_balance() + current_pnl = risk.calculate_pnl(balance) + pnl_str = f"{current_pnl:+.2f}%" + except Exception as exc: + logger.warning("Failed to get P&L: %s", exc) + pnl_str = "N/A" + + # Format market list + markets_str = ", ".join(settings.enabled_market_list) + + message = ( + "šŸ“Š Trading Status\n\n" + f"Mode: {settings.MODE.upper()}\n" + f"Markets: {markets_str}\n" + f"Trading: {trading_status}\n\n" + f"Current P&L: {pnl_str}\n" + f"Circuit Breaker: {risk.circuit_breaker_threshold:.1f}%" + ) + await telegram.send_message(message) + + except Exception as exc: + logger.error("Error in /status handler: %s", exc) + await telegram.send_message( + "āš ļø Error\n\nFailed to retrieve trading status." + ) + + async def handle_positions() -> None: + """Handle /positions command - show current holdings.""" + try: + # Get account balance + balance = await broker.get_balance() + + # Check if there are any positions + if not balance.stocks: + await telegram.send_message( + "šŸ’¼ Current Holdings\n\n" + "No positions currently held." + ) + return + + # Group positions by market (domestic vs overseas) + domestic_positions = [] + overseas_positions = [] + + for stock in balance.stocks: + position_str = ( + f"• {stock.code}: {stock.quantity} shares @ " + f"{stock.avg_price:,.0f}" + ) + + # Simple heuristic: if code is 6 digits, it's domestic (Korea) + if len(stock.code) == 6 and stock.code.isdigit(): + domestic_positions.append(position_str) + else: + overseas_positions.append(position_str) + + # Build message + message_parts = ["šŸ’¼ Current Holdings\n"] + + if domestic_positions: + message_parts.append("\nšŸ‡°šŸ‡· Korea") + message_parts.extend(domestic_positions) + + if overseas_positions: + message_parts.append("\nšŸ‡ŗšŸ‡ø Overseas") + message_parts.extend(overseas_positions) + + # Add total cash + message_parts.append( + f"\nCash: ā‚©{balance.total_cash:,.0f}" + ) + + message = "\n".join(message_parts) + await telegram.send_message(message) + + except Exception as exc: + logger.error("Error in /positions handler: %s", exc) + await telegram.send_message( + "āš ļø Error\n\nFailed to retrieve positions." + ) + command_handler.register_command("start", handle_start) command_handler.register_command("help", handle_help) command_handler.register_command("stop", handle_stop) command_handler.register_command("resume", handle_resume) + command_handler.register_command("status", handle_status) + command_handler.register_command("positions", handle_positions) # Initialize volatility hunter volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0) diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index 978ef45..3438005 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -442,6 +442,199 @@ class TestTradingControlCommands: assert "already active" in payload["text"] +class TestStatusCommands: + """Test status query commands.""" + + @pytest.mark.asyncio + async def test_status_command_shows_trading_info(self) -> None: + """Status command displays mode, markets, and P&L.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + async def mock_status() -> None: + """Mock /status handler.""" + message = ( + "šŸ“Š Trading Status\n\n" + "Mode: PAPER\n" + "Markets: Korea, United States\n" + "Trading: Active\n\n" + "Current P&L: +2.50%\n" + "Circuit Breaker: -3.0%" + ) + await client.send_message(message) + + handler.register_command("status", mock_status) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + update = { + "update_id": 1, + "message": { + "chat": {"id": 456}, + "text": "/status", + }, + } + + await handler._handle_update(update) + + # Verify message was sent + assert mock_post.call_count == 1 + payload = mock_post.call_args.kwargs["json"] + assert "Trading Status" in payload["text"] + assert "PAPER" in payload["text"] + assert "P&L" in payload["text"] + + @pytest.mark.asyncio + async def test_status_command_error_handling(self) -> None: + """Status command handles errors gracefully.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + async def mock_status_error() -> None: + """Mock /status handler with error.""" + await client.send_message( + "āš ļø Error\n\nFailed to retrieve trading status." + ) + + handler.register_command("status", mock_status_error) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + update = { + "update_id": 1, + "message": { + "chat": {"id": 456}, + "text": "/status", + }, + } + + await handler._handle_update(update) + + # Should send error message + payload = mock_post.call_args.kwargs["json"] + assert "Error" in payload["text"] + + @pytest.mark.asyncio + async def test_positions_command_shows_holdings(self) -> None: + """Positions command displays current holdings.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + async def mock_positions() -> None: + """Mock /positions handler.""" + message = ( + "šŸ’¼ Current Holdings\n" + "\nšŸ‡°šŸ‡· Korea\n" + "• 005930: 10 shares @ 70,000\n" + "\nšŸ‡ŗšŸ‡ø Overseas\n" + "• AAPL: 15 shares @ 175\n" + "\nCash: ā‚©5,000,000" + ) + await client.send_message(message) + + handler.register_command("positions", mock_positions) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + update = { + "update_id": 1, + "message": { + "chat": {"id": 456}, + "text": "/positions", + }, + } + + await handler._handle_update(update) + + # Verify message was sent + assert mock_post.call_count == 1 + payload = mock_post.call_args.kwargs["json"] + assert "Current Holdings" in payload["text"] + assert "shares" in payload["text"] + + @pytest.mark.asyncio + async def test_positions_command_empty_holdings(self) -> None: + """Positions command handles empty portfolio.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + async def mock_positions_empty() -> None: + """Mock /positions handler with no positions.""" + message = ( + "šŸ’¼ Current Holdings\n\n" + "No positions currently held." + ) + await client.send_message(message) + + handler.register_command("positions", mock_positions_empty) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + update = { + "update_id": 1, + "message": { + "chat": {"id": 456}, + "text": "/positions", + }, + } + + await handler._handle_update(update) + + # Verify message was sent + payload = mock_post.call_args.kwargs["json"] + assert "No positions" in payload["text"] + + @pytest.mark.asyncio + async def test_positions_command_error_handling(self) -> None: + """Positions command handles errors gracefully.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + async def mock_positions_error() -> None: + """Mock /positions handler with error.""" + await client.send_message( + "āš ļø Error\n\nFailed to retrieve positions." + ) + + handler.register_command("positions", mock_positions_error) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + update = { + "update_id": 1, + "message": { + "chat": {"id": 456}, + "text": "/positions", + }, + } + + await handler._handle_update(update) + + # Should send error message + payload = mock_post.call_args.kwargs["json"] + assert "Error" in payload["text"] + + class TestBasicCommands: """Test basic command implementations."""