""" Grid Bot — Async API wrapper for Tradernet (market data + trading). Uses format: POST https://tradernet.by/api/{cmd} with HMAC-SHA256. """ import logging import time import json import hmac import hashlib import asyncio import urllib.request import urllib.error from typing import Optional try: import aiohttp except ImportError: raise ImportError("pip install aiohttp") logger = logging.getLogger(__name__) DOMAIN = "api.tadernet.by" BASE_URL = f"https://{DOMAIN}" class TradernetAPI: def __init__(self, public_key: str, private_key: str, login: str, password: str, base_url: str = BASE_URL): self.public_key = public_key self.private_key = private_key self.login = login self.password = password self.base_url = base_url.rstrip("/") self._session: Optional[aiohttp.ClientSession] = None async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession() return self._session def _sign(self, payload: str) -> tuple[str, str]: ts = str(int(time.time())) msg = payload + ts sig = hmac.new( self.private_key.encode(), msg.encode(), hashlib.sha256 ).hexdigest() return ts, sig async def _post(self, cmd: str, params: Optional[dict] = None) -> dict: params = params or {} payload = json.dumps(params, separators=(',', ':')) ts, sig = self._sign(payload) headers = { "Content-Type": "application/json", "X-NtApi-PublicKey": self.public_key, "X-NtApi-Timestamp": ts, "X-NtApi-Sig": sig, } url = f"{self.base_url}/api/{cmd}" session = await self._get_session() try: async with session.post( url, data=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=30) ) as resp: data = await resp.json() logger.debug(f"REST {cmd} → {resp.status}: {str(data)[:100]}") if resp.status == 403: logger.warning(f"[REST 403] {cmd} → {data}") return {} return data or {} except asyncio.TimeoutError: logger.warning(f"REST {cmd} timed out") return {} except Exception as e: logger.warning(f"REST {cmd} failed: {e}") return {} async def close(self): if self._session and not self._session.closed: await self._session.close() # ─── Market data ────────────────────────────────────────────────────────── async def get_quotes(self, tickers: list[str]) -> dict: return await self._post("getStockQuotesJson", {"tickers": ",".join(tickers)}) async def get_quote(self, ticker: str) -> dict: return await self._post("getStockQuotesJson", {"tickers": ticker}) async def get_market_status(self) -> dict: return await self._post("getMarketStatus", {}) async def get_orderbook(self, ticker: str, depth: int = 20) -> dict: """Get order book (market depth) for a ticker.""" return await self._post("getOrderBook", {"ticker": ticker, "depth": depth}) # ─── Portfolio ──────────────────────────────────────────────────────────── async def account_get_summary(self) -> dict: """DEPRECATED: Tradernet returns 'Command not found' for this. Use get_position_json().""" return await self._post("accountGetSummary", {}) async def get_opq(self) -> dict: """Initial user data (account info, portfolio summary, main currency).""" return await self._post("getOPQ", {}) async def get_position_json(self) -> dict: """Current account balances + open positions. Response shape: { "result": { "ps": { "acc": [{"curr": "USD", "currval": 71.02, "s": 0, ...}, ...], "pos": [...], # open positions "key": "user@email" } } } """ return await self._post("getPositionJson", {}) def get_position_json_sync(self, timeout: float = 15.0) -> dict: """Synchronous version of get_position_json. Safe to call from any context (e.g. Flask handler).""" return self._post_sync("getPositionJson", {}, timeout) def get_opq_sync(self, timeout: float = 10.0) -> dict: """Synchronous getOPQ: account info, main currency, brief info. Response shape (top level): {"OPQ": {"rev": int, "init_margin": int, "brief_nm": str, "main_curr": "USDT", "active": int, "reception": int, "f_kval": "0", "quotes": {"q": [...]}}} """ return self._post_sync("getOPQ", {}, timeout) def get_security_info_sync(self, ticker: str, timeout: float = 10.0) -> dict: """Synchronous getSecurityInfo: lot size, min step, currency, market. Response: {"id": "BTC-USDT.IMEX", "short_name": "BTC-USDT", "default_ticker": "BTC-IMEX", "code_nm": "BTC-IMEX", "currency": "USDT", "min_step": "0.10000000", "lot": "0.00001000", "mkt_name": "IMEX", "firstDate": "01.05.1970", "mrkt": {"tz": "Europe/Minsk", "o": "05:00:00", "c": "02:00:00"}} """ return self._post_sync("getSecurityInfo", {"ticker": ticker, "sup": True}, timeout) def get_hloc_sync(self, ticker: str, timeframe_min: int = 60, date_from: str = "", date_to: str = "", count: int = 0, timeout: float = 15.0) -> dict: """Synchronous getHloc: historical OHLCV candlesticks. Args: ticker: e.g. "BTC-USDT.IMEX" timeframe_min: candle interval in minutes (1, 5, 15, 60, 1440) date_from: "DD.MM.YYYY hh:mm" or empty date_to: "DD.MM.YYYY hh:mm" or empty count: extra candles beyond the date range (0 = none) Response: {"hloc": {"BTC-USDT.IMEX": [[open, high, low, close], ...]}, "vl": {"BTC-USDT.IMEX": [vol, vol, ...]}, "xSeries": {"BTC-USDT.IMEX": [unix_ts, ...]}, "info": {"BTC-USDT.IMEX": {...security info...}}, "maxSeries": {"BTC-USDT.IMEX": ts}} """ params = { "id": ticker, "timeframe": timeframe_min, "date_from": date_from, "date_to": date_to, "count": count, "intervalMode": "ClosedRay", } return self._post_sync("getHloc", params, timeout) def _post_sync(self, command: str, params: dict, timeout: float = 10.0) -> dict: """Generic synchronous POST to Tradernet REST API. Returns parsed JSON or {} on error. Used for: getOPQ, getPositionJson, getSecurityInfo, getHloc. """ payload = json.dumps(params, separators=(',', ':')) ts, sig = self._sign(payload) headers = { "Content-Type": "application/json", "X-NtApi-PublicKey": self.public_key, "X-NtApi-Timestamp": ts, "X-NtApi-Sig": sig, } url = f"{self.base_url}/api/{command}" req = urllib.request.Request(url, data=payload.encode(), headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) if resp.status == 200: return data logger.warning(f"REST sync {command} status={resp.status}: {str(data)[:100]}") return {} except urllib.error.HTTPError as e: logger.warning(f"REST sync {command} HTTPError {e.code}: {e.reason}") return {} except urllib.error.URLError as e: logger.warning(f"REST sync {command} URLError: {e.reason}") return {} except Exception as e: logger.warning(f"REST sync {command} error: {e}") return {} async def portfolio_get_changes(self) -> dict: return await self._post("portfolio-get-changes", {}) # ─── Orders ─────────────────────────────────────────────────────────────── async def orders_get_current_history(self) -> dict: return await self._post("getNotifyOrderJson", {"active_only": 1}) async def orders_get_history(self, date_from: str = "", date_to: str = "") -> dict: params = {} if date_from: params["from"] = date_from if date_to: params["till"] = date_to return await self._post("getOrdersHistory", params) async def put_order(self, ticker: str, price: float, qty: float, action: str = "BUY", order_type: str = "LIMIT", client_id: str = "") -> dict: params = { "instr_name": ticker, "price": str(price), "qty": str(int(qty)), "action_id": "2" if action.upper() == "BUY" else "4", "order_type_id": "2" if order_type == "LIMIT" else "1", "expiration_id": "1", } if client_id: params["user_order_id"] = str(client_id) return await self._post("putTradeOrder", params) async def cancel_order(self, order_id: str) -> dict: return await self._post("delTradeOrder", {"order_id": int(order_id)}) async def cancel_all_orders(self) -> dict: return await self._post("delAllTradeOrders", {}) # ─── Sync versions for live trading (called from async trading_loop) ────── # These are needed because the API's aiohttp session gets bound to the loop # it was created in, but our trading_loop runs in its own loop. Calling # aiohttp from a different loop fails. urllib is safe in any context. def put_order_sync(self, ticker: str, price: float, qty: float, action: str = "BUY", order_type: str = "LIMIT", client_id: str = "", timeout: float = 15.0) -> dict: """Place a LIMIT order. Returns parsed JSON or {} on error. Response shape on success: {"order_id": , "order": {}, "warning": ""} On rejection: {"error": "", "code": 0} """ params = { "instr_name": ticker, "price": str(price), "qty": str(int(qty)), "action_id": "2" if action.upper() == "BUY" else "4", # 2=BUY, 4=SELL "order_type_id": "2" if order_type == "LIMIT" else "1", # 2=LIMIT, 1=MARKET "expiration_id": "1", # DAY } if client_id: params["user_order_id"] = str(client_id) return self._post_sync("putTradeOrder", params, timeout) def cancel_order_sync(self, order_id: int, timeout: float = 10.0) -> dict: """Cancel an active order by its ID. Returns parsed JSON or {} on error.""" return self._post_sync("delTradeOrder", {"order_id": int(order_id)}, timeout) def cancel_all_orders_sync(self, timeout: float = 15.0) -> dict: """Cancel all active orders. Returns parsed JSON or {} on error.""" return self._post_sync("delAllTradeOrders", {}, timeout) def get_active_orders_sync(self, timeout: float = 10.0) -> list: """Get list of currently active orders for this account. Response: {"result": {"orders": {"key": "...", : {}, ...}}} Returns a list of order dicts (extracted from the key'd map), empty list on error. """ data = self._post_sync("getNotifyOrderJson", {"active_only": 1}, timeout) out = [] if not data: return out orders_map = data.get("result", {}).get("orders", {}) # The order map has a "key" field plus order_id keys for k, v in orders_map.items(): if k == "key": continue if isinstance(v, dict): out.append(v) return out def get_orders_history_sync(self, date_from: str = "", date_to: str = "", timeout: float = 15.0) -> list: """Get list of orders for the given date range. Args: date_from: "YYYY-MM-DD" or empty (no lower bound) date_to: "YYYY-MM-DD" or empty (no upper bound) Response: {"orders": {"order": [, ...]}} """ params = {} if date_from: params["from"] = date_from if date_to: params["till"] = date_to data = self._post_sync("getOrdersHistory", params, timeout) if not data: return [] orders_obj = data.get("orders", {}) order_list = orders_obj.get("order", []) if isinstance(order_list, dict): # API can return single order as dict too — wrap in list order_list = [order_list] return order_list or []