Compare commits
11 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca3fe9f5d | ||
| 462f8763ab | |||
|
|
57a45a24cb | ||
| a7696568cc | |||
|
|
70701bf73a | ||
| 20dbd94892 | |||
|
|
48a99962e3 | ||
| ee66ecc305 | |||
|
|
065c9daaad | ||
| c76b9d5c15 | |||
| 8e715c55cd |
@@ -66,6 +66,10 @@ class Settings(BaseSettings):
|
|||||||
TELEGRAM_CHAT_ID: str | None = None
|
TELEGRAM_CHAT_ID: str | None = None
|
||||||
TELEGRAM_ENABLED: bool = True
|
TELEGRAM_ENABLED: bool = True
|
||||||
|
|
||||||
|
# Telegram Commands (optional)
|
||||||
|
TELEGRAM_COMMANDS_ENABLED: bool = True
|
||||||
|
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
172
src/main.py
172
src/main.py
@@ -29,7 +29,7 @@ from src.db import init_db, log_trade
|
|||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.logging_config import setup_logging
|
from src.logging_config import setup_logging
|
||||||
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
||||||
from src.notifications.telegram_client import TelegramClient
|
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -575,6 +575,160 @@ async def run(settings: Settings) -> None:
|
|||||||
enabled=settings.TELEGRAM_ENABLED,
|
enabled=settings.TELEGRAM_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Initialize Telegram command handler
|
||||||
|
command_handler = TelegramCommandHandler(telegram)
|
||||||
|
|
||||||
|
# Register basic commands
|
||||||
|
async def handle_start() -> None:
|
||||||
|
"""Handle /start command."""
|
||||||
|
message = (
|
||||||
|
"<b>🤖 The Ouroboros Trading Bot</b>\n\n"
|
||||||
|
"AI-powered global stock trading agent with real-time notifications.\n\n"
|
||||||
|
"<b>Available commands:</b>\n"
|
||||||
|
"/help - Show this help message\n"
|
||||||
|
"/status - Current trading status\n"
|
||||||
|
"/positions - View holdings\n"
|
||||||
|
"/stop - Pause trading\n"
|
||||||
|
"/resume - Resume trading"
|
||||||
|
)
|
||||||
|
await telegram.send_message(message)
|
||||||
|
|
||||||
|
async def handle_help() -> None:
|
||||||
|
"""Handle /help command."""
|
||||||
|
message = (
|
||||||
|
"<b>📖 Available Commands</b>\n\n"
|
||||||
|
"/start - Welcome message\n"
|
||||||
|
"/help - Show available commands\n"
|
||||||
|
"/status - Trading status (mode, markets, P&L)\n"
|
||||||
|
"/positions - Current holdings\n"
|
||||||
|
"/stop - Pause trading\n"
|
||||||
|
"/resume - Resume trading"
|
||||||
|
)
|
||||||
|
await telegram.send_message(message)
|
||||||
|
|
||||||
|
async def handle_stop() -> None:
|
||||||
|
"""Handle /stop command - pause trading."""
|
||||||
|
if not pause_trading.is_set():
|
||||||
|
await telegram.send_message("⏸️ Trading is already paused")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_trading.clear()
|
||||||
|
logger.info("Trading paused via Telegram command")
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>⏸️ Trading Paused</b>\n\n"
|
||||||
|
"All trading operations have been suspended.\n"
|
||||||
|
"Use /resume to restart trading."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_resume() -> None:
|
||||||
|
"""Handle /resume command - resume trading."""
|
||||||
|
if pause_trading.is_set():
|
||||||
|
await telegram.send_message("▶️ Trading is already active")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_trading.set()
|
||||||
|
logger.info("Trading resumed via Telegram command")
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>▶️ Trading Resumed</b>\n\n"
|
||||||
|
"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 = (
|
||||||
|
"<b>📊 Trading Status</b>\n\n"
|
||||||
|
f"<b>Mode:</b> {settings.MODE.upper()}\n"
|
||||||
|
f"<b>Markets:</b> {markets_str}\n"
|
||||||
|
f"<b>Trading:</b> {trading_status}\n\n"
|
||||||
|
f"<b>Current P&L:</b> {pnl_str}\n"
|
||||||
|
f"<b>Circuit Breaker:</b> {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(
|
||||||
|
"<b>⚠️ Error</b>\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(
|
||||||
|
"<b>💼 Current Holdings</b>\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 = ["<b>💼 Current Holdings</b>\n"]
|
||||||
|
|
||||||
|
if domestic_positions:
|
||||||
|
message_parts.append("\n🇰🇷 <b>Korea</b>")
|
||||||
|
message_parts.extend(domestic_positions)
|
||||||
|
|
||||||
|
if overseas_positions:
|
||||||
|
message_parts.append("\n🇺🇸 <b>Overseas</b>")
|
||||||
|
message_parts.extend(overseas_positions)
|
||||||
|
|
||||||
|
# Add total cash
|
||||||
|
message_parts.append(
|
||||||
|
f"\n<b>Cash:</b> ₩{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(
|
||||||
|
"<b>⚠️ Error</b>\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
|
# Initialize volatility hunter
|
||||||
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
||||||
market_scanner = MarketScanner(
|
market_scanner = MarketScanner(
|
||||||
@@ -602,7 +756,10 @@ async def run(settings: Settings) -> None:
|
|||||||
# Track market open/close state for notifications
|
# Track market open/close state for notifications
|
||||||
_market_states: dict[str, bool] = {} # market_code -> is_open
|
_market_states: dict[str, bool] = {} # market_code -> is_open
|
||||||
|
|
||||||
|
# Trading control events
|
||||||
shutdown = asyncio.Event()
|
shutdown = asyncio.Event()
|
||||||
|
pause_trading = asyncio.Event()
|
||||||
|
pause_trading.set() # Default: trading enabled
|
||||||
|
|
||||||
def _signal_handler() -> None:
|
def _signal_handler() -> None:
|
||||||
logger.info("Shutdown signal received")
|
logger.info("Shutdown signal received")
|
||||||
@@ -621,6 +778,12 @@ async def run(settings: Settings) -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("System startup notification failed: %s", exc)
|
logger.warning("System startup notification failed: %s", exc)
|
||||||
|
|
||||||
|
# Start command handler
|
||||||
|
try:
|
||||||
|
await command_handler.start_polling()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to start command handler: %s", exc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Branch based on trading mode
|
# Branch based on trading mode
|
||||||
if settings.TRADE_MODE == "daily":
|
if settings.TRADE_MODE == "daily":
|
||||||
@@ -634,6 +797,9 @@ async def run(settings: Settings) -> None:
|
|||||||
session_interval = settings.SESSION_INTERVAL_HOURS * 3600 # Convert to seconds
|
session_interval = settings.SESSION_INTERVAL_HOURS * 3600 # Convert to seconds
|
||||||
|
|
||||||
while not shutdown.is_set():
|
while not shutdown.is_set():
|
||||||
|
# Wait for trading to be unpaused
|
||||||
|
await pause_trading.wait()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await run_daily_session(
|
await run_daily_session(
|
||||||
broker,
|
broker,
|
||||||
@@ -666,6 +832,9 @@ async def run(settings: Settings) -> None:
|
|||||||
logger.info("Realtime trading mode: 60s interval per stock")
|
logger.info("Realtime trading mode: 60s interval per stock")
|
||||||
|
|
||||||
while not shutdown.is_set():
|
while not shutdown.is_set():
|
||||||
|
# Wait for trading to be unpaused
|
||||||
|
await pause_trading.wait()
|
||||||
|
|
||||||
# Get currently open markets
|
# Get currently open markets
|
||||||
open_markets = get_open_markets(settings.enabled_market_list)
|
open_markets = get_open_markets(settings.enabled_market_list)
|
||||||
|
|
||||||
@@ -831,6 +1000,7 @@ async def run(settings: Settings) -> None:
|
|||||||
pass # Normal — timeout means it's time for next cycle
|
pass # Normal — timeout means it's time for next cycle
|
||||||
finally:
|
finally:
|
||||||
# Clean up resources
|
# Clean up resources
|
||||||
|
await command_handler.stop_polling()
|
||||||
await broker.close()
|
await broker.close()
|
||||||
await telegram.close()
|
await telegram.close()
|
||||||
db_conn.close()
|
db_conn.close()
|
||||||
|
|||||||
@@ -200,14 +200,151 @@ telegram = TelegramClient(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Bidirectional Commands
|
||||||
|
|
||||||
|
Control your trading bot remotely via Telegram commands. The bot not only sends notifications but also accepts commands for real-time control.
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/start` | Welcome message with quick start guide |
|
||||||
|
| `/help` | List all available commands |
|
||||||
|
| `/status` | Current trading status (mode, markets, P&L, circuit breaker) |
|
||||||
|
| `/positions` | View current holdings grouped by market |
|
||||||
|
| `/stop` | Pause all trading operations |
|
||||||
|
| `/resume` | Resume trading operations |
|
||||||
|
|
||||||
|
### Command Examples
|
||||||
|
|
||||||
|
**Check Trading Status**
|
||||||
|
```
|
||||||
|
You: /status
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
📊 Trading Status
|
||||||
|
|
||||||
|
Mode: PAPER
|
||||||
|
Markets: Korea, United States
|
||||||
|
Trading: Active
|
||||||
|
|
||||||
|
Current P&L: +2.50%
|
||||||
|
Circuit Breaker: -3.0%
|
||||||
|
```
|
||||||
|
|
||||||
|
**View Holdings**
|
||||||
|
```
|
||||||
|
You: /positions
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
💼 Current Holdings
|
||||||
|
|
||||||
|
🇰🇷 Korea
|
||||||
|
• 005930: 10 shares @ 70,000
|
||||||
|
• 035420: 5 shares @ 200,000
|
||||||
|
|
||||||
|
🇺🇸 Overseas
|
||||||
|
• AAPL: 15 shares @ 175
|
||||||
|
• TSLA: 8 shares @ 245
|
||||||
|
|
||||||
|
Cash: ₩5,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pause Trading**
|
||||||
|
```
|
||||||
|
You: /stop
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
⏸️ Trading Paused
|
||||||
|
|
||||||
|
All trading operations have been suspended.
|
||||||
|
Use /resume to restart trading.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resume Trading**
|
||||||
|
```
|
||||||
|
You: /resume
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
▶️ Trading Resumed
|
||||||
|
|
||||||
|
Trading operations have been restarted.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
**Chat ID Verification**
|
||||||
|
- Commands are only accepted from the configured `TELEGRAM_CHAT_ID`
|
||||||
|
- Unauthorized users receive no response
|
||||||
|
- Command attempts from wrong chat IDs are logged
|
||||||
|
|
||||||
|
**Authorization Required**
|
||||||
|
- Only the bot owner (chat ID in `.env`) can control trading
|
||||||
|
- No way for unauthorized users to discover or use commands
|
||||||
|
- All command executions are logged for audit
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commands are enabled by default
|
||||||
|
TELEGRAM_COMMANDS_ENABLED=true
|
||||||
|
|
||||||
|
# Polling interval (seconds) - how often to check for commands
|
||||||
|
TELEGRAM_POLLING_INTERVAL=1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
To disable commands but keep notifications:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_COMMANDS_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Long Polling**: Bot checks Telegram API every second for new messages
|
||||||
|
2. **Command Parsing**: Messages starting with `/` are parsed as commands
|
||||||
|
3. **Authentication**: Chat ID is verified before executing any command
|
||||||
|
4. **Execution**: Command handler is called with current bot state
|
||||||
|
5. **Response**: Result is sent back via Telegram
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Command parsing errors → "Unknown command" response
|
||||||
|
- API failures → Graceful degradation, error logged
|
||||||
|
- Invalid state → Appropriate message (e.g., "Trading is already paused")
|
||||||
|
- Trading loop isolation → Command errors never crash trading
|
||||||
|
|
||||||
|
### Troubleshooting Commands
|
||||||
|
|
||||||
|
**Commands not responding**
|
||||||
|
1. Check `TELEGRAM_COMMANDS_ENABLED=true` in `.env`
|
||||||
|
2. Verify you started conversation with `/start`
|
||||||
|
3. Check logs for command handler errors
|
||||||
|
4. Confirm chat ID matches `.env` configuration
|
||||||
|
|
||||||
|
**Wrong chat ID**
|
||||||
|
- Commands from unauthorized chats are silently ignored
|
||||||
|
- Check logs for "unauthorized chat_id" warnings
|
||||||
|
|
||||||
|
**Delayed responses**
|
||||||
|
- Polling interval is 1 second by default
|
||||||
|
- Network latency may add delay
|
||||||
|
- Check `TELEGRAM_POLLING_INTERVAL` setting
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
See `telegram_client.py` for full API documentation.
|
See `telegram_client.py` for full API documentation.
|
||||||
|
|
||||||
Key methods:
|
### Notification Methods
|
||||||
- `notify_trade_execution()` - Trade alerts
|
- `notify_trade_execution()` - Trade alerts
|
||||||
- `notify_circuit_breaker()` - Emergency stops
|
- `notify_circuit_breaker()` - Emergency stops
|
||||||
- `notify_fat_finger()` - Order rejections
|
- `notify_fat_finger()` - Order rejections
|
||||||
- `notify_market_open/close()` - Session tracking
|
- `notify_market_open/close()` - Session tracking
|
||||||
- `notify_system_start/shutdown()` - Lifecycle events
|
- `notify_system_start/shutdown()` - Lifecycle events
|
||||||
- `notify_error()` - Error alerts
|
- `notify_error()` - Error alerts
|
||||||
|
|
||||||
|
### Command Handler
|
||||||
|
- `TelegramCommandHandler` - Bidirectional command processing
|
||||||
|
- `register_command()` - Register custom command handlers
|
||||||
|
- `start_polling()` / `stop_polling()` - Lifecycle management
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -339,3 +340,171 @@ class TelegramClient:
|
|||||||
await self._send_notification(
|
await self._send_notification(
|
||||||
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
|
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramCommandHandler:
|
||||||
|
"""Handles incoming Telegram commands via long polling."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, client: TelegramClient, polling_interval: float = 1.0
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize command handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: TelegramClient instance for sending responses
|
||||||
|
polling_interval: Polling interval in seconds
|
||||||
|
"""
|
||||||
|
self._client = client
|
||||||
|
self._polling_interval = polling_interval
|
||||||
|
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
|
||||||
|
self._last_update_id = 0
|
||||||
|
self._polling_task: asyncio.Task[None] | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def register_command(
|
||||||
|
self, command: str, handler: Callable[[], Awaitable[None]]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a command handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command name (without leading slash, e.g., "start")
|
||||||
|
handler: Async function to handle the command
|
||||||
|
"""
|
||||||
|
self._commands[command] = handler
|
||||||
|
logger.debug("Registered command handler: /%s", command)
|
||||||
|
|
||||||
|
async def start_polling(self) -> None:
|
||||||
|
"""Start long polling for commands."""
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Command handler already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._client._enabled:
|
||||||
|
logger.info("Command handler disabled (TelegramClient disabled)")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._polling_task = asyncio.create_task(self._poll_loop())
|
||||||
|
logger.info("Started Telegram command polling")
|
||||||
|
|
||||||
|
async def stop_polling(self) -> None:
|
||||||
|
"""Stop polling and cancel pending tasks."""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
if self._polling_task:
|
||||||
|
self._polling_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._polling_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Stopped Telegram command polling")
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""Main polling loop that fetches updates."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
updates = await self._get_updates()
|
||||||
|
for update in updates:
|
||||||
|
await self._handle_update(update)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in polling loop: %s", exc)
|
||||||
|
|
||||||
|
await asyncio.sleep(self._polling_interval)
|
||||||
|
|
||||||
|
async def _get_updates(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch updates from Telegram API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of update objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"{self._client.API_BASE.format(token=self._client._bot_token)}/getUpdates"
|
||||||
|
payload = {
|
||||||
|
"offset": self._last_update_id + 1,
|
||||||
|
"timeout": int(self._polling_interval),
|
||||||
|
"allowed_updates": ["message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
session = self._client._get_session()
|
||||||
|
async with session.post(url, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
logger.error(
|
||||||
|
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
logger.error("getUpdates returned ok=false: %s", data)
|
||||||
|
return []
|
||||||
|
|
||||||
|
updates = data.get("result", [])
|
||||||
|
if updates:
|
||||||
|
self._last_update_id = updates[-1]["update_id"]
|
||||||
|
|
||||||
|
return updates
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("getUpdates timeout (normal)")
|
||||||
|
return []
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
logger.error("getUpdates failed: %s", exc)
|
||||||
|
return []
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error in _get_updates: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _handle_update(self, update: dict) -> None:
|
||||||
|
"""
|
||||||
|
Parse and handle a single update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
update: Update object from Telegram API
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message = update.get("message")
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify chat_id matches configured chat
|
||||||
|
chat_id = str(message.get("chat", {}).get("id", ""))
|
||||||
|
if chat_id != self._client._chat_id:
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring command from unauthorized chat_id: %s", chat_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract command text
|
||||||
|
text = message.get("text", "").strip()
|
||||||
|
if not text.startswith("/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse command (remove leading slash and extract command name)
|
||||||
|
command_parts = text[1:].split()
|
||||||
|
if not command_parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
command_name = command_parts[0]
|
||||||
|
|
||||||
|
# Execute handler
|
||||||
|
handler = self._commands.get(command_name)
|
||||||
|
if handler:
|
||||||
|
logger.info("Executing command: /%s", command_name)
|
||||||
|
await handler()
|
||||||
|
else:
|
||||||
|
logger.debug("Unknown command: /%s", command_name)
|
||||||
|
await self._client.send_message(
|
||||||
|
f"Unknown command: /{command_name}\nUse /help to see available commands."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error handling update: %s", exc)
|
||||||
|
# Don't crash the polling loop on handler errors
|
||||||
|
|||||||
798
tests/test_telegram_commands.py
Normal file
798
tests/test_telegram_commands.py
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
"""Tests for Telegram command handler."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandHandlerInit:
|
||||||
|
"""Test command handler initialization."""
|
||||||
|
|
||||||
|
def test_init_with_client(self) -> None:
|
||||||
|
"""Handler initializes with TelegramClient."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
assert handler._client is client
|
||||||
|
assert handler._polling_interval == 1.0
|
||||||
|
assert handler._commands == {}
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
def test_custom_polling_interval(self) -> None:
|
||||||
|
"""Handler accepts custom polling interval."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client, polling_interval=2.5)
|
||||||
|
|
||||||
|
assert handler._polling_interval == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandRegistration:
|
||||||
|
"""Test command registration."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_command(self) -> None:
|
||||||
|
"""Commands can be registered."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def test_handler() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
handler.register_command("test", test_handler)
|
||||||
|
|
||||||
|
assert "test" in handler._commands
|
||||||
|
assert handler._commands["test"] is test_handler
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_multiple_commands(self) -> None:
|
||||||
|
"""Multiple commands can be registered."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def handler1() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handler2() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
handler.register_command("start", handler1)
|
||||||
|
handler.register_command("help", handler2)
|
||||||
|
|
||||||
|
assert len(handler._commands) == 2
|
||||||
|
assert handler._commands["start"] is handler1
|
||||||
|
assert handler._commands["help"] is handler2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollingLifecycle:
|
||||||
|
"""Test polling start/stop."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_polling(self) -> None:
|
||||||
|
"""Polling can be started."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
|
||||||
|
await handler.start_polling()
|
||||||
|
|
||||||
|
assert handler._running is True
|
||||||
|
assert handler._polling_task is not None
|
||||||
|
|
||||||
|
await handler.stop_polling()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_polling_disabled_client(self) -> None:
|
||||||
|
"""Polling not started when client disabled."""
|
||||||
|
client = TelegramClient(enabled=False)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
await handler.start_polling()
|
||||||
|
|
||||||
|
assert handler._running is False
|
||||||
|
assert handler._polling_task is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_polling(self) -> None:
|
||||||
|
"""Polling can be stopped."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
|
||||||
|
await handler.start_polling()
|
||||||
|
await handler.stop_polling()
|
||||||
|
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_double_start_ignored(self) -> None:
|
||||||
|
"""Starting already running handler is ignored."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
|
||||||
|
await handler.start_polling()
|
||||||
|
task1 = handler._polling_task
|
||||||
|
|
||||||
|
await handler.start_polling() # Second start
|
||||||
|
task2 = handler._polling_task
|
||||||
|
|
||||||
|
# Should be the same task
|
||||||
|
assert task1 is task2
|
||||||
|
|
||||||
|
await handler.stop_polling()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateHandling:
|
||||||
|
"""Test update parsing and handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_valid_command(self) -> None:
|
||||||
|
"""Valid commands are executed."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("test", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_unknown_command(self) -> None:
|
||||||
|
"""Unknown commands send help message."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/unknown",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Should send error message
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Unknown command" in payload["text"]
|
||||||
|
assert "/unknown" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignore_unauthorized_chat(self) -> None:
|
||||||
|
"""Commands from unauthorized chats are ignored."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("test", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 999}, # Wrong chat_id
|
||||||
|
"text": "/test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignore_non_command_text(self) -> None:
|
||||||
|
"""Non-command text is ignored."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("test", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "Hello, not a command",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_update_error_isolation(self) -> None:
|
||||||
|
"""Errors in handlers don't crash the system."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def failing_command() -> None:
|
||||||
|
raise ValueError("Test error")
|
||||||
|
|
||||||
|
handler.register_command("fail", failing_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/fail",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should not raise exception
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTradingControlCommands:
|
||||||
|
"""Test trading control commands."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_command_pauses_trading(self) -> None:
|
||||||
|
"""Stop command clears pause event."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.set() # Initially active
|
||||||
|
|
||||||
|
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_stop() -> None:
|
||||||
|
"""Mock /stop handler."""
|
||||||
|
if not pause_event.is_set():
|
||||||
|
await client.send_message("⏸️ Trading is already paused")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.clear()
|
||||||
|
await client.send_message(
|
||||||
|
"<b>⏸️ Trading Paused</b>\n\n"
|
||||||
|
"All trading operations have been suspended.\n"
|
||||||
|
"Use /resume to restart trading."
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.register_command("stop", mock_stop)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/stop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify pause event was cleared
|
||||||
|
assert not pause_event.is_set()
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Trading Paused" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_command_resumes_trading(self) -> None:
|
||||||
|
"""Resume command sets pause event."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event (initially paused)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.clear() # Initially paused
|
||||||
|
|
||||||
|
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_resume() -> None:
|
||||||
|
"""Mock /resume handler."""
|
||||||
|
if pause_event.is_set():
|
||||||
|
await client.send_message("▶️ Trading is already active")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.set()
|
||||||
|
await client.send_message(
|
||||||
|
"<b>▶️ Trading Resumed</b>\n\n"
|
||||||
|
"Trading operations have been restarted."
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.register_command("resume", mock_resume)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/resume",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify pause event was set
|
||||||
|
assert pause_event.is_set()
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Trading Resumed" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_when_already_paused(self) -> None:
|
||||||
|
"""Stop command when already paused sends appropriate message."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event (already paused)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.clear()
|
||||||
|
|
||||||
|
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_stop() -> None:
|
||||||
|
"""Mock /stop handler."""
|
||||||
|
if not pause_event.is_set():
|
||||||
|
await client.send_message("⏸️ Trading is already paused")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.clear()
|
||||||
|
|
||||||
|
handler.register_command("stop", mock_stop)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/stop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "already paused" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_when_already_active(self) -> None:
|
||||||
|
"""Resume command when already active sends appropriate message."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event (already active)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.set()
|
||||||
|
|
||||||
|
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_resume() -> None:
|
||||||
|
"""Mock /resume handler."""
|
||||||
|
if pause_event.is_set():
|
||||||
|
await client.send_message("▶️ Trading is already active")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.set()
|
||||||
|
|
||||||
|
handler.register_command("resume", mock_resume)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/resume",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
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 = (
|
||||||
|
"<b>📊 Trading Status</b>\n\n"
|
||||||
|
"<b>Mode:</b> PAPER\n"
|
||||||
|
"<b>Markets:</b> Korea, United States\n"
|
||||||
|
"<b>Trading:</b> Active\n\n"
|
||||||
|
"<b>Current P&L:</b> +2.50%\n"
|
||||||
|
"<b>Circuit Breaker:</b> -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(
|
||||||
|
"<b>⚠️ Error</b>\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 = (
|
||||||
|
"<b>💼 Current Holdings</b>\n"
|
||||||
|
"\n🇰🇷 <b>Korea</b>\n"
|
||||||
|
"• 005930: 10 shares @ 70,000\n"
|
||||||
|
"\n🇺🇸 <b>Overseas</b>\n"
|
||||||
|
"• AAPL: 15 shares @ 175\n"
|
||||||
|
"\n<b>Cash:</b> ₩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 = (
|
||||||
|
"<b>💼 Current Holdings</b>\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(
|
||||||
|
"<b>⚠️ Error</b>\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."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_command_content(self) -> None:
|
||||||
|
"""Start command contains welcome message and command list."""
|
||||||
|
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_start() -> None:
|
||||||
|
"""Mock /start handler."""
|
||||||
|
message = (
|
||||||
|
"<b>🤖 The Ouroboros Trading Bot</b>\n\n"
|
||||||
|
"AI-powered global stock trading agent with real-time notifications.\n\n"
|
||||||
|
"<b>Available commands:</b>\n"
|
||||||
|
"/help - Show this help message\n"
|
||||||
|
"/status - Current trading status\n"
|
||||||
|
"/positions - View holdings\n"
|
||||||
|
"/stop - Pause trading\n"
|
||||||
|
"/resume - Resume trading"
|
||||||
|
)
|
||||||
|
await client.send_message(message)
|
||||||
|
|
||||||
|
handler.register_command("start", mock_start)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Ouroboros Trading Bot" in payload["text"]
|
||||||
|
assert "/help" in payload["text"]
|
||||||
|
assert "/status" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command_content(self) -> None:
|
||||||
|
"""Help command lists all available commands."""
|
||||||
|
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_help() -> None:
|
||||||
|
"""Mock /help handler."""
|
||||||
|
message = (
|
||||||
|
"<b>📖 Available Commands</b>\n\n"
|
||||||
|
"/start - Welcome message\n"
|
||||||
|
"/help - Show available commands\n"
|
||||||
|
"/status - Trading status (mode, markets, P&L)\n"
|
||||||
|
"/positions - Current holdings\n"
|
||||||
|
"/stop - Pause trading\n"
|
||||||
|
"/resume - Resume trading"
|
||||||
|
)
|
||||||
|
await client.send_message(message)
|
||||||
|
|
||||||
|
handler.register_command("help", mock_help)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/help",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Available Commands" in payload["text"]
|
||||||
|
assert "/start" in payload["text"]
|
||||||
|
assert "/help" in payload["text"]
|
||||||
|
assert "/status" in payload["text"]
|
||||||
|
assert "/positions" in payload["text"]
|
||||||
|
assert "/stop" in payload["text"]
|
||||||
|
assert "/resume" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUpdates:
|
||||||
|
"""Test getUpdates API interaction."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_success(self) -> None:
|
||||||
|
"""getUpdates fetches and parses updates."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"result": [
|
||||||
|
{"update_id": 1, "message": {"text": "/test"}},
|
||||||
|
{"update_id": 2, "message": {"text": "/help"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
|
assert len(updates) == 2
|
||||||
|
assert updates[0]["update_id"] == 1
|
||||||
|
assert updates[1]["update_id"] == 2
|
||||||
|
assert handler._last_update_id == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_api_error(self) -> None:
|
||||||
|
"""getUpdates handles API errors gracefully."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 400
|
||||||
|
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
|
assert updates == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_empty_result(self) -> None:
|
||||||
|
"""getUpdates handles empty results."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"ok": True, "result": []})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
|
assert updates == []
|
||||||
Reference in New Issue
Block a user