333 lines
13 KiB
Python
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 [] |