diff --git a/.env.example b/.env.example index 6fdd465..071c866 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,13 @@ SPOT_WALLET_ADDRESS_1= SPOT_ACCOUNT_ID_2= SPOT_PRIVATE_KEY_2= SPOT_WALLET_ADDRESS_2= + +# ----------------------------------------------------------------------- +# Telegram Trading Bot +# ----------------------------------------------------------------------- +# Bot token from @BotFather on Telegram +TELEGRAM_BOT_TOKEN= + +# (Optional) Comma-separated Telegram user IDs allowed to use the bot. +# Leave blank to allow all users (not recommended for production). +ALLOWED_USER_IDS= diff --git a/pyproject.toml b/pyproject.toml index 1e27511..b4a6b2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,8 @@ dependencies = [ "tomli>=2.0.1,<3.0.0", "types-requests>=2.31.0,<2.32.0", "python-dateutil>=2.8.0,<3.0.0", - "aiohttp-retry>=2.8.3,<3.0.0" + "aiohttp-retry>=2.8.3,<3.0.0", + "python-telegram-bot>=21.0,<22.0" ] [project.optional-dependencies] @@ -60,7 +61,8 @@ dev = [ [tool.poetry] packages = [ {include = "examples"}, - {include = "sdk"} + {include = "sdk"}, + {include = "telegram_bot"} ] [build-system] diff --git a/telegram_bot/__init__.py b/telegram_bot/__init__.py new file mode 100644 index 0000000..0304c81 --- /dev/null +++ b/telegram_bot/__init__.py @@ -0,0 +1 @@ +"""Telegram trading bot for Reya exchange.""" diff --git a/telegram_bot/bot.py b/telegram_bot/bot.py new file mode 100644 index 0000000..2278405 --- /dev/null +++ b/telegram_bot/bot.py @@ -0,0 +1,434 @@ +""" +Telegram bot command handlers for Reya trading. + +Each handler corresponds to a bot command and delegates to TradingService +for SDK operations and to formatters for message construction. +""" + +import logging + +from telegram import Update +from telegram.constants import ParseMode +from telegram.ext import Application, CommandHandler, ContextTypes + +from telegram_bot import formatters +from telegram_bot.trading import TradingService + +logger = logging.getLogger("telegram_bot.bot") + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +HELP_TEXT = """ +*Reya Trading Bot — Commands* + +*Market Data* +`/prices` — All market prices +`/price ` — Price for one symbol +`/markets` — 24h market summaries +`/symbols` — List all available symbols + +*Account* +`/accounts` — Your account IDs +`/balance` — Account balances +`/positions` — Open positions +`/orders` — Open orders +`/history` — Recent trade history + +*Trading — Perp / Spot IOC (fill-or-kill)* +`/buy ` — Limit buy (IOC) +`/sell ` — Limit sell (IOC) + +*Trading — Perp GTC (resting limit order)* +`/buygtc ` — Limit buy (GTC) +`/sellgtc ` — Limit sell (GTC) + +*Trigger Orders — Perp only* +`/sl ` — Stop loss +`/tp ` — Take profit + +*Order Management* +`/cancel ` — Cancel an order + +*Examples* +`/price ETHRUSDPERP` +`/buy ETHRUSDPERP 0.01 2000` +`/sellgtc BTCRUSDPERP 0.001 70000` +`/sl ETHRUSDPERP sell 1500` +`/tp ETHRUSDPERP sell 5000` +`/cancel 12345` +""" + + +async def _reply(update: Update, text: str) -> None: + """Send a Markdown reply, falling back to plain text on parse errors.""" + try: + await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN) + except Exception: + await update.message.reply_text(text) + + +def _get_trading(context: ContextTypes.DEFAULT_TYPE) -> TradingService: + return context.bot_data["trading"] + + +# --------------------------------------------------------------------------- +# General commands +# --------------------------------------------------------------------------- + + +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await _reply( + update, + "*Welcome to the Reya Trading Bot!*\n\n" + "Trade perpetuals and spot on the Reya exchange directly from Telegram.\n\n" + + HELP_TEXT, + ) + + +async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await _reply(update, HELP_TEXT) + + +# --------------------------------------------------------------------------- +# Market data commands +# --------------------------------------------------------------------------- + + +async def cmd_prices(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + prices = await trading.get_prices() + await _reply(update, formatters.fmt_prices(prices)) + except Exception as exc: + logger.exception("Error fetching prices") + await _reply(update, f"Failed to fetch prices: {exc}") + + +async def cmd_price(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not context.args: + await _reply(update, "Usage: `/price `\nExample: `/price ETHRUSDPERP`") + return + + symbol = context.args[0].upper() + trading = _get_trading(context) + try: + price = await trading.get_price(symbol) + await _reply(update, formatters.fmt_single_price(symbol, price)) + except Exception as exc: + logger.exception("Error fetching price for %s", symbol) + await _reply(update, f"Failed to fetch price for `{symbol}`: {exc}") + + +async def cmd_markets(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + summaries = await trading.get_market_summaries() + await _reply(update, formatters.fmt_market_summaries(summaries)) + except Exception as exc: + logger.exception("Error fetching market summaries") + await _reply(update, f"Failed to fetch market summaries: {exc}") + + +async def cmd_symbols(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + perp_defs = await trading.get_market_definitions() + spot_defs = await trading.get_spot_market_definitions() + lines = ["*Available Symbols*\n", "*Perpetuals:*"] + for m in perp_defs: + lines.append(f" `{m.symbol}`") + lines.append("\n*Spot:*") + for m in spot_defs: + lines.append(f" `{m.symbol}`") + await _reply(update, "\n".join(lines)) + except Exception as exc: + logger.exception("Error fetching symbols") + await _reply(update, f"Failed to fetch symbols: {exc}") + + +# --------------------------------------------------------------------------- +# Account / wallet commands +# --------------------------------------------------------------------------- + + +async def cmd_accounts(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + accounts = await trading.get_accounts() + await _reply(update, formatters.fmt_accounts(accounts)) + except Exception as exc: + logger.exception("Error fetching accounts") + await _reply(update, f"Failed to fetch accounts: {exc}") + + +async def cmd_balance(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + balances = await trading.get_account_balances() + await _reply(update, formatters.fmt_account_balances(balances)) + except Exception as exc: + logger.exception("Error fetching balances") + await _reply(update, f"Failed to fetch balances: {exc}") + + +async def cmd_positions(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + positions = await trading.get_positions() + await _reply(update, formatters.fmt_positions(positions)) + except Exception as exc: + logger.exception("Error fetching positions") + await _reply(update, f"Failed to fetch positions: {exc}") + + +async def cmd_orders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + orders = await trading.get_open_orders() + await _reply(update, formatters.fmt_open_orders(orders)) + except Exception as exc: + logger.exception("Error fetching orders") + await _reply(update, f"Failed to fetch open orders: {exc}") + + +async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + trading = _get_trading(context) + try: + executions = await trading.get_perp_executions() + await _reply(update, formatters.fmt_executions(executions)) + except Exception as exc: + logger.exception("Error fetching trade history") + await _reply(update, f"Failed to fetch trade history: {exc}") + + +# --------------------------------------------------------------------------- +# Trading commands — IOC +# --------------------------------------------------------------------------- + + +def _parse_order_args(args: list[str]) -> tuple[str, str, str]: + """Parse and validate args. Returns (symbol, qty, price).""" + if len(args) < 3: + raise ValueError("Usage: ` `") + symbol = args[0].upper() + qty = args[1] + price = args[2] + float(qty) # validate numeric + float(price) # validate numeric + return symbol, qty, price + + +async def cmd_buy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + symbol, qty, price = _parse_order_args(context.args or []) + except ValueError as exc: + await _reply(update, f"Invalid arguments. {exc}\nExample: `/buy ETHRUSDPERP 0.01 2000`") + return + + trading = _get_trading(context) + await _reply(update, f"Submitting IOC limit buy: {qty} {symbol} @ {price}...") + try: + response = await trading.buy_ioc(symbol, qty, price) + await _reply(update, formatters.fmt_order_created("IOC Buy", response)) + except Exception as exc: + logger.exception("Error submitting buy order") + await _reply(update, f"Buy order failed: {exc}") + + +async def cmd_sell(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + symbol, qty, price = _parse_order_args(context.args or []) + except ValueError as exc: + await _reply(update, f"Invalid arguments. {exc}\nExample: `/sell ETHRUSDPERP 0.01 4000`") + return + + trading = _get_trading(context) + await _reply(update, f"Submitting IOC limit sell: {qty} {symbol} @ {price}...") + try: + response = await trading.sell_ioc(symbol, qty, price) + await _reply(update, formatters.fmt_order_created("IOC Sell", response)) + except Exception as exc: + logger.exception("Error submitting sell order") + await _reply(update, f"Sell order failed: {exc}") + + +# --------------------------------------------------------------------------- +# Trading commands — GTC +# --------------------------------------------------------------------------- + + +async def cmd_buygtc(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + symbol, qty, price = _parse_order_args(context.args or []) + except ValueError as exc: + await _reply(update, f"Invalid arguments. {exc}\nExample: `/buygtc ETHRUSDPERP 0.01 1800`") + return + + trading = _get_trading(context) + await _reply(update, f"Submitting GTC limit buy: {qty} {symbol} @ {price}...") + try: + response = await trading.buy_gtc(symbol, qty, price) + await _reply(update, formatters.fmt_order_created("GTC Buy", response)) + except Exception as exc: + logger.exception("Error submitting GTC buy order") + await _reply(update, f"GTC buy order failed: {exc}") + + +async def cmd_sellgtc(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + symbol, qty, price = _parse_order_args(context.args or []) + except ValueError as exc: + await _reply(update, f"Invalid arguments. {exc}\nExample: `/sellgtc ETHRUSDPERP 0.01 4200`") + return + + trading = _get_trading(context) + await _reply(update, f"Submitting GTC limit sell: {qty} {symbol} @ {price}...") + try: + response = await trading.sell_gtc(symbol, qty, price) + await _reply(update, formatters.fmt_order_created("GTC Sell", response)) + except Exception as exc: + logger.exception("Error submitting GTC sell order") + await _reply(update, f"GTC sell order failed: {exc}") + + +# --------------------------------------------------------------------------- +# Trigger order commands +# --------------------------------------------------------------------------- + + +def _parse_trigger_args(args: list[str]) -> tuple[str, bool, str]: + """Parse . Returns (symbol, is_buy, trigger_px).""" + if len(args) < 3: + raise ValueError("Usage: ` `") + symbol = args[0].upper() + side = args[1].lower() + if side not in ("buy", "sell"): + raise ValueError("Side must be `buy` or `sell`.") + is_buy = side == "buy" + trigger_px = args[2] + float(trigger_px) # validate numeric + return symbol, is_buy, trigger_px + + +async def cmd_sl(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + symbol, is_buy, trigger_px = _parse_trigger_args(context.args or []) + except ValueError as exc: + await _reply( + update, + f"Invalid arguments. {exc}\n" + "Example: `/sl ETHRUSDPERP sell 1500` (SL for long position)\n" + "Example: `/sl ETHRUSDPERP buy 9000` (SL for short position)", + ) + return + + trading = _get_trading(context) + side_str = "buy" if is_buy else "sell" + await _reply(update, f"Submitting stop loss: {side_str} {symbol} trigger @ {trigger_px}...") + try: + response = await trading.stop_loss(symbol, is_buy, trigger_px) + await _reply(update, formatters.fmt_order_created("Stop Loss", response)) + except Exception as exc: + logger.exception("Error submitting stop loss") + await _reply(update, f"Stop loss failed: {exc}") + + +async def cmd_tp(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + symbol, is_buy, trigger_px = _parse_trigger_args(context.args or []) + except ValueError as exc: + await _reply( + update, + f"Invalid arguments. {exc}\n" + "Example: `/tp ETHRUSDPERP sell 5000` (TP for long position)\n" + "Example: `/tp ETHRUSDPERP buy 1500` (TP for short position)", + ) + return + + trading = _get_trading(context) + side_str = "buy" if is_buy else "sell" + await _reply(update, f"Submitting take profit: {side_str} {symbol} trigger @ {trigger_px}...") + try: + response = await trading.take_profit(symbol, is_buy, trigger_px) + await _reply(update, formatters.fmt_order_created("Take Profit", response)) + except Exception as exc: + logger.exception("Error submitting take profit") + await _reply(update, f"Take profit failed: {exc}") + + +# --------------------------------------------------------------------------- +# Order management +# --------------------------------------------------------------------------- + + +async def cmd_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not context.args: + await _reply(update, "Usage: `/cancel `\nExample: `/cancel 12345`") + return + + order_id = context.args[0] + trading = _get_trading(context) + await _reply(update, f"Cancelling order `{order_id}`...") + try: + response = await trading.cancel_order(order_id) + await _reply(update, formatters.fmt_order_cancelled(response)) + except Exception as exc: + logger.exception("Error cancelling order %s", order_id) + await _reply(update, f"Cancel failed: {exc}") + + +# --------------------------------------------------------------------------- +# Application factory +# --------------------------------------------------------------------------- + + +def build_application(token: str, trading: TradingService) -> Application: + """ + Construct and return the Telegram Application with all handlers registered. + + Args: + token: Telegram bot token from BotFather. + trading: Initialised TradingService instance. + + Returns: + A configured Application ready to run. + """ + app = Application.builder().token(token).build() + + # Store trading service in bot_data so handlers can access it + app.bot_data["trading"] = trading + + # Register all command handlers + handlers = [ + ("start", cmd_start), + ("help", cmd_help), + # Market data + ("prices", cmd_prices), + ("price", cmd_price), + ("markets", cmd_markets), + ("symbols", cmd_symbols), + # Account + ("accounts", cmd_accounts), + ("balance", cmd_balance), + ("positions", cmd_positions), + ("orders", cmd_orders), + ("history", cmd_history), + # Trading — IOC + ("buy", cmd_buy), + ("sell", cmd_sell), + # Trading — GTC + ("buygtc", cmd_buygtc), + ("sellgtc", cmd_sellgtc), + # Trigger orders + ("sl", cmd_sl), + ("tp", cmd_tp), + # Order management + ("cancel", cmd_cancel), + ] + + for command, handler in handlers: + app.add_handler(CommandHandler(command, handler)) + + return app diff --git a/telegram_bot/formatters.py b/telegram_bot/formatters.py new file mode 100644 index 0000000..5a84abf --- /dev/null +++ b/telegram_bot/formatters.py @@ -0,0 +1,154 @@ +""" +Message formatting utilities for the Telegram trading bot. + +All functions return Markdown-formatted strings suitable for sending via Telegram. +""" + +from typing import Any + + +def _oracle_price_usd(raw: Any) -> str: + """Convert a raw oracle price (18-decimal integer string) to a USD string.""" + try: + return f"${float(raw) / 1e18:,.4f}" + except (TypeError, ValueError): + return str(raw) + + +def fmt_prices(prices: list) -> str: + if not prices: + return "No price data available." + + lines = ["*Market Prices*\n"] + for p in prices: + oracle = _oracle_price_usd(p.oracle_price) if p.oracle_price else "N/A" + lines.append(f"`{p.symbol}`: {oracle}") + return "\n".join(lines) + + +def fmt_single_price(symbol: str, price) -> str: + if price is None: + return f"No price data found for `{symbol}`." + + oracle = _oracle_price_usd(price.oracle_price) if price.oracle_price else "N/A" + return f"*{symbol}*\nOracle price: {oracle}" + + +def fmt_accounts(accounts: list) -> str: + if not accounts: + return "No accounts found for this wallet." + + lines = ["*Accounts*\n"] + for i, acc in enumerate(accounts, 1): + lines.append(f"*#{i}* — Account ID: `{acc.id}`") + if hasattr(acc, "type") and acc.type: + lines.append(f" Type: {acc.type}") + return "\n".join(lines) + + +def fmt_account_balances(balances: list) -> str: + if not balances: + return "No account balances found." + + lines = ["*Account Balances*\n"] + for b in balances: + account_id = getattr(b, "account_id", "N/A") + token = getattr(b, "token_symbol", "N/A") + balance_raw = getattr(b, "balance", None) + balance_str = f"{float(balance_raw) / 1e18:,.6f}" if balance_raw else "N/A" + lines.append(f"Account `{account_id}` — {token}: `{balance_str}`") + return "\n".join(lines) + + +def fmt_positions(positions: list) -> str: + if not positions: + return "No open positions." + + lines = ["*Open Positions*\n"] + for pos in positions: + symbol = getattr(pos, "symbol", "N/A") + side = "Long" if getattr(pos, "is_long", True) else "Short" + size_raw = getattr(pos, "size", None) + size_str = f"{float(size_raw) / 1e18:,.6f}" if size_raw else "N/A" + entry_raw = getattr(pos, "avg_entry_price", None) + entry_str = _oracle_price_usd(entry_raw) if entry_raw else "N/A" + pnl_raw = getattr(pos, "unrealized_pnl", None) + pnl_str = f"{float(pnl_raw) / 1e18:,.4f}" if pnl_raw else "N/A" + lines.append( + f"`{symbol}` — {side} {size_str}\n" + f" Entry: {entry_str} | uPnL: {pnl_str} rUSD" + ) + return "\n".join(lines) + + +def fmt_open_orders(orders: list) -> str: + if not orders: + return "No open orders." + + lines = ["*Open Orders*\n"] + for o in orders: + order_id = getattr(o, "order_id", "N/A") + symbol = getattr(o, "symbol", "N/A") + side = "Buy" if getattr(o, "is_buy", True) else "Sell" + qty_raw = getattr(o, "qty", None) + qty_str = f"{float(qty_raw) / 1e18:,.6f}" if qty_raw else "N/A" + px_raw = getattr(o, "limit_px", None) + px_str = _oracle_price_usd(px_raw) if px_raw else "N/A" + order_type = getattr(o, "order_type", "") + tif = getattr(o, "time_in_force", "") + status = getattr(o, "status", "") + lines.append( + f"ID `{order_id}` — {side} {qty_str} `{symbol}` @ {px_str}\n" + f" Type: {order_type} {tif} | Status: {status}" + ) + return "\n".join(lines) + + +def fmt_order_created(order_type: str, response) -> str: + order_id = getattr(response, "order_id", "N/A") + status = getattr(response, "status", "N/A") + return ( + f"*{order_type} order submitted*\n" + f"Order ID: `{order_id}`\n" + f"Status: `{status}`" + ) + + +def fmt_order_cancelled(response) -> str: + order_id = getattr(response, "order_id", "N/A") + status = getattr(response, "status", "N/A") + return f"*Order cancelled*\nOrder ID: `{order_id}`\nStatus: `{status}`" + + +def fmt_executions(executions) -> str: + data = getattr(executions, "data", []) + if not data: + return "No trade history found." + + lines = ["*Recent Trades*\n"] + for ex in data[:10]: # Show last 10 + symbol = getattr(ex, "symbol", "N/A") + side = "Buy" if getattr(ex, "is_buy", True) else "Sell" + qty_raw = getattr(ex, "qty", None) + qty_str = f"{float(qty_raw) / 1e18:,.6f}" if qty_raw else "N/A" + px_raw = getattr(ex, "fill_px", None) + px_str = _oracle_price_usd(px_raw) if px_raw else "N/A" + lines.append(f"{side} {qty_str} `{symbol}` @ {px_str}") + if len(data) > 10: + lines.append(f"_...and {len(data) - 10} more_") + return "\n".join(lines) + + +def fmt_market_summaries(summaries: list) -> str: + if not summaries: + return "No market summary data available." + + lines = ["*Market Summaries*\n"] + for s in summaries: + symbol = getattr(s, "symbol", "N/A") + last_raw = getattr(s, "last_price", None) + last_str = _oracle_price_usd(last_raw) if last_raw else "N/A" + volume_raw = getattr(s, "volume_24h", None) + volume_str = f"{float(volume_raw) / 1e18:,.2f}" if volume_raw else "N/A" + lines.append(f"`{symbol}` — Last: {last_str} Vol 24h: {volume_str}") + return "\n".join(lines) diff --git a/telegram_bot/main.py b/telegram_bot/main.py new file mode 100644 index 0000000..5356db7 --- /dev/null +++ b/telegram_bot/main.py @@ -0,0 +1,150 @@ +""" +Entry point for the Reya Telegram trading bot. + +Usage: + python -m telegram_bot.main + +Required environment variables: + TELEGRAM_BOT_TOKEN — Bot token from @BotFather + PERP_WALLET_ADDRESS_1 — Owner wallet address + PERP_PRIVATE_KEY_1 — Private key for signing orders + PERP_ACCOUNT_ID_1 — Reya account ID + +Optional: + CHAIN_ID — 1729 (mainnet, default) or 89346162 (testnet) + REYA_API_URL — Override the Reya REST API base URL + ALLOWED_USER_IDS — Comma-separated Telegram user IDs allowed to trade + (if unset, all users can interact with the bot) +""" + +import asyncio +import logging +import os +import sys + +from dotenv import load_dotenv + +from sdk.reya_rest_api.config import TradingConfig +from telegram_bot.bot import build_application +from telegram_bot.trading import TradingService + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger("telegram_bot.main") + + +def _load_env() -> dict: + """Load and validate required environment variables.""" + load_dotenv() + + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not token: + logger.error("TELEGRAM_BOT_TOKEN is required. Set it in .env or as an environment variable.") + sys.exit(1) + + allowed_ids_raw = os.environ.get("ALLOWED_USER_IDS", "") + allowed_user_ids: set[int] = set() + if allowed_ids_raw.strip(): + for uid in allowed_ids_raw.split(","): + uid = uid.strip() + if uid: + try: + allowed_user_ids.add(int(uid)) + except ValueError: + logger.warning("Ignoring invalid ALLOWED_USER_IDS entry: %r", uid) + + return {"token": token, "allowed_user_ids": allowed_user_ids} + + +async def _main() -> None: + env = _load_env() + token: str = env["token"] + allowed_user_ids: set[int] = env["allowed_user_ids"] + + # Build Reya trading config from environment + try: + config = TradingConfig.from_env() + except ValueError as exc: + logger.error("Failed to load trading configuration: %s", exc) + sys.exit(1) + + logger.info("Connecting to Reya API: %s", config.api_url) + logger.info("Wallet: %s", config.owner_wallet_address) + logger.info("Chain ID: %d (%s)", config.chain_id, "mainnet" if config.is_mainnet else "testnet") + + if allowed_user_ids: + logger.info("Access restricted to user IDs: %s", allowed_user_ids) + else: + logger.warning("ALLOWED_USER_IDS not set — all Telegram users can interact with the bot") + + # Initialise trading service + trading = TradingService(config=config) + try: + await trading.start() + except Exception as exc: + logger.error("Failed to start trading service: %s", exc) + sys.exit(1) + + # Build the Telegram application + app = build_application(token=token, trading=trading) + + # Inject access control middleware if user IDs are restricted + if allowed_user_ids: + _apply_access_control(app, allowed_user_ids) + + logger.info("Bot is running. Press Ctrl+C to stop.") + + try: + # Run polling (blocking until KeyboardInterrupt or stop signal) + await app.initialize() + await app.start() + await app.updater.start_polling(drop_pending_updates=True) + + # Wait until the bot is told to stop + stop_event = asyncio.Event() + try: + await stop_event.wait() + except asyncio.CancelledError: + pass + finally: + logger.info("Shutting down…") + await app.updater.stop() + await app.stop() + await app.shutdown() + await trading.stop() + + +def _apply_access_control(app, allowed_user_ids: set[int]) -> None: + """ + Add a pre-handler that rejects updates from users not in the allow-list. + + Uses a TypeHandler with group=-1 so it runs before any command handler. + """ + from telegram import Update + from telegram.ext import TypeHandler + + async def _check_user(update: Update, context) -> None: + if not update.effective_user: + return + uid = update.effective_user.id + if uid not in allowed_user_ids: + logger.warning("Rejected update from unauthorised user %d", uid) + if update.message: + await update.message.reply_text("You are not authorised to use this bot.") + raise Exception("Unauthorised") + + app.add_handler(TypeHandler(Update, _check_user), group=-1) + + +def main() -> None: + try: + asyncio.run(_main()) + except KeyboardInterrupt: + logger.info("Stopped by user.") + + +if __name__ == "__main__": + main() diff --git a/telegram_bot/trading.py b/telegram_bot/trading.py new file mode 100644 index 0000000..2a037a0 --- /dev/null +++ b/telegram_bot/trading.py @@ -0,0 +1,195 @@ +""" +Trading operations wrapper around the Reya Python SDK. + +Provides a high-level async interface for all trading operations used by the bot. +""" + +import logging +from typing import Optional + +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters, TriggerOrderParameters + +logger = logging.getLogger("telegram_bot.trading") + + +class TradingService: + """ + Wrapper around ReyaTradingClient providing trading operations for the Telegram bot. + + This class manages the client lifecycle and exposes simple async methods + for each bot command that involves trading. + """ + + def __init__(self, config: TradingConfig): + self._config = config + self._client: Optional[ReyaTradingClient] = None + + async def start(self) -> None: + """Initialize the trading client and load market definitions.""" + self._client = ReyaTradingClient(config=self._config) + await self._client.start() + logger.info("Trading service started") + + async def stop(self) -> None: + """Close the trading client session.""" + if self._client: + await self._client.close() + self._client = None + logger.info("Trading service stopped") + + def _require_client(self) -> ReyaTradingClient: + if self._client is None: + raise RuntimeError("Trading service is not started. Call start() first.") + return self._client + + # ------------------------------------------------------------------------- + # Market data + # ------------------------------------------------------------------------- + + async def get_prices(self) -> list: + """Return all market prices.""" + client = self._require_client() + return await client.markets.get_prices() + + async def get_price(self, symbol: str) -> Optional[object]: + """Return the price for a specific symbol, or None if not found.""" + prices = await self.get_prices() + for price in prices: + if price.symbol == symbol: + return price + return None + + async def get_market_definitions(self) -> list: + """Return all perp market definitions.""" + client = self._require_client() + return await client.reference.get_market_definitions() + + async def get_spot_market_definitions(self) -> list: + """Return all spot market definitions.""" + client = self._require_client() + return await client.reference.get_spot_market_definitions() + + async def get_market_summaries(self) -> list: + """Return all market summaries.""" + client = self._require_client() + return await client.markets.get_markets() + + # ------------------------------------------------------------------------- + # Account / wallet data + # ------------------------------------------------------------------------- + + async def get_accounts(self) -> list: + """Return accounts for the configured wallet.""" + client = self._require_client() + return await client.get_accounts() + + async def get_account_balances(self) -> list: + """Return account balances for the configured wallet.""" + client = self._require_client() + return await client.get_account_balances() + + async def get_positions(self) -> list: + """Return open positions for the configured wallet.""" + client = self._require_client() + return await client.get_positions() + + async def get_open_orders(self) -> list: + """Return open orders for the configured wallet.""" + client = self._require_client() + return await client.get_open_orders() + + async def get_perp_executions(self) -> object: + """Return perp trade history for the configured wallet.""" + client = self._require_client() + return await client.get_perp_executions() + + # ------------------------------------------------------------------------- + # Order management + # ------------------------------------------------------------------------- + + async def buy_ioc(self, symbol: str, qty: str, limit_px: str) -> object: + """Submit an IOC limit buy order.""" + client = self._require_client() + return await client.create_limit_order( + LimitOrderParameters( + symbol=symbol, + is_buy=True, + limit_px=limit_px, + qty=qty, + time_in_force=TimeInForce.IOC, + reduce_only=False, + ) + ) + + async def sell_ioc(self, symbol: str, qty: str, limit_px: str) -> object: + """Submit an IOC limit sell order.""" + client = self._require_client() + return await client.create_limit_order( + LimitOrderParameters( + symbol=symbol, + is_buy=False, + limit_px=limit_px, + qty=qty, + time_in_force=TimeInForce.IOC, + reduce_only=False, + ) + ) + + async def buy_gtc(self, symbol: str, qty: str, limit_px: str) -> object: + """Submit a GTC limit buy order.""" + client = self._require_client() + return await client.create_limit_order( + LimitOrderParameters( + symbol=symbol, + is_buy=True, + limit_px=limit_px, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + ) + + async def sell_gtc(self, symbol: str, qty: str, limit_px: str) -> object: + """Submit a GTC limit sell order.""" + client = self._require_client() + return await client.create_limit_order( + LimitOrderParameters( + symbol=symbol, + is_buy=False, + limit_px=limit_px, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + ) + + async def stop_loss(self, symbol: str, is_buy: bool, trigger_px: str) -> object: + """Submit a stop-loss trigger order.""" + client = self._require_client() + return await client.create_trigger_order( + TriggerOrderParameters( + symbol=symbol, + is_buy=is_buy, + trigger_px=trigger_px, + trigger_type=OrderType.SL, + ) + ) + + async def take_profit(self, symbol: str, is_buy: bool, trigger_px: str) -> object: + """Submit a take-profit trigger order.""" + client = self._require_client() + return await client.create_trigger_order( + TriggerOrderParameters( + symbol=symbol, + is_buy=is_buy, + trigger_px=trigger_px, + trigger_type=OrderType.TP, + ) + ) + + async def cancel_order(self, order_id: str) -> object: + """Cancel an order by its ID.""" + client = self._require_client() + return await client.cancel_order(order_id=order_id)