Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import time
- import math
- import hmac
- import json
- import hashlib
- from typing import Any, Dict, List, Optional, Tuple
- from pydmm_connectors.connectors import BaseConnector
- from pydmm_connectors.core.iplist_parser import IpListParser
- from pydmm_connectors.core.session_manager import SessionManager
- import requests
- def proper_round(x: float, precision: int) -> str:
- """Round and format x with given decimal precision."""
- return f"%.{precision}f" % round(x, precision)
- class CoinstoreSpot(BaseConnector):
- BASE_URL = "https://api.coinstore.com/api"
- market = 'coinstore.spot'
- def __init__(
- self,
- args: Dict[str, str],
- symbol: Tuple[str, str, str, int, int],
- logger_dir: Optional[str] = None,
- ) -> None:
- """
- symbol: (EXTERNAL_NAME, BASE, QUOTE, price_decimals, quantity_decimals)
- where EXTERNAL_NAME is like 'BTC_USDT'.
- """
- super().__init__(symbol[0], logger_dir)
- self.session = SessionManager(IpListParser.available_ip_list(None))
- self.market = "coinstore.spot"
- self.min_notional = 5
- self.api_key: str = args["api"]
- self.secret_key: bytes = args["secret"].encode("utf-8")
- # Convert external symbol name BASE_QUOTE -> BTCUSDT (exchange format)
- ext_name, base, quote, price_dp, qty_dp = symbol
- exchange_symbol = ext_name.replace("_", "").upper()
- base = base.upper()
- quote = quote.upper()
- self._symbol_code: str = exchange_symbol
- self._base_asset: str = base
- self._quote_asset: str = quote
- self._price_decimals: int = int(price_dp)
- self._qty_decimals: int = int(qty_dp)
- # These are for incremental trades:
- self._last_public_trade_id: Optional[int] = None
- self._last_private_trade_id: Optional[int] = None
- # Fees cache
- self._maker_fee: Optional[float] = None
- self._taker_fee: Optional[float] = None
- # Pull actual symbol config from exchange and adjust precisions if needed
- try:
- self._load_symbol_info()
- except Exception as e:
- # Не валим конструктор, просто пишем в лог
- self.logger.error(f"Failed to load symbol info from Coinstore: {e}")
- # Public attribute symbol: (exchange_symbol, base, quote, price_dp, qty_dp)
- self.symbol: Tuple[str, str, str, int, int] = (
- self._symbol_code,
- self._base_asset,
- self._quote_asset,
- self._price_decimals,
- self._qty_decimals,
- )
- # ==================== Low-level HTTP helpers ====================
- def _make_signature_and_headers(self, payload: str) -> Dict[str, str]:
- """
- Build Coinstore headers with signature for private endpoints.
- payload: string that is signed (query string or JSON body).
- """
- expires = int(time.time() * 1000)
- expires_key = str(math.floor(expires / 30000)).encode("utf-8")
- # step 1: key = HMAC(secret_key, expires_key)
- key_hex = hmac.new(self.secret_key, expires_key, hashlib.sha256).hexdigest()
- key_bytes = key_hex.encode("utf-8")
- # step 2: sign = HMAC(key, payload)
- payload_bytes = payload.encode("utf-8")
- sign_hex = hmac.new(key_bytes, payload_bytes, hashlib.sha256).hexdigest()
- headers = {
- "X-CS-APIKEY": self.api_key,
- "X-CS-SIGN": sign_hex,
- "X-CS-EXPIRES": str(expires),
- "exch-language": "en_US",
- "Content-Type": "application/json",
- "Accept": "*/*",
- "Connection": "keep-alive",
- }
- return headers
- def _private_post(self, path: str, body: Dict[str, Any]) -> Any:
- """POST with authentication and JSON body."""
- url = f"{self.BASE_URL}{path}"
- payload = json.dumps(body, separators=(",", ":"))
- headers = self._make_signature_and_headers(payload)
- resp = self.session.post(url, headers=headers, data=payload, timeout=10)
- resp.raise_for_status()
- data = resp.json()
- if data.get("code") not in (0, "0"):
- raise RuntimeError(f"Coinstore error: {data}")
- return data.get("data")
- def _private_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
- """GET with authentication and query string parameters."""
- if params:
- # Simple query-string serialization: key=value&...
- qs = "&".join(f"{k}={v}" for k, v in params.items())
- url = f"{self.BASE_URL}{path}?{qs}"
- payload = qs
- else:
- url = f"{self.BASE_URL}{path}"
- payload = ""
- headers = self._make_signature_and_headers(payload)
- resp = self.session.get(url, headers=headers, timeout=10)
- resp.raise_for_status()
- data = resp.json()
- if data.get("code") not in (0, "0"):
- raise RuntimeError(f"Coinstore error: {data}")
- return data.get("data")
- def _public_get(
- self, path: str, params: Optional[Dict[str, Any]] = None
- ) -> Any:
- """GET without authentication."""
- url = f"{self.BASE_URL}{path}"
- resp = self.session.get(url, params=params or {}, timeout=10)
- resp.raise_for_status()
- data = resp.json()
- if data.get("code") not in (0, "0"):
- raise RuntimeError(f"Coinstore public error: {data}")
- return data.get("data")
- # ==================== Symbol / config ====================
- def _load_symbol_info(self) -> None:
- """
- Loads symbol config from /v2/public/config/spot/symbols and updates
- price/qty precision and fees.
- """
- body = {"symbolCodes": [self._symbol_code]}
- # Although endpoint path in docs is /v2/public/... they prepend /api in example:
- data = self._private_post("/v2/public/config/spot/symbols", body)
- if not data:
- return
- info = data[0]
- # Asset codes (API returns lowercase)
- trade_cur = str(info.get("tradeCurrencyCode", "")).upper()
- quote_cur = str(info.get("quoteCurrencyCode", "")).upper()
- if trade_cur:
- self._base_asset = trade_cur
- if quote_cur:
- self._quote_asset = quote_cur
- # Precision (tickSz, lotSz are integers of decimal places)
- tick_sz = info.get("tickSz")
- lot_sz = info.get("lotSz")
- if tick_sz is not None:
- self._price_decimals = int(tick_sz)
- if lot_sz is not None:
- self._qty_decimals = int(lot_sz)
- # Fees
- maker = info.get("makerFee")
- taker = info.get("takerFee")
- if maker is not None:
- self._maker_fee = float(maker)
- if taker is not None:
- self._taker_fee = float(taker)
- # ==================== High-level public info ====================
- def _get_book_top(self) -> Tuple[float, float]:
- """
- Returns (best_bid, best_ask) using /v1/market/tickers.
- Errors are handled by caller (get_info).
- """
- data = self._public_get(f"/v1/market/depth/{self.symbol[0]}?depth=5")
- best_bid = 0.0
- best_ask = None
- for item in data.get("a") or []:
- best_ask = min(best_ask if best_ask else float(item[0]), float(item[0]))
- for item in data.get("b") or []:
- best_bid = max(best_bid, float(item[0]))
- return best_bid, best_ask or 0.0
- def _get_balances_for_symbol(self) -> Dict[str, float]:
- """
- Returns total balances in base and quote assets for this symbol.
- Does NOT catch exceptions (per spec).
- """
- data = self._private_post("/spot/accountList", {})
- base_total = 0.0
- quote_total = 0.0
- base = self._base_asset.upper()
- quote = self._quote_asset.upper()
- for item in data or []:
- cur = str(item.get("currency", "")).upper()
- bal_str = item.get("balance", "0")
- try:
- bal = float(bal_str)
- except Exception:
- bal = 0.0
- if cur == base:
- print(item)
- base_total += bal
- elif cur == quote:
- print(item)
- quote_total += bal
- return {"base": base_total, "quote": quote_total}
- # ==================== REQUIRED METHODS ====================
- def get_info(self) -> Dict[str, Dict[str, float]]:
- """
- Returns:
- {
- 'book': {'ask_price': float, 'bid_price': float},
- 'balance':{'base': float, 'quote': float}
- }
- """
- # Book — must handle errors and fall back to zero
- bid_price = 0.0
- ask_price = 0.0
- try:
- bid_price, ask_price = self._get_book_top()
- except Exception as e:
- self.logger.error(f"Failed to fetch book for {self._symbol_code}: {e}")
- bid_price = 0.0
- ask_price = 0.0
- # Balance — must NOT handle exceptions
- balances = self._get_balances_for_symbol()
- return {
- "book": {"ask_price": ask_price, "bid_price": bid_price},
- "balance": balances,
- }
- def new_limit(self, price: float, quantity: float, is_bid: bool) -> None:
- """
- Place a regular LIMIT order. Does not return anything.
- Logs errors if they occur.
- """
- side = "BUY" if is_bid else "SELL"
- price_str = proper_round(float(price), self._price_decimals)
- qty_str = proper_round(float(quantity), self._qty_decimals)
- body = {
- "symbol": self._symbol_code,
- "side": side,
- "ordType": "LIMIT",
- "ordPrice": price_str,
- "ordQty": qty_str,
- "timestamp": int(time.time() * 1000),
- }
- try:
- self.logger.info('coinstore.spot new_limit:', self._private_post("/trade/order/place", body))
- except Exception as e:
- self.logger.error(f"Failed to place limit order on {self._symbol_code}: {e}")
- def new_limit_maker(self, price: float, quantity: float, is_bid: bool) -> None:
- """
- Place a POST_ONLY limit order if supported.
- If not supported by exchange, falls back to regular limit.
- """
- side = "BUY" if is_bid else "SELL"
- price_str = proper_round(float(price), self._price_decimals)
- qty_str = proper_round(float(quantity), self._qty_decimals)
- body = {
- "symbol": self._symbol_code,
- "side": side,
- "ordType": "POST_ONLY",
- "ordPrice": price_str,
- "ordQty": qty_str,
- "timestamp": int(time.time() * 1000),
- }
- try:
- self.logger.info('coinstore.spot new_limit_maker:', self._private_post("/trade/order/place", body))
- except Exception as e:
- # if POST_ONLY is not accepted by exchange — fallback
- self.logger.error(
- f"Failed to place POST_ONLY order on {self._symbol_code}, "
- f"falling back to LIMIT: {e}"
- )
- self.new_limit(price, quantity, is_bid)
- def new_batch_limit_maker(self, asks, bids):
- total = [(ask + [False]) for ask in asks] + [(bid + [True]) for bid in
- bids]
- for i in range(0, len(total), 20):
- try:
- part = total[i:min(len(total), i + 20)]
- body = {
- "symbol": self._symbol_code,
- "orders": [],
- "timestamp": int(time.time() * 1000),
- }
- for order in part:
- body["orders"].append({
- "side": "BUY" if order[2] else "SELL",
- "ordType": "POST_ONLY",
- "ordPrice": proper_round(order[0], self.symbol[3]),
- "ordQty": proper_round(order[1], self.symbol[4]),
- })
- self._private_post("/trade/order/placeBatch", body)
- except Exception as e:
- self.logger.error(f"Failed to place batch limit order on {self._symbol_code}: {e}")
- def cancel(self, order_id: int) -> None:
- """
- Cancel a single order. Logs errors, no exceptions propagated.
- """
- body = {"symbol": self._symbol_code, "ordId": int(order_id)}
- try:
- self.logger.info('coinstore.spot cancel:', self._private_post("/trade/order/cancel", body))
- except Exception as e:
- self.logger.error(
- f"Failed to cancel order {order_id} on {self._symbol_code}: {e}"
- )
- def cancel_batch(self, oids):
- for i in range(0, len(oids), 20):
- try:
- part = oids[i:min(len(oids), i + 20)]
- body = {
- "symbol": self._symbol_code,
- "orderIds": part,
- "timestamp": int(time.time() * 1000),
- }
- self._private_post("/trade/order/cancelBatch", body)
- except Exception as e:
- self.logger.error(f"Failed to place batch limit order on {self._symbol_code}: {e}")
- def get_orders(self) -> Dict[str, List[Dict[str, Any]]]:
- """
- Returns open orders for this symbol:
- {
- 'ask': [{'id': ..., 'quantity': ..., 'price': ...}, ...],
- 'bid': [...]
- }
- """
- try:
- data = self._private_get(
- "/trade/order/active",
- params={"symbol": self._symbol_code.lower()},
- )
- except Exception as e:
- self.logger.error(f"Failed to fetch open orders: {e}")
- raise e
- asks: List[Dict[str, Any]] = []
- bids: List[Dict[str, Any]] = []
- print(data)
- for o in data or []:
- side = str(o.get("side", "")).upper()
- if str(o.get("symbol", "")).upper() != self._symbol_code.upper():
- continue
- try:
- oid = int(o.get("ordId"))
- except Exception:
- continue
- try:
- price = float(o.get("ordPrice", 0))
- qty = float(o.get("ordQty", 0))
- except Exception:
- price = 0.0
- qty = 0.0
- order_desc = {"id": oid, "quantity": qty, "price": price}
- if side == "BUY":
- bids.append(order_desc)
- elif side == "SELL":
- asks.append(order_desc)
- return {"ask": asks, "bid": bids}
- def get_depth(self) -> Dict[str, List[Tuple[float, float]]]:
- """
- Returns depth for this symbol:
- {
- 'asks': [(price, quantity), ...],
- 'bids': [(price, quantity), ...]
- }
- at least 50 levels if available.
- Exceptions are handled and result in empty depth.
- """
- try:
- data = self._public_get(
- f"/v1/market/depth/{self._symbol_code}", params={"depth": 50}
- )
- except Exception as e:
- self.logger.error(f"Failed to fetch depth for {self._symbol_code}: {e}")
- return {"asks": [], "bids": []}
- asks: List[Tuple[float, float]] = []
- bids: List[Tuple[float, float]] = []
- # 'a' for asks, 'b' for bids, each entry is [price, qty, direction]
- for a in (data or {}).get("a", []):
- try:
- p = float(a[0])
- q = float(a[1])
- asks.append((p, q))
- except Exception:
- continue
- for b in (data or {}).get("b", []):
- try:
- p = float(b[0])
- q = float(b[1])
- bids.append((p, q))
- except Exception:
- continue
- return {"asks": asks, "bids": bids}
- def get_new_public_trades(self) -> List[Dict[str, Any]]:
- """
- Returns new PUBLIC trades since last call.
- On first call, returns [] (but sets internal cursor).
- On error, returns [].
- Trade format:
- {
- 'price': float,
- 'quantity': float,
- 'side': 'buy' or 'sell',
- 'timestamp': int # ms
- }
- """
- try:
- data = self._public_get(
- f"/v1/market/trade/{self._symbol_code}", params={"size": 100}
- )
- except Exception as e:
- self.logger.error(
- f"Failed to fetch public trades for {self._symbol_code}: {e}"
- )
- return []
- trades_raw = data or []
- if not trades_raw:
- return []
- # Trades are list of dicts; tradeId is monotonically increasing
- ids = [int(t.get("tradeId", 0)) for t in trades_raw if t.get("tradeId") is not None]
- if not ids:
- return []
- max_id = max(ids)
- # First call -> just set cursor and return empty list
- if self._last_public_trade_id is None:
- self._last_public_trade_id = max_id
- return []
- new_trades: List[Dict[str, Any]] = []
- for t in trades_raw:
- try:
- tid = int(t.get("tradeId"))
- except Exception:
- continue
- if tid <= self._last_public_trade_id:
- continue
- try:
- price = float(t.get("price", 0))
- qty = float(t.get("volume", 0))
- except Exception:
- price = 0.0
- qty = 0.0
- side_raw = str(t.get("takerSide", "")).lower()
- side = "buy" if side_raw == "buy" else "sell"
- ts = t.get("ts") or int(t.get("time", 0) * 1000)
- new_trades.append(
- {
- "price": price,
- "quantity": qty,
- "side": side,
- "timestamp": int(ts),
- }
- )
- if new_trades:
- self._last_public_trade_id = max(max_id, self._last_public_trade_id)
- return new_trades
- def get_new_private_trades(self) -> List[Dict[str, Any]]:
- """
- Returns new USER trades since last call.
- On first call, returns [] (but sets internal cursor).
- On error, returns [].
- Format (price is execAmt/execQty):
- {
- 'price': float,
- 'quantity': float,
- 'side': 'buy' or 'sell',
- 'timestamp': int # seconds from matchTime
- }
- """
- page_num = 1
- page_size = 100
- max_pages = 20
- trades_raw: List[Dict[str, Any]] = []
- try:
- while True:
- params = {
- "symbol": self._symbol_code.lower(),
- "pageNum": page_num,
- "pageSize": page_size,
- }
- data = self._private_get("/trade/match/accountMatches", params=params)
- page = data or []
- if not page:
- break
- trades_raw.extend(page)
- if len(page) < page_size:
- break
- if self._last_private_trade_id is not None:
- try:
- min_id_in_page = min(
- int(t.get("tradeId", 0)) for t in page if t.get("tradeId") is not None
- )
- except Exception:
- min_id_in_page = None
- if min_id_in_page is not None and min_id_in_page <= self._last_private_trade_id:
- break
- page_num += 1
- if page_num > max_pages:
- break
- except Exception as e:
- self.logger.error(
- f"Failed to fetch private trades for {self._symbol_code}: {e}"
- )
- return []
- if not trades_raw:
- return []
- self.logger.info('get_new_private_trades::trades_raw:', len(trades_raw))
- ids = [int(t.get("tradeId", 0)) for t in trades_raw if t.get("tradeId") is not None]
- if not ids:
- return []
- max_id = max(ids)
- # First call: set cursor, return no trades
- if self._last_private_trade_id is None:
- self._last_private_trade_id = max_id
- return []
- new_trades: List[Dict[str, Any]] = []
- for t in trades_raw:
- try:
- tid = int(t.get("tradeId"))
- except Exception:
- continue
- if tid <= self._last_private_trade_id:
- continue
- try:
- qty = float(t.get("execQty", 0))
- amt = float(t.get("execAmt", 0))
- price = amt / qty if qty > 0 else 0.0
- except Exception:
- qty = 0.0
- price = 0.0
- side_val = int(t.get("side", 0))
- side = "buy" if side_val == 1 else "sell"
- ts = int(t.get("matchTime", 0)) # seconds
- new_trades.append(
- {
- "price": price,
- "quantity": qty,
- "side": side,
- "timestamp": ts,
- }
- )
- if new_trades:
- self._last_private_trade_id = max(max_id, self._last_private_trade_id)
- return new_trades
- def cancel_open_orders(self) -> None:
- """
- Cancels all currently open orders for this symbol.
- Logs errors but does not propagate them.
- """
- orders = self.get_orders()
- self.logger.info('cancel_open_orders open orders:', orders)
- for side in ("ask", "bid"):
- for o in orders.get(side, []):
- oid = o.get("id")
- if oid is None:
- continue
- try:
- self.cancel(int(oid))
- except Exception as e:
- self.logger.error(
- f"Failed to cancel order {oid} while cancelling all: {e}"
- )
- def get_price(self) -> float:
- """
- Returns current market price for this symbol using /v1/ticker/price.
- """
- params = {"symbol": self._symbol_code.lower()}
- data = self._public_get("/v1/ticker/price", params=params)
- # data is a list of {"id":..., "symbol":..., "price": "..."}
- price = 0.0
- sym_lc = self._symbol_code.lower()
- for item in data or []:
- if str(item.get("symbol", "")).lower() == sym_lc:
- try:
- price = float(item.get("price", 0))
- except Exception:
- price = 0.0
- break
- return price
- def get_fees(self) -> Dict[str, float]:
- """
- Returns fees:
- {
- 'maker': float,
- 'taker': float
- }
- """
- if self._maker_fee is None or self._taker_fee is None:
- # refresh symbol info
- try:
- self._load_symbol_info()
- except Exception as e:
- self.logger.error(f"Failed to refresh fees info: {e}")
- maker = float(self._maker_fee or 0.0)
- taker = float(self._taker_fee or 0.0)
- return {"maker": maker, "taker": taker}
- spot = CoinstoreSpot(args, ('MAME_USDT', 'MAME', 'USDT', 5, 2))
- print('first orders', spot.get_orders())
- print('first trades', spot.get_new_private_trades())
- print('first book', spot.get_info())
- p = spot.get_info()['book']['ask_price']
- spot.new_limit(1.1 * p, 6 / p, True)
- time.sleep(5)
- print('second trades', spot.get_new_private_trades())
- print('second book', spot.get_info())
- print('second orders', spot.get_orders())
Advertisement