Files
gridbot/api.py
T

333 lines
13 KiB
Python

"""
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(qty if qty < 1 else 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": <int>, "order": {<order fields>}, "warning": ""}
On rejection:
{"error": "<reason>", "code": 0}
"""
params = {
"instr_name": ticker,
"price": str(price),
"qty": str(qty if qty < 1 else int(qty)), # BTC float for sub-unit
"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": "...", <order_id>: {<order fields>}, ...}}}
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": [<order dict>, ...]}}
"""
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 []