更新环境配置示例,添加 GRVT 相关的 API 凭证和选项,增强文档以支持新的交易所适配器,确保用户能够正确配置和使用 GRVT 交易功能。

This commit is contained in:
discountry
2025-09-27 16:12:20 +08:00
parent 64640f4494
commit dde7ab71a9
60 changed files with 16126 additions and 527 deletions
@@ -0,0 +1,812 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
import json
import logging
from typing import Any, Literal
import requests
from .grvt_ccxt_base import GrvtCcxtBase
from .grvt_ccxt_env import GrvtEnv, get_grvt_endpoint
from .grvt_ccxt_types import (
Amount,
GrvtInstrumentKind,
GrvtInvalidOrder,
GrvtOrderSide,
GrvtOrderType,
Num,
)
from .grvt_ccxt_utils import (
EnumEncoder,
GrvtOrder,
get_cookie_with_expiration,
get_grvt_order,
get_order_payload,
)
class GrvtCcxt(GrvtCcxtBase):
"""
GrvtCcxt class to interact with Grvt Rest API in synchronous mode.
Args:
env: GrvtEnv (DEV, TESTNET, PROD)
logger: logging.Logger
parameters: dict with trading_account_id, private_key, api_key etc
Examples:
>>> from grvt_api import GrvtCcxt
>>> from grvt_env import GrvtEnv
>>> grvt = GrvtCcxt(env=GrvtEnv.TESTNET)
>>> grvt.fetch_markets()
"""
def __init__(
self,
env: GrvtEnv,
logger: logging.Logger | None = None,
parameters: dict = {},
order_book_ccxt_format: bool = False,
):
"""Initialize the GrvtCcxt instance."""
super().__init__(env, logger, parameters, order_book_ccxt_format)
self._clsname: str = type(self).__name__
self._session: requests.Session = requests.Session()
self._session.headers.update({"Content-Type": "application/json"})
self.refresh_cookie()
# Assign markets here
self.markets: dict[str, dict] = self.load_markets()
def refresh_cookie(self) -> dict | None:
"""Refresh the session cookie."""
if not self.should_refresh_cookie():
return self._cookie
path = get_grvt_endpoint(self.env, "AUTH")
self._cookie = get_cookie_with_expiration(path, self._api_key)
self._path_return_value_map[path] = self._cookie
if self._cookie:
self._session.cookies.update({"gravity": self._cookie["gravity"]})
if self._cookie["X-Grvt-Account-Id"]:
self._session.headers.update(
{"X-Grvt-Account-Id": self._cookie["X-Grvt-Account-Id"]}
)
self.logger.info(
f"refresh_cookie {self._cookie=} {self._session.cookies=} {self._session.headers=}"
)
return self._cookie
# PRIVATE API CALLS
def _auth_and_post(self, path: str, payload: dict) -> dict:
FN = f"_auth_and_post {path=}"
MAX_LEN_TO_LOG = 1280
response: dict = {}
if not path:
self.logger.warning(f"{FN} Invalid path {path=} {payload=}")
raise GrvtInvalidOrder(f"{FN} Invalid path {path=} {payload=}")
# Always see if need to referesh cookie before sending a request
self.refresh_cookie()
payload_json = json.dumps(payload, cls=EnumEncoder)
self.logger.info(f"{FN} {payload=}\n{payload_json=}")
return_value = self._session.post(path, data=payload_json, timeout=5)
return_text: str = ""
try:
return_text = return_value.text
response = return_value.json()
except Exception as err:
self.logger.warning(f"{FN} Unable to parse {return_value=} as json. {err=}")
if not return_value.ok:
self.logger.warning(f"{FN} ERROR {payload_json=}\n{return_value=}\n{response=}")
else:
if len(return_text) > MAX_LEN_TO_LOG:
self.logger.debug(f"{FN} OK {return_value=} {response=}")
self.logger.info(f"{FN} OK {return_value=} response=**TOO LONG**")
else:
self.logger.info(f"{FN} OK {return_value=} {response=}")
self._path_return_value_map[path] = response
return response
def _create_grvt_order(self, order: GrvtOrder) -> dict:
"""
Send a GrvtOrder object to the exchange.
:param order: The GrvtOrder object.
Return: dictionary representing the order response.
"""
FN = f"{self._clsname} _create_grvt_order cloid:{order.metadata.client_order_id}"
order_payload = get_order_payload(
order,
private_key=self._private_key,
env=self.env,
instruments=self.markets,
)
path = get_grvt_endpoint(self.env, "CREATE_ORDER")
self.logger.info(f"{FN} {path=} {order_payload=}")
response: dict = self._auth_and_post(path, payload=order_payload)
if response.get("result") is None:
self.logger.error(f"{FN} Error: {response}")
return {}
self.logger.info(
f"{FN} Order created:"
f"{response.get('result', {}).get('metadata', {}).get('client_order_id')}"
)
return response.get("result", {})
def create_order(
self,
symbol: str,
order_type: GrvtOrderType,
side: GrvtOrderSide,
amount: Amount,
price: Num = None,
params={},
) -> dict:
"""Ccxt compliant signature."""
self._check_account_auth()
self._check_valid_symbol(symbol)
# Validate order fields
self._check_order_arguments(order_type, side, amount, price)
# create GrvtOrder object
order_duration_secs = params.get("order_duration_secs", 24 * 60 * 60)
order = get_grvt_order(
sub_account_id=self.get_trading_account_id(),
symbol=symbol,
order_type=order_type,
side=side,
amount=amount,
limit_price=price,
order_duration_secs=order_duration_secs,
params=params,
)
return self._create_grvt_order(order)
def create_limit_order(
self,
symbol: str,
side: GrvtOrderSide,
amount: Amount,
price: Num = None,
params={},
) -> dict:
return self.create_order(symbol, "limit", side, amount, price, params)
def cancel_all_orders(
self,
params: dict = {},
) -> bool:
"""
Ccxt compliant signature BUT lacks symbol
Cancel all orders for a sub-account.
params: dictionary with parameters. Valid keys:<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Defaults to all.<br>
"""
self._check_account_auth()
FN = f"{self._clsname} cancel_all_orders"
payload: dict = self._get_payload_cancel_all_orders(params)
path = get_grvt_endpoint(self.env, "CANCEL_ALL_ORDERS")
response: dict = self._auth_and_post(path, payload)
cancel_ack = response.get("result", {}).get("ack")
if not cancel_ack:
self.logger.warning(f"{FN} failed to cancel orders: {response=}")
return False
self.logger.info(f"{FN} Cancelled {response=}")
return True
def cancel_order(
self,
id: str | None = None,
symbol: str | None = None,
params: dict = {},
) -> bool:
"""
Ccxt compliant signature
Cancel specific order for the account.<br>
Private call requires authorization.<br>
See [Cancel order](https://api-docs.grvt.io/trading_api/#cancel-order)
for details.<br>.
Args:
id (str): exchange assigned order ID<br>
symbol (str): trading symbol<br>
params:
* client_order_id (str): client assigned order ID<br>
* time_to_live_ms (str): lifetime of cancel requiest in millisecs<br>
Returns:
True if cancel request was acked by exchange. False otherwise.<br>
"""
FN = f"{self._clsname} cancel_order"
self._check_account_auth()
payload: dict = {
"sub_account_id": self.get_trading_account_id(),
}
if id:
payload["order_id"] = str(id)
elif "client_order_id" in params:
payload["client_order_id"] = str(params["client_order_id"])
else:
raise GrvtInvalidOrder(f"{FN} requires either order_id or client_order_id")
if "time_to_live_ms" in params:
payload["time_to_live_ms"] = str(params["time_to_live_ms"])
path = get_grvt_endpoint(self.env, "CANCEL_ORDER")
self.logger.info(
f"{FN} Send cancel {payload=} for trading_account_id={self.get_trading_account_id()}"
)
response: dict = self._auth_and_post(path, payload)
cancel_ack = response.get("result", {}).get("ack")
if not cancel_ack:
self.logger.warning(f"{FN} failed to cancel order: {response=}")
return False
self.logger.info(f"{FN} Cancelled {response=}")
return True
def set_derisk_mm_ratio(self, ratio: str) -> bool:
"""
Ccxt compliant signature
Set the Derisk to Maintenance marginb ratio for the account.
Private call requires authorization.
See [Set Derisk M M ratio](https://api-docs.grvt.io/trading_api/#set-derisk-m-m-ratio)
for details.
Args:
ratio (Amount): The new derisking market making ratio.
Returns:
True if the request was acknowledged by the exchange. False otherwise.
"""
FN = f"{self._clsname} set_derisk_mm_ratio"
self._check_account_auth()
payload: dict[str, str | dict] = self._get_set_derisk_mm_ratio_payload(str(ratio))
path = get_grvt_endpoint(self.env, "SET_DERISK_MM_RATIO")
self.logger.info(
f"{FN} Send {payload=} for trading_account_id={self.get_trading_account_id()}"
)
response: dict = self._auth_and_post(path, payload)
# set_ack = response.get("result", {}).get("ack")
# if not set_ack:
# self.logger.warning(f"{FN} failed to set derisk_mm_ratio: {response=}")
# return False
self.logger.info(f"{FN} Set derisk_mm_ratio {response=}")
return True
def fetch_open_orders(
self,
symbol: str | None = None,
since: int | None = None,
limit: int | None = None,
params: dict = {},
) -> list[dict]:
"""
Ccxt compliant signature
Fetch open orders for the account.<br>
Private call requires authorization.<br>
See [Open orders](https://api-docs.grvt.io/trading_api/#open-orders)
for details.<br>.
Args:
symbol: (str) get orders for this symbol only.<br>
since: ccxt-compliant argument, NOT SUPPORTED.<br>
limit: ccxt-compliant argument, NOT SUPPORTED.<br>
params: dictionary with parameters. Valid keys:<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Defaults to all.<br>
Returns:
a list of dictionaries, each dict represent an order.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_open_orders(symbol, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_OPEN_ORDERS")
response: dict = self._auth_and_post(path, payload)
open_orders: list = response.get("result", [])
if symbol:
open_orders = [
o for o in open_orders if o.get("legs") and o["legs"][0].get("instrument") == symbol
]
return open_orders
def fetch_order(
self,
id: str | None = None,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature
Get Order status by order_id or client_order_id
Private call requires authorization.<br>
See [Open orders](https://api-docs.grvt.io/trading_api/#open-orders)
for details.<br>
Args:
id: (str) order_id to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`client_order_id` (int): client assigned order ID.<br>
Return: dict with order's details or {} if order was NOT found.
"""
self._check_account_auth()
payload = {
"sub_account_id": self.get_trading_account_id(),
}
if id:
payload["order_id"] = id
elif "client_order_id" in params:
payload["client_order_id"] = str(params["client_order_id"])
else:
raise GrvtInvalidOrder(
f"{self._clsname} fetch_order() requires order_id or params['client_order_id']"
)
path = get_grvt_endpoint(self.env, "GET_ORDER")
response: dict = self._auth_and_post(path, payload)
return response
def fetch_order_history(self, params: dict = {}) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.<br>
Get Order status by order_id or client_order_id
Private call requires authorization.<br>
See [Order History](https://api-docs.grvt.io/trading_api/#order-history)
for details.<br>
Args:
params: dictionary with parameters. Valid keys:<br>
`kind`: (str) - The kind filter to apply. Defaults to all kinds.<br>
`base`: (str) - The base currency filter. Defaults to all base currencies.<br>
`quote`: (str) - The quote currency filter. Defaults to all quote currencies.<br>
`expiration`: (int) The expiration time in nanoseconds. Defaults to all.<br>
`strike_price`: (str) The strike price to apply. Defaults to all strike prices.<br>
`limit`: (int) The limit to query for. Defaults to 500; Max 1000.<br>
`cursor`: (str) The cursor to use for pagination. If nil, return the first page.<br>
Return: a dictionary with keys:
`total` : total number of account history snapshots.<br>
`next` : cursor for the next page.<br>
`result` : a list of dictionaries, each dict represent an order state.<br>.
"""
self._check_account_auth()
payload = self._get_payload_fetch_order_history(params)
path = get_grvt_endpoint(self.env, "GET_ORDER_HISTORY")
response: dict = self._auth_and_post(path, payload)
return response
def get_account_summary(
self, type: Literal["sub-account", "funding", "aggregated"] = "sub-account"
) -> dict:
"""
Return: The account summary.
Private call requires authorization.<br>
See [Account Summary](https://api-docs.grvt.io/trading_api/#sub-account_summary)
for details.<br>
Returns: dictionary with account data.<br>.
"""
FN = f"{self._clsname} get_account_summary {type=}"
self._check_account_auth()
payload = {}
if type == "sub-account":
path = get_grvt_endpoint(self.env, "GET_ACCOUNT_SUMMARY")
payload = {"sub_account_id": self.get_trading_account_id()}
elif type == "funding":
path = get_grvt_endpoint(self.env, "GET_FUNDING_ACCOUNT_SUMMARY")
elif type == "aggregated":
path = get_grvt_endpoint(self.env, "GET_AGGREGATED_ACCOUNT_SUMMARY")
else:
raise GrvtInvalidOrder(f"{FN} Invalid account summary type {type}")
response: dict = self._auth_and_post(path, payload=payload)
sub_account: dict = response.get("result", {})
if not sub_account:
self.logger.info(f"{FN} No account summary for {path=} {payload=}")
return sub_account
def fetch_balance(
self, type: Literal["sub-account", "funding", "aggregated"] = "sub-account"
) -> dict:
"""
Ccxt compliant signature
Fetch balances for the account.<br>
Private call requires authorization.<br>
See [Account Summary](https://api-docs.grvt.io/trading_api/#sub-account_summary)
for details.<br>.
Args:
type: (str) - The type of account to fetch balances for. Defaults to 'sub-account'.
Valid values: 'sub-account', 'funding', 'aggregated'.
Returns: dictionary with ccxt-compliant balance data https://docs.ccxt.com/#/README?id=account-balance.<br>.
"""
account_summary: dict = self.get_account_summary(type)
return self._get_balances_from_account_summary(account_summary)
def fetch_account_history(self, params: dict = {}, limit: int = 500) -> dict:
"""
HISTORICAL data.<br>
Get account history.<br>
Private call requires authorization.<br>
See [Account History](https://api-docs.grvt.io/trading_api/#account-history)
for details.<br>.
Args:
limit: maximum number of account snapshots per page to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`start_time` (int): fetch orders since this timestamp in nanoseconds.<br>
`end_time` (int): fetch orders until this timestamp in nanoseconds.<br>
`cursor` (str): cursor for the pagination. If cursor is present then we ignore
`start_time` and `end_time`.<br>
Returns:
a dictionary with keys:
`total` : total number of account history snapshots.<br>
`next` : cursor for the next page.<br>
`result` : list of account history snapshots.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_account_history(limit, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_ACCOUNT_HISTORY")
response: dict = self._auth_and_post(path, payload=payload)
return response
def fetch_positions(self, symbols: list[str] = [], params={}):
"""
Ccxt compliant signature
Fetch positions for the account.<br>
Private call requires authorization.<br>
See [Positions](https://api-docs.grvt.io/trading_api/#positions)
for details.<br>.
Args:
symbols: list(str) get positions for these symbols only.<br>
Returns: list of dictionaries, each dict represent a position.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_positions(symbols, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_POSITIONS")
response: dict = self._auth_and_post(path, payload)
positions: list = response.get("result", [])
if symbols:
self.logger.info(f"fetch_positions filter positions by {symbols=}")
positions = [p for p in positions if p.get("instrument") in symbols]
return positions
def fetch_my_trades(
self,
symbol: str | None = None,
since: int | None = None,
limit: int | None = None,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.<br>
Fetch past trades for the account.<br>
Private call requires authorization.<br>
See [Private Trade History](https://api-docs.grvt.io/trading_api/#private-trade-history)
for details.<br>.
Args:
symbol: get trades for this symbol only.<br>
since: fetch trades since this timestamp in nanoseconds.<br>
limit: maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Default: 'USDT'.<br>
Returns:
a dictionary with keys:
`total` : total number of account history snapshots.<br>
`next` : cursor for the next page.<br>
`result` : a list of dictionaries, each dict represent a trade.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_my_trades(symbol, since, limit, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_FILL_HISTORY")
response: dict = self._auth_and_post(path, payload=payload)
if symbol:
# filter result by symbol
trades: list = response.get("result", [])
trades = [t for t in trades if t.get("instrument") == symbol]
response["result"] = trades
return response
# **************** PUBLIC API CALLS
def load_markets(self) -> dict[str, dict]:
self.logger.info("load_markets START")
instruments = self.fetch_markets(
params={
"kind": GrvtInstrumentKind.PERPETUAL,
# "base": "BTC",
# "quote": "USDT",
}
)
if instruments:
self.markets = {
str(i.get("instrument", "")): i for i in instruments if i.get("instrument")
}
self.logger.info(f"load_markets: loaded {len(self.markets)} markets.")
else:
self.logger.warning("load_markets: No markets found.")
return self.markets
def fetch_markets(
self,
params: dict = {},
) -> list[dict]:
"""
Ccxt compliant signature
Retrieve the list of all instruments of matching kind, base and quote
supported by the exchange.
Params: dict with keys:<br>
`is_active` (bool) - defaults to True.<br>
`limit` (int) - defaiults to 20.<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Default: 'USDT'.<br>
Returns: list of dictionaries per instrument with keys:<br>
`instrument`: symbol e.g. 'BTC_USDT_Perp'.<br>
`instrument_hash`: hashed symbol for order signing e.g. '0x030501'.<br>
`base`: base currency e.g. 'BTC'.<br>
`quote`: quote currency e.g. 'USDT'.<br>
`kind`: kind of instrument 'PERPETUAL'/'FUTURE'.<br>
'base_decimals': size multiplier for order signing.<br>
`tick_size`: price tick size.<br>
`min_size`: minimum order size.<br>
"""
# Prepare request payload
payload = self._get_payload_fetch_markets(params)
# Make the POST request to get all instruments
path = get_grvt_endpoint(self.env, "GET_INSTRUMENTS")
response: dict = self._auth_and_post(path, payload)
return response.get("result", [])
def fetch_all_markets(
self,
is_active: bool | None = True,
) -> list[dict]:
"""
Retrieve the list of all instruments supported by the exchange.<br>
Params:<br>
`is_active` (bool) - defaults to True.<br>.
Returns: list of dictionaries per instrument. See fetch_markets().<br>
"""
# Prepare request payload
payload = {"is_active": is_active}
# Make the POST request to get all instruments
path = get_grvt_endpoint(self.env, "GET_ALL_INSTRUMENTS")
response: dict = self._auth_and_post(path, payload)
return response.get("result", [])
def fetch_market(self, symbol: str) -> dict:
"""
Retrieve the instrument object for a given symbol.
:param symbol: The symbol of the instrument.
"""
# Make the POST request to get all instruments
path = get_grvt_endpoint(self.env, "GET_INSTRUMENT")
response: dict = self._auth_and_post(path, payload={"instrument": symbol})
return response.get("result", [])
def fetch_ticker(self, symbol: str, params: dict = {}) -> dict:
"""
Ccxt compliant signature
Retrieve the ticker of a given symbol.
:param symbol: The instrument name.
:return: The ticker dictionary of the instrument.
"""
# {'event_time': '1724252426000000000', 'instrument': 'BTC_USDT_Perp',
# 'mark_price': '59373870996065', 'index_price': '59395287961367',
# 'last_price': '99000000000000', 'last_size': '9917000000', 'mid_price': '59569850000000',
# 'best_bid_price': '59866000000000', 'best_bid_size': '23705000000', 'best_ask_price':
# '592737000', 'best_ask_size': '21678', 'funding_rate_curr': 2544, 'funding_rate_avg': 0,
# 'interest_rate': 0, 'forward_price': '0', 'buy_volume_u': '401930000000',
# 'sell_volume_u': '1218289000000', 'buy_volume_q': '34637817515500',
# 'sell_volume_q': '68764000329900', 'high_price': '3435450000', 'low_price': '100000',
# 'open_price': '32554000000000', 'open_interest': '8174350000000',
# 'long_short_ratio': 1.0948905}
path = get_grvt_endpoint(self.env, "GET_TICKER")
response: dict = self._auth_and_post(path, payload={"instrument": symbol})
return response.get("result", [])
def fetch_mini_ticker(self, symbol: str) -> dict:
"""
Retrieve the mini-ticker of a given symbol.
:param symbol: The instrument name.
:return: The mini-ticker dictionary of the instrument.
"""
# {'event_time': '1724252426000000000', 'instrument': 'BTC_USDT_Perp',
# 'mark_price': '59373870996065', 'index_price': '59395287961367',
# 'last_price': '99000000000000', 'last_size': '9917000000', 'mid_price': '59569850000000',
# 'best_bid_price': '59866000000000', 'best_bid_size': '23705000000', 'best_ask_price':
# '59273700000000', 'best_ask_size': '21678000000'}
path = get_grvt_endpoint(self.env, "GET_MINI_TICKER")
response: dict = self._auth_and_post(path, payload={"instrument": symbol})
return response.get("result", [])
def fetch_order_book(self, symbol: str, limit: int = 10, params={}) -> dict:
"""
Ccxt compliant signature
Retrieve the order book of a given symbol.
:param symbol: The instrument name.
:return: The order book dictionary of the instrument.
"""
# {'event_time': '0', 'instrument': 'BTC_USDT_Perp',
# 'bids': [{'price': '100000000', 'size': '86353000000', 'num_orders': 4},...]
# 'asks': [{'price': '59273700000000', 'size': '21678000000', 'num_orders': 1}, ...]
payload = {"instrument": symbol, "aggregate": 1}
if limit:
payload["depth"] = limit
path = get_grvt_endpoint(self.env, "GET_ORDER_BOOK")
response: dict = self._auth_and_post(path, payload=payload)
if self.is_order_book_ccxt_format():
# Convert to ccxt format
return self.convert_grvt_ob_to_ccxt(response.get("result", {}))
return response.get("result", {})
def fetch_recent_trades(
self,
symbol: str,
limit: int | None = None,
) -> list:
"""
Retrieve recent trades of a given instrument.
:param symbol: The instrument name.
:return: The list of trades for the instrument.
"""
# List of {'event_time': '1724248876870635916', 'instrument': 'ETH_USDT_Perp',
# 'is_taker_buyer': True, 'size': '24000000000', 'price': '2600000000000',
# 'mark_price': '2591055564869', 'index_price': '2592459142472', 'interest_rate': 0,
# 'forward_price': '0', 'trade_id': '729726', 'venue': 'ORDERBOOK'}
payload: dict[str, str | int] = {"instrument": symbol}
if limit:
payload["limit"] = limit
path = get_grvt_endpoint(self.env, "GET_TRADES")
response: dict = self._auth_and_post(path, payload=payload)
return response.get("result", [])
def fetch_trades(
self,
symbol: str,
since: int | None = None,
limit: int = 10,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.<br>
Retrieve trade history of a given instrument.
:param symbol: The instrument name.
:return: dict with field 'result' containing a list of trades.
"""
# List of {'event_time': '1724248876870635916', 'instrument': 'ETH_USDT_Perp',
# 'is_taker_buyer': True, 'size': '24000000000', 'price': '2600000000000',
# 'mark_price': '2591055564869', 'index_price': '2592459142472', 'interest_rate': 0,
# 'forward_price': '0', 'trade_id': '729726', 'venue': 'ORDERBOOK'}
payload: dict = self._get_payload_fetch_trades(
symbol,
since=since,
limit=limit,
params=params,
)
path: str = get_grvt_endpoint(self.env, "GET_TRADE_HISTORY")
response: dict = self._auth_and_post(path, payload=payload)
return response
def fetch_funding_rate_history(
self,
symbol: str,
since: int = 0,
limit: int = 1_000,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.<br>
Retrieve the funding rates history of a given instrument.<br>
Args:
symbol (str): The instrument name.<br>
since (int): fetch trades since this timestamp in nanoseconds.<br>
limit: int - maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`end_time` (int): end time in nanoseconds.<br>
Returns:
dict with field 'result' containing list of dictionaries repesenting funding rate
at a point in time with fields:<br>
`instrument` (str): instrument name.<br>
'funding_rate' (float): funding rate.<br>
'funding_time' (int): funding time in nanoseconds.<br>
'mark_price' (float): mark price.<br>.
"""
payload: dict[str, str | int] = {"instrument": symbol}
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
if since:
payload["start_time"] = str(since)
if params.get("end_time"):
payload["end_time"] = str(params["end_time"])
if limit:
payload["limit"] = int(limit)
path = get_grvt_endpoint(self.env, "GET_FUNDING")
response: dict = self._auth_and_post(path, payload=payload)
return response
def fetch_ohlcv(
self,
symbol: str,
timeframe="1m",
since: int = 0,
limit: int = 10,
params={},
) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.
Retrieve the ohlc history of a given instrument.
Args:
symbol: The instrument name.
timeframe: The timeframe of the ohlc. See `ccxt_interval_to_grvt_candlestick_interval`.
since: fetch ohlc since this timestamp in nanoseconds.
limit: maximum number of ohlc to fetch.
params: dictionary with parameters. Valid keys:
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.
`end_time` (int): end time in nanoseconds.
`candle_type` (str): candle type. Valid values: 'TRADE', 'MARK', 'INDEX'.
Returns:
dict with field 'result' containing a list of dictionaries, each dict representing a candlestick with fields:<br>
`instrument` - instrument name.<br>
`open_time` - start of interval in nanoseconds.<br>
`close_time` - end of interval in nanoseconds.<br>
`open` - opening price.<br>
`close` - closing price.<br>
`high` - highest price.<br>
`low` - lowest price.<br>
`volume_u` - volume in units.<br>
`volume_q` - volume in quote(USDT).<br>
`trades` - number of trades.<br>
"""
FN = f"{self._clsname} fetch_ohlcv"
payload: dict[str, Any] = self._get_payload_fetch_ohlcv(
symbol, timeframe, since, limit, params
)
self.logger.info(f"{FN} {payload=}")
path = get_grvt_endpoint(self.env, "GET_CANDLESTICK")
return self._auth_and_post(path, payload=payload)
# Vault Management APIs
def fetch_vault_manager_investor_history(self, only_own_investments: bool = False) -> dict:
payload: dict = self._get_fetch_vault_manager_investor_history_payload(
vault_id=self.get_trading_account_id(),
only_own_investments=only_own_investments, # Default to False to fetch all investments
)
path: str = get_grvt_endpoint(self.env, "GET_VAULT_MANAGER_INVESTOR_HISTORY")
# path = "https://trades.grvt.io/full/v1/vault_manager_investor_history"
return self._auth_and_post(path, payload=payload)
def fetch_vault_redemption_queue(self):
payload: dict = self._get_fetch_vault_redemption_queue_payload(
vault_id=self.get_trading_account_id()
)
path: str = get_grvt_endpoint(self.env, "GET_VAULT_REDEMPTION_QUEUE")
# path = "https://trades.grvt.io/full/v1/vault_view_redemption_queue"
return self._auth_and_post(path, payload=payload)
@@ -0,0 +1,586 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
import logging
import time
from datetime import datetime
from decimal import Decimal
from typing import Any, get_args
from .grvt_ccxt_env import GrvtEnv
from .grvt_ccxt_types import (
CandlestickInterval,
CandlestickType,
GrvtInvalidOrder,
GrvtOrderSide,
GrvtOrderType,
Num,
ccxt_interval_to_grvt_candlestick_interval,
)
from .grvt_ccxt_utils import get_kuq_from_symbol, sign_derisk_mm_ratio_request
# COOKIE_REFRESH_INTERVAL_SECS = 60 * 60 # 30 minutes
class GrvtCcxtBase:
"""
GrvtCcxtBase is an abstract class for other Grvt Rest
and WebSocket connectivity classes.
Args:
env: GrvtCcxtBase (DEV, TESTNET, PROD)
logger (logging.Logger, optional). Defaults to None.
parameters: (dict, optional). Dict with trading_account_id, private_key, api_key etc
defaults to empty.
"""
def __init__(
self,
env: GrvtEnv,
logger: logging.Logger | None = None,
parameters: dict = {},
order_book_ccxt_format: bool = False,
):
"""Initialize the GrvtCcxtBase part."""
self.name: str = "GRVT"
self.logger = logger or logging.getLogger(__name__)
self.env: GrvtEnv = env
self._trading_account_id: str | None = parameters.get("trading_account_id")
self._private_key: str = str(parameters.get("private_key", ""))
self._api_key: str = str(parameters.get("api_key", ""))
self._order_book_ccxt_format: bool = order_book_ccxt_format
self._path_return_value_map: dict = {}
self._cookie: dict | None = None
self.markets: dict = {}
self._clsname: str = type(self).__name__
self.logger.info(f"GrvtCcxtBase: {self.env=}, {self._trading_account_id=}")
def describe(self) -> list[str]:
"""Returns the description of the class methods."""
return [
"create_order",
"create_limit_order",
"cancel_all_orders",
"cancel_order",
"fetch_balance",
"fetch_open_orders",
"fetch_order",
"fetch_order_history",
"get_account_summary",
"fetch_account_history",
"fetch_positions",
"fetch_my_trades",
"load_markets",
"fetch_markets",
"fetch_all_markets",
"fetch_market",
"fetch_ticker",
"fetch_mini_ticker",
"fetch_order_book",
"fetch_recent_trades",
"fetch_trades",
"fetch_funding_rate_history",
"fetch_ohlcv",
]
def get_trading_account_id(self) -> str:
"""Returns the trading account id."""
return self._trading_account_id or ""
def is_order_book_ccxt_format(self) -> bool:
"""Returns True if order book should be returned in CCXT format."""
return self._order_book_ccxt_format
def should_refresh_cookie(self) -> bool:
"""
Retuns:
True if this object has API key and the session cookie should be refreshed.
False - otherwise.
"""
if not self._api_key:
return False
time_till_expiration = None
if self._cookie and "expires" in self._cookie:
time_till_expiration = self._cookie["expires"] - time.time()
is_cookie_fresh = time_till_expiration is not None and time_till_expiration > 5
if not is_cookie_fresh:
self.logger.info(
f"cookie should be refreshed {self._cookie=} now={time.time()}"
f" {time_till_expiration=} secs"
)
return not is_cookie_fresh
def get_path_return_value_map(self) -> dict:
"""Returns the path return value map."""
return self._path_return_value_map
def get_endpoint_return_value(self, endpoint: str) -> dict:
"""Returns the return value for the endpoint."""
return self._path_return_value_map.get(endpoint, {})
def was_path_called(self, path: str) -> bool:
"""Returns True if the path was called."""
return path in self._path_return_value_map
# PRIVATE API CALLS
def _check_order_arguments(
self, order_type: GrvtOrderType, side: GrvtOrderSide, amount: Num, price: Num
) -> None:
FN = f"{self._clsname} _check_order_arguments"
if order_type not in get_args(GrvtOrderType):
raise GrvtInvalidOrder(f"{FN}: order_type should be one of {get_args(GrvtOrderType)}")
if side not in get_args(GrvtOrderSide):
raise GrvtInvalidOrder(f"{FN}: side should be one of {get_args(GrvtOrderSide)}")
if order_type == "limit":
if price is None or Decimal(price) <= Decimal("0"):
raise GrvtInvalidOrder(f"{FN}: requires a price argument for a limit order")
elif order_type == "market":
if price:
raise GrvtInvalidOrder(
f"{FN}: should not have a positive price argument for a market order"
)
if not amount or Decimal(amount) < Decimal("0"):
raise GrvtInvalidOrder(f"{FN}: amount should be above 0")
def _check_account_auth(self) -> bool:
if not self.get_trading_account_id():
raise GrvtInvalidOrder(f"{self._clsname}: this action requires a trading_account_id")
return True
def _check_valid_symbol(self, symbol: str) -> bool:
if not self.markets:
raise GrvtInvalidOrder(f"{self._clsname}: markets not loaded")
market = self.markets.get(symbol)
if not market:
raise GrvtInvalidOrder(f"{self._clsname}: {symbol=} not found")
return True
def _get_payload_cancel_all_orders(
self,
params: dict = {},
) -> dict:
"""
Prepares payload for fetch_order_history() method.<br>.
Args:
params: (dict) with possible keys as:.<br>
`kind`: (str) - The kind filter to apply. Defaults to all kinds.<br>
`base`: (str) - The base currency filter. Defaults to all base currencies.<br>
`quote`: (str) - The quote currency filter. Defaults to all quote currencies.<br>
Returns: a dictionary with a payload for Rest API call to cancel all orders.<br>
"""
payload: dict[str, str | int | bool | list] = {
"sub_account_id": str(self.get_trading_account_id())
}
if "kind" in params:
payload["kind"] = [params["kind"]]
if "base" in params:
payload["base"] = [params["base"]]
if "quote" in params:
payload["quote"] = [params["quote"]]
return payload
def _get_payload_fetch_markets(self, params: dict) -> dict:
payload: dict[str, str | int | bool | list] = {}
if params.get("kind"):
payload["kind"] = [params.get("kind")]
if params.get("base"):
payload["base"] = [params.get("base")]
if params.get("quote"):
payload["quote"] = [params.get("quote")]
payload["limit"] = int(params.get("limit", 1_000))
payload["is_active"] = bool(params.get("is_active", True))
return payload
def _get_payload_fetch_my_trades(
self,
symbol: str | None = None,
since: int | None = None,
limit: int | None = None,
params: dict = {},
) -> dict:
"""
Prepares payload for fetch_my_trades() method.<br>.
Args:
symbol: get trades for this symbol only.<br>
since: fetch trades since this timestamp in nanoseconds.<br>
limit: maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Default: 'USDT'.<br>
`end_time` (int): fetch trades until this timestamp in nanoseconds.<br>
Returns:
a dictionary with a payload for Rest API call to fetch trades.<br>
"""
payload: dict[str, str | int | list] = {
"sub_account_id": str(self.get_trading_account_id())
}
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
if symbol:
payload["instrument"] = symbol
else:
if "kind" in params:
payload["kind"] = [params["kind"]]
if "base" in params:
payload["base"] = [params["base"]]
if "quote" in params:
payload["quote"] = [params["quote"]]
if since:
payload["start_time"] = str(since)
if params.get("end_time"):
payload["end_time"] = str(params["end_time"])
if limit:
payload["limit"] = int(limit)
return payload
def _get_payload_fetch_trades(
self,
symbol: str,
since: int | None = None,
limit: int = 1_000,
params: dict = {},
) -> dict:
"""
Prepares payload for fetch_trades() method.<br>.
Args:
symbol: get trades for this symbol only.<br>
since: fetch trades since this timestamp in nanoseconds.<br>
limit: maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Default: 'USDT'.<br>
Returns:
a dictionary with a payload for Rest API call to fetch trades.<br>
"""
payload: dict[str, str | int] = {
"sub_account_id": str(self.get_trading_account_id()),
"instrument": symbol,
}
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
if since:
payload["start_time"] = str(since)
if params.get("end_time"):
payload["end_time"] = str(params["end_time"])
payload["limit"] = limit
return payload
def _get_payload_fetch_account_history(
self,
# since: int | None = None,
limit: int = 500,
params: dict = {},
) -> dict:
"""
Prepares payload for fetch_account_history() method.<br>.
Args:
limit: maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`start_time` (int): fetch orders since this timestamp in nanoseconds.<br>
`end_time` (int): fetch orders until this timestamp in nanoseconds.<br>
`cursor` (int):cursor for the pagination. If cursor is present then we ignore
`start_time` and `end_time`.<br>
Returns:
a dictionary with a payload for Rest API call to fetch account history.<br>
"""
payload: dict[str, str | int] = {"sub_account_id": str(self.get_trading_account_id())}
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
start_time = params.get("start_time")
end_time = params.get("end_time")
if start_time:
payload["start_time"] = str(start_time)
if end_time:
payload["end_time"] = str(end_time)
payload["limit"] = limit | 500
return payload
def _get_payload_fetch_positions(self, symbols: list[str] = [], params={}) -> dict:
"""
Prepares payload for fetch_positions() method.<br>.
Args:
symbols: list(str) get positions for these symbols only.<br>
Returns: a dictionary with a payload for Rest API call to fetch positions.<br>
"""
payload: dict[str, str | int | bool | list] = {
"sub_account_id": str(self.get_trading_account_id())
}
if symbols:
ks, us, qs = [], [], []
for symbol in symbols:
try:
k, u, q = get_kuq_from_symbol(symbol)
ks.append(k)
us.append(u)
qs.append(q)
except Exception as e:
raise GrvtInvalidOrder(f"Invalid symbol {symbol} in fetch_positions {e}")
payload["kind"] = list(set(ks))
payload["base"] = list(set(us))
payload["quote"] = list(set(qs))
else:
if "kind" in params:
payload["kind"] = [params["kind"]]
if "base" in params:
payload["base"] = [params["base"]]
if "quote" in params:
payload["quote"] = [params["quote"]]
return payload
def _get_payload_fetch_order_history(
self,
params: dict,
) -> dict:
"""
Prepares payload for fetch_order_history() method.<br>.
Args:
params: (dict) with possible keys as:.<br>
`kind`: (str) - The kind filter to apply. Defaults to all kinds.<br>
`base`: (str) - The base currency filter. Defaults to all base currencies.<br>
`quote`: (str) - The quote currency filter. Defaults to all quote currencies.<br>
`expiration`: (int) The expiration time in nanoseconds. Defaults to all.<br>
`strike_price`: (str) The strike price to apply. Defaults to all strike prices.<br>
`limit`: (int) The limit to query for. Defaults to 500; Max 1000.<br>
`cursor`: (str) The cursor to use for pagination. If nil, return the first page.<br>
Returns: a dictionary with a payload for Rest API call to fetch order history.<br>
"""
payload: dict[str, str | int | bool | list] = {
"sub_account_id": str(self.get_trading_account_id())
}
if "limit" in params:
payload["limit"] = params["limit"]
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
if "kind" in params:
payload["kind"] = [params["kind"]]
if "base" in params:
payload["base"] = [params["base"]]
if "quote" in params:
payload["quote"] = [params["quote"]]
if "expiration" in params:
payload["expiration"] = [params["expiration"]]
if "strike_price" in params:
payload["strike_price"] = [params["strike_price"]]
return payload
def _get_payload_fetch_open_orders(
self,
symbol: str | None = None,
params: dict = {},
) -> dict:
"""
Prepares payload for fetch_order_history() method.<br>.
Args:
params: (dict) with possible keys as:.<br>
`kind`: (str) - The kind filter to apply. Defaults to all kinds.<br>
`base`: (str) - The base currency filter. Defaults to all base currencies.<br>
`quote`: (str) - The quote currency filter. Defaults to all quote currencies.<br>
Returns: a dictionary with a payload for Rest API call to fetch order history.<br>
"""
payload: dict[str, str | int | bool | list] = {
"sub_account_id": str(self.get_trading_account_id())
}
if symbol:
try:
k, u, q = get_kuq_from_symbol(symbol)
payload["kind"] = [k]
payload["base"] = [u]
payload["quote"] = [q]
except Exception as e:
raise GrvtInvalidOrder(f"Invalid symbol {symbol} in fetch_open_orders {e}")
else:
if "kind" in params:
payload["kind"] = [params["kind"]]
if "base" in params:
payload["base"] = [params["base"]]
if "quote" in params:
payload["quote"] = [params["quote"]]
return payload
def _get_payload_fetch_ohlcv(
self,
symbol: str,
timeframe: str,
since: int,
limit: int,
params={},
) -> dict:
"""
Prepares payload for fetch_ohlcv() method.<br>.
Args:
symbol: The instrument name.<br>
timeframe: The timeframe of the ohlc.
See `ccxt_interval_to_grvt_candlestick_interval`.<br>
since: fetch ohlc since this timestamp in nanoseconds.<br>
limit: maximum number of ohlc to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`end_time` (int): end time in nanoseconds.<br>
`candle_type` (str): candle type. Valid values: 'TRADE', 'MARK', 'INDEX'.<br>
Returns: a dictionary with a payload for Rest API call to fetch_ohlcv.<br>
See [Candlestick] (https://api-docs.grvt.io/market_data_api/#candlestick_1)
for more details.<br>
"""
if timeframe not in ccxt_interval_to_grvt_candlestick_interval:
raise ValueError(f"Invalid timeframe {timeframe}")
interval: CandlestickInterval = ccxt_interval_to_grvt_candlestick_interval[timeframe]
payload: dict[str, str | int | bool | list] = {"instrument": symbol}
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
if interval:
payload["interval"] = interval.value
candle_type = CandlestickType.TRADE
if "candle_type" in params:
candle_type = CandlestickType[params["candle_type"]]
payload["type"] = candle_type.value
if since:
payload["start_time"] = str(since)
if "end_time" in params:
payload["end_time"] = str(params["end_time"])
if limit:
payload["limit"] = int(limit)
return payload
def _get_balances_from_account_summary(self, account_summary: dict) -> dict:
balances: dict = {}
balances["info"] = account_summary.get("spot_balances", [])
balances["timestamp"] = int(int(account_summary.get("event_time", 0)) / 1_000_000)
balances["datetime"] = (
datetime.fromtimestamp(balances["timestamp"] / 1_000).strftime("%Y-%m-%dT%H:%M:%S.%f")[
:-3
]
+ "Z"
)
balances["total"] = {}
balances["free"] = {}
balances["used"] = {}
for currency_balance in account_summary.get("spot_balances", []):
if not currency_balance or not isinstance(currency_balance, dict):
continue
currency: str = currency_balance.get("currency", "")
if not currency:
continue
balances[currency] = {"total": currency_balance.get("balance", "0.0")}
balances["total"][currency] = balances[currency]["total"]
if currency == "USDT":
balances[currency]["free"] = account_summary.get("available_balance", "0.0")
balances[currency]["used"] = str(
Decimal(balances[currency]["total"]) - Decimal(balances[currency]["free"])
)
else:
balances[currency]["free"] = balances[currency]["total"]
balances[currency]["used"] = "0.0"
balances["free"][currency] = balances[currency]["free"]
balances["used"][currency] = balances[currency]["used"]
return balances
def _get_set_derisk_mm_ratio_payload(
self,
ratio: str,
) -> dict[str, str | dict]:
"""
Returns a payload for setting the derisking market making ratio.
"""
payload: dict[str, str | dict] = {
"sub_account_id": self.get_trading_account_id(),
"ratio": str(ratio),
}
signature: dict = sign_derisk_mm_ratio_request(
self.env, int(self.get_trading_account_id()), str(ratio), self._private_key
)
payload["signature"] = signature
return payload
def convert_grvt_ob_to_ccxt(self, order_book: dict) -> dict:
"""
Converts GRVT-specific order book format to CCXT format.
"""
ob_time_ms: int = int(order_book["event_time"]) // 1_000_000
ccxt_ob = {
"symbol": order_book["instrument"],
"bids": [],
"asks": [],
"timestamp": ob_time_ms,
"datetime": datetime.fromtimestamp(ob_time_ms / 1_000).strftime("%Y-%m-%dT%H:%M:%S.%f")[
:-3
]
+ "Z",
"nonce": int(order_book["event_time"]),
}
ccxt_ob["bids"] = [[bid["price"], bid["size"]] for bid in order_book["bids"]]
ccxt_ob["asks"] = [[ask["price"], ask["size"]] for ask in order_book["asks"]]
return ccxt_ob
# Vault Management APIs
def _get_fetch_vault_manager_investor_history_payload(
self,
vault_id: str,
only_own_investments: bool = False,
) -> dict:
"""
Prepares payload for fetch_vault_manager_investor_history() method.<br>.
Args:
vault_id: The vault id to fetch history for.<br>
only_own_investments: If True, fetch only investments by the manager.<br>
Returns:
A dictionary with a payload for Rest API call to fetch vault investor history.
"""
payload: dict[str, str | bool] = {
"vault_id": vault_id,
"only_own_investments": only_own_investments,
}
return payload
def _get_fetch_vault_redemption_queue_payload(
self,
vault_id: str,
) -> dict:
"""
Prepares payload for fetch_vault_redemption_queue() method.<br>.
Args:
vault_id: The vault id to fetch redemption queue for.<br>
Returns:
A dictionary with a payload for Rest API call to fetch vault redemption queue.
"""
return {"vault_id": vault_id}
@@ -0,0 +1,195 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
import os
from enum import Enum
class GrvtEnv(str, Enum):
PROD = "prod"
TESTNET = "testnet"
STAGING = "staging"
DEV = "dev"
# GrvtEndpointType defines the root path for a family of endpoints
class GrvtEndpointType(str, Enum):
EDGE = "edge"
TRADE_DATA = "tdg"
MARKET_DATA = "mdg"
class GrvtWSEndpointType(str, Enum):
TRADE_DATA = "tdg"
MARKET_DATA = "mdg"
TRADE_DATA_RPC_FULL = "tdg_rpc_full"
MARKET_DATA_RPC_FULL = "mdg_rpc_full"
END_POINT_VERSION = os.getenv("GRVT_END_POINT_VERSION", "v1")
def get_grvt_endpoint_domains(env_name: str) -> dict[GrvtEndpointType, str]:
if env_name == GrvtEnv.PROD.value:
return {
GrvtEndpointType.EDGE: "https://edge.grvt.io",
GrvtEndpointType.TRADE_DATA: "https://trades.grvt.io",
GrvtEndpointType.MARKET_DATA: "https://market-data.grvt.io",
}
if env_name == GrvtEnv.TESTNET.value:
return {
GrvtEndpointType.EDGE: f"https://edge.{env_name}.grvt.io",
GrvtEndpointType.TRADE_DATA: f"https://trades.{env_name}.grvt.io",
GrvtEndpointType.MARKET_DATA: f"https://market-data.{env_name}.grvt.io",
}
if env_name == GrvtEnv.STAGING.value:
return {
GrvtEndpointType.EDGE: f"https://edge.{env_name}.gravitymarkets.io",
GrvtEndpointType.TRADE_DATA: f"https://trades.{env_name}.gravitymarkets.io",
GrvtEndpointType.MARKET_DATA: f"https://market-data.{env_name}.gravitymarkets.io",
}
if env_name == GrvtEnv.DEV.value:
return {
GrvtEndpointType.EDGE: f"https://edge.{env_name}.gravitymarkets.io",
GrvtEndpointType.TRADE_DATA: f"https://trades.{env_name}.gravitymarkets.io",
GrvtEndpointType.MARKET_DATA: f"https://market-data.{env_name}.gravitymarkets.io",
}
return {}
def get_grvt_ws_endpoint(
env: str,
endpoint_type: GrvtWSEndpointType,
) -> str:
"""Returns string pointing to WS endpoint for given environment and endpoint type."""
if env == GrvtEnv.PROD.value:
return {
GrvtWSEndpointType.TRADE_DATA: "wss://trades.grvt.io/ws",
GrvtWSEndpointType.MARKET_DATA: "wss://market-data.grvt.io/ws",
GrvtWSEndpointType.TRADE_DATA_RPC_FULL: "wss://trades.grvt.io/ws/full",
GrvtWSEndpointType.MARKET_DATA_RPC_FULL: "wss://market-data.grvt.io/ws/full",
}.get(endpoint_type, "")
if env == GrvtEnv.TESTNET.value:
return {
GrvtWSEndpointType.TRADE_DATA: f"wss://trades.{env}.grvt.io/ws",
GrvtWSEndpointType.MARKET_DATA: f"wss://market-data.{env}.grvt.io/ws",
GrvtWSEndpointType.TRADE_DATA_RPC_FULL: f"wss://trades.{env}.grvt.io/ws/full",
GrvtWSEndpointType.MARKET_DATA_RPC_FULL: f"wss://market-data.{env}.grvt.io/ws/full",
}.get(endpoint_type, "")
if env == GrvtEnv.STAGING.value:
return {
GrvtWSEndpointType.TRADE_DATA: f"wss://trades.{env}.gravitymarkets.io/ws",
GrvtWSEndpointType.MARKET_DATA: f"wss://market-data.{env}.gravitymarkets.io/ws",
GrvtWSEndpointType.TRADE_DATA_RPC_FULL: f"wss://trades.{env}.gravitymarkets.io/ws/full",
GrvtWSEndpointType.MARKET_DATA_RPC_FULL: f"wss://market-data.{env}.gravitymarkets.io/ws/full",
}.get(endpoint_type, "")
if env == GrvtEnv.DEV.value:
return {
GrvtWSEndpointType.TRADE_DATA: f"wss://trades.{env}.gravitymarkets.io/ws",
GrvtWSEndpointType.MARKET_DATA: f"wss://market-data.{env}.gravitymarkets.io/ws",
GrvtWSEndpointType.TRADE_DATA_RPC_FULL: f"wss://trades.{env}.gravitymarkets.io/ws/full",
GrvtWSEndpointType.MARKET_DATA_RPC_FULL: f"wss://market-data.{env}.gravitymarkets.io/ws/full",
}.get(endpoint_type, "")
return ""
# Mapping of WS stream names to DEFAULT endpoint types
GRVT_WS_STREAMS = {
# ******* Market Data ********
"mini.s": GrvtWSEndpointType.MARKET_DATA,
"mini.d": GrvtWSEndpointType.MARKET_DATA,
"ticker.s": GrvtWSEndpointType.MARKET_DATA,
"ticker.d": GrvtWSEndpointType.MARKET_DATA,
"book.s": GrvtWSEndpointType.MARKET_DATA,
"book.d": GrvtWSEndpointType.MARKET_DATA,
"trade": GrvtWSEndpointType.MARKET_DATA,
"candle": GrvtWSEndpointType.MARKET_DATA,
# ******* Trade Data ********
"order": GrvtWSEndpointType.TRADE_DATA,
"state": GrvtWSEndpointType.TRADE_DATA,
"cancel": GrvtWSEndpointType.TRADE_DATA,
"position": GrvtWSEndpointType.TRADE_DATA,
"fill": GrvtWSEndpointType.TRADE_DATA,
"transfer": GrvtWSEndpointType.TRADE_DATA,
"deposit": GrvtWSEndpointType.TRADE_DATA,
"withdrawal": GrvtWSEndpointType.TRADE_DATA,
}
def is_trading_ws_endpoint(end_point_type: GrvtWSEndpointType) -> bool:
return end_point_type in [
GrvtWSEndpointType.TRADE_DATA,
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
]
# "wss://market-data.testnet.grvt.io/ws"
# wss://trades.testnet.grvt.io/ws
# GRVT_ENDPOINTS defines the endpoint paths grouped by endpoint type
GRVT_ENDPOINTS = {
GrvtEndpointType.EDGE: {
"GRAPHQL": "query",
"AUTH": "auth/api_key/login",
},
GrvtEndpointType.TRADE_DATA: {
"CREATE_ORDER": f"full/{END_POINT_VERSION}/create_order",
"CANCEL_ALL_ORDERS": f"full/{END_POINT_VERSION}/cancel_all_orders",
"CANCEL_ORDER": f"full/{END_POINT_VERSION}/cancel_order",
"GET_OPEN_ORDERS": f"full/{END_POINT_VERSION}/open_orders",
"GET_ACCOUNT_SUMMARY": f"full/{END_POINT_VERSION}/account_summary",
"GET_FUNDING_ACCOUNT_SUMMARY": f"full/{END_POINT_VERSION}/funding_account_summary",
"GET_AGGREGATED_ACCOUNT_SUMMARY": f"full/{END_POINT_VERSION}/aggregated_account_summary",
"GET_ACCOUNT_HISTORY": f"full/{END_POINT_VERSION}/account_history",
"GET_POSITIONS": f"full/{END_POINT_VERSION}/positions",
"GET_ORDER": f"full/{END_POINT_VERSION}/order",
"GET_ORDER_HISTORY": f"full/{END_POINT_VERSION}/order_history",
"GET_FILL_HISTORY": f"full/{END_POINT_VERSION}/fill_history",
"SET_DERISK_MM_RATIO": f"full/{END_POINT_VERSION}/set_derisk_mm_ratio",
"GET_VAULT_MANAGER_INVESTOR_HISTORY": f"full/{END_POINT_VERSION}/vault_manager_investor_history",
"GET_VAULT_REDEMPTION_QUEUE": f"full/{END_POINT_VERSION}/vault_view_redemption_queue",
},
GrvtEndpointType.MARKET_DATA: {
"GET_ALL_INSTRUMENTS": f"full/{END_POINT_VERSION}/all_instruments",
"GET_INSTRUMENTS": f"full/{END_POINT_VERSION}/instruments",
"GET_INSTRUMENT": f"full/{END_POINT_VERSION}/instrument",
"GET_TICKER": f"full/{END_POINT_VERSION}/ticker",
"GET_MINI_TICKER": f"full/{END_POINT_VERSION}/mini",
"GET_ORDER_BOOK": f"full/{END_POINT_VERSION}/book",
"GET_TRADES": f"full/{END_POINT_VERSION}/trade",
"GET_TRADE_HISTORY": f"full/{END_POINT_VERSION}/trade_history",
"GET_FUNDING": f"full/{END_POINT_VERSION}/funding",
"GET_CANDLESTICK": f"full/{END_POINT_VERSION}/kline",
},
}
def get_grvt_endpoint(environment: GrvtEnv, end_point: str) -> str:
# if end_point == "GET_ALL_INSTRUMENTS":
# return "https://market-data.testnet.grvt.io/full/v1/instruments"
endpoint_domains = get_grvt_endpoint_domains(environment.value)
for endpoints_type, endpoints in GRVT_ENDPOINTS.items():
if end_point in endpoints:
return f"{endpoint_domains[endpoints_type]}/{endpoints[end_point]}"
return ""
def get_all_grvt_endpoints(environment: GrvtEnv) -> dict[str, str]:
endpoint_domains = get_grvt_endpoint_domains(environment.value)
endpoints = {}
for endpoints_type, endpoints_map in GRVT_ENDPOINTS.items():
for endpoint, path in endpoints_map.items():
endpoints[endpoint] = f"{endpoint_domains[endpoints_type]}/{path}"
return endpoints
CHAIN_IDS = {
GrvtEnv.DEV.value: 327,
GrvtEnv.STAGING.value: 327,
GrvtEnv.TESTNET.value: 326,
GrvtEnv.PROD.value: 325,
}
########################################################
@@ -0,0 +1,32 @@
import logging
import os
import sys
from datetime import datetime
LOG_FILE = os.getenv("LOG_FILE", "FALSE").upper()
GRVT_ENV = os.getenv("GRVT_ENV")
if LOG_FILE == "TRUE":
LOG_TIMESTAMP = datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
fn = sys.argv[0].split("/")[-1]
fn_base = fn.split(".")[0]
if GRVT_ENV:
filename = f"logs/{fn_base}_{GRVT_ENV}_{LOG_TIMESTAMP}.log"
else:
filename = f"logs/{fn_base}_{LOG_TIMESTAMP}.log"
os.makedirs("logs", exist_ok=True)
logging.basicConfig(
filename=filename,
level=os.getenv("LOGGING_LEVEL", "INFO"),
format="%(asctime)s.%(msecs)03d | %(levelname)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.info(f"Using FILE logger {LOG_FILE=}")
else:
logging.basicConfig(
level=os.getenv("LOGGING_LEVEL", "INFO"),
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
logger.info(f"Using CONSOLE logger {LOG_FILE=}")
@@ -0,0 +1,841 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
import asyncio
import json
import logging
from typing import Literal
import aiohttp
from .grvt_ccxt_base import GrvtCcxtBase
# import requests
# from env import ENDPOINTS
from .grvt_ccxt_env import GrvtEnv, get_grvt_endpoint
from .grvt_ccxt_types import (
Amount,
GrvtInstrumentKind,
GrvtInvalidOrder,
GrvtOrderSide,
GrvtOrderType,
Num,
)
from .grvt_ccxt_utils import (
EnumEncoder,
GrvtOrder,
get_cookie_with_expiration,
get_cookie_with_expiration_async,
get_grvt_order,
get_order_payload,
)
class GrvtCcxtPro(GrvtCcxtBase):
"""
GrvtCcxtPro class to interact with Grvt Rest API and WebSockets in asynchronous mode.
Args:
env: GrvtCcxtPro (DEV, TESTNET, PROD)
parameters: dict with trading_account_id, private_key, api_key etc
Examples:
>>> from grvt_api_pro import GrvtCcxtPro
>>> from grvt_env import GrvtEnv
>>> grvt = GrvtCcxtPro(env=GrvtEnv.TESTNET)
>>> await grvt.fetch_markets()
"""
def __init__(
self,
env: GrvtEnv,
logger: logging.Logger | None = None,
parameters: dict = {},
order_book_ccxt_format: bool = False,
):
"""Initialize the GrvtCcxt instance."""
super().__init__(env, logger, parameters, order_book_ccxt_format)
self._clsname: str = type(self).__name__
self._session = aiohttp.ClientSession(headers={"Content-Type": "application/json"})
# Force sync call to get cookie here
self._cookie = get_cookie_with_expiration(
get_grvt_endpoint(self.env, "AUTH"), self._api_key
)
self.update_session_with_cookie()
def __del__(self):
"""Close the aiohttp session when the instance is deleted."""
self.logger.info(f"{self._clsname} __del__() called")
if self._session:
self.logger.info(f"{self._clsname} closing session")
asyncio.get_running_loop().create_task(self._session.close())
def update_session_with_cookie(self) -> None:
if self._cookie:
self._session.cookie_jar.update_cookies({"gravity": self._cookie["gravity"]})
if self._cookie["X-Grvt-Account-Id"]:
self._session.headers.update(
{"X-Grvt-Account-Id": self._cookie["X-Grvt-Account-Id"]}
)
self.logger.info(
f"update_session_with_cookie {self._cookie=} {self._session.cookie_jar=}"
f" {self._session.headers=}"
)
async def refresh_cookie(self) -> dict | None:
"""Refresh the session cookie."""
if not self.should_refresh_cookie():
return self._cookie
path: str = get_grvt_endpoint(self.env, "AUTH")
self._cookie = await get_cookie_with_expiration_async(path, self._api_key)
self._path_return_value_map[path] = self._cookie
self.update_session_with_cookie()
return self._cookie
# PRIVATE API CALLS
async def _auth_and_post(self, path: str, payload: dict) -> dict:
FN = f"{self._clsname} _auth_and_post {path=}"
MAX_LEN_TO_LOG = 1280
response: dict = {}
if not path:
self.logger.warning(f"{FN} Invalid path {path=} {payload=}")
raise GrvtInvalidOrder(f"{FN} Invalid path {path=} {payload=}")
# Always see if need to referesh cookie before sending a request
await self.refresh_cookie()
payload_json = json.dumps(payload, cls=EnumEncoder)
self.logger.info(f"{FN} {payload=}\n{payload_json=}")
return_text: str = ""
async with self._session.post(
url=path,
data=payload_json,
headers={"Content-Type": "application/json"},
timeout=5,
) as return_value:
return_text: str = ""
try:
return_text = await return_value.text()
response = await return_value.json(content_type="application/json")
except Exception as err:
self.logger.warning(
f"{FN} Unable to parse {return_value=} as "
f" json(content_type='application/json'). {err=}"
)
if not return_value.ok:
self.logger.warning(f"{FN} {payload_json=}\n{return_value=}\n{response=}")
else:
if len(return_text) > MAX_LEN_TO_LOG:
self.logger.debug(f"{FN} OK {return_value=} response={response}")
self.logger.info(f"{FN} OK {return_value=} response=**TOO LONG**")
else:
self.logger.info(f"{FN} OK {return_value=} response={response}")
self._path_return_value_map[path] = response
return response or {}
async def _create_grvt_order(self, order: GrvtOrder) -> dict:
"""
Send a GrvtOrder object to the exchange.
:param order: The GrvtOrder object.
Return: dictionary representing the order response.
"""
FN = f"{self._clsname} _create_grvt_order cloid:{order.metadata.client_order_id}"
order_payload = get_order_payload(
order,
private_key=self._private_key,
env=self.env,
instruments=self.markets,
)
path = get_grvt_endpoint(self.env, "CREATE_ORDER")
self.logger.info(f"{FN} {path=} {order_payload=}")
response: dict = await self._auth_and_post(path, payload=order_payload)
if response.get("result") is None:
self.logger.error(f"Error creating order, {response}")
return {}
self.logger.info(
f"{FN} Order created:"
f"{response.get('result', {}).get('metadata', {}).get('client_order_id')}"
)
return response.get("result", {})
def _get_order_with_validations(
self,
symbol: str,
order_type: GrvtOrderType,
side: GrvtOrderSide,
amount: Amount,
price: Num = None,
params: dict = {},
) -> GrvtOrder:
self._check_account_auth()
self._check_valid_symbol(symbol)
# Validate order fields
self._check_order_arguments(order_type, side, amount, price)
# create GrvtOrder object
order_duration_secs = params.get("order_duration_secs", 24 * 60 * 60)
return get_grvt_order(
sub_account_id=self.get_trading_account_id(),
symbol=symbol,
order_type=order_type,
side=side,
amount=amount,
limit_price=price,
order_duration_secs=order_duration_secs,
params=params,
)
async def create_order(
self,
symbol: str,
order_type: GrvtOrderType,
side: GrvtOrderSide,
amount: Amount,
price: Num = None,
params={},
) -> dict:
"""Ccxt compliant signature."""
order = self._get_order_with_validations(symbol, order_type, side, amount, price, params)
return await self._create_grvt_order(order)
async def create_limit_order(
self,
symbol: str,
side: GrvtOrderSide,
amount: Amount,
price: Num = None,
params={},
) -> dict:
return await self.create_order(symbol, "limit", side, amount, price, params)
async def cancel_all_orders(
self,
params: dict = {},
) -> bool:
"""
Ccxt compliant signature BUT lacks symbol
Cancel all orders for a sub-account.
params: dictionary with parameters. Valid keys:<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Defaults to all.<br>
"""
self._check_account_auth()
FN = f"{self._clsname} cancel_all_orders"
payload: dict = self._get_payload_cancel_all_orders(params)
path = get_grvt_endpoint(self.env, "CANCEL_ALL_ORDERS")
response: dict = await self._auth_and_post(path, payload=payload)
cancel_ack = response.get("result", {}).get("ack")
if not cancel_ack:
self.logger.warning(f"{FN} failed to cancel orders: {response=}")
return False
self.logger.info(f"{FN} Cancelled {response=}")
return True
async def cancel_order(
self,
id: str | None = None,
symbol: str | None = None,
params: dict = {},
) -> bool:
"""
Ccxt compliant signature
Cancel specific order for the account.<br>
Private call requires authorization.<br>
See [Cancel order](https://api-docs.grvt.io/trading_api/#cancel-order)
for details.<br>.
Args:
id (str): exchange assigned order ID<br>
symbol (str): trading symbol<br>
params:
* client_order_id (str): client assigned order ID<br>
* time_to_live_ms (str): lifetime of cancel requiest in millisecs<br>
Returns:
True if cancel request was acked by exchange. False otherwise.<br>
"""
FN = f"{self._clsname} cancel_order"
self._check_account_auth()
# Prepare payload
payload: dict = {
"sub_account_id": str(self._trading_account_id),
}
if id:
payload["order_id"] = str(id)
elif "client_order_id" in params:
payload["client_order_id"] = str(params["client_order_id"])
else:
raise GrvtInvalidOrder(f"{FN} requires either order_id or client_order_id")
if "time_to_live_ms" in params:
payload["time_to_live_ms"] = str(params["time_to_live_ms"])
# Send cancel request
path = get_grvt_endpoint(self.env, "CANCEL_ORDER")
self.logger.info(
f"{FN} Send cancel {payload=} for trading_account_id={self._trading_account_id}"
)
response: dict = await self._auth_and_post(path, payload)
cancel_ack = response.get("result", {}).get("ack")
if not cancel_ack:
self.logger.warning(f"{FN} failed to cancel order: {response=}")
return False
self.logger.info(f"{FN} Cancelled {response=}")
return True
async def set_derisk_mm_ratio(self, ratio: str) -> bool:
"""
Set the Derisk to Maintenance marginb ratio for the account.
Private call requires authorization.
See [Set Derisk M M ratio](https://api-docs.grvt.io/trading_api/#set-derisk-m-m-ratio)
for details.
Args:
ratio (Amount): The new derisking market making ratio.
Returns:
True if the request was acknowledged by the exchange. False otherwise.
"""
FN = f"{self._clsname} set_derisk_mm_ratio"
self._check_account_auth()
payload: dict[str, str | dict] = self._get_set_derisk_mm_ratio_payload(str(ratio))
path = get_grvt_endpoint(self.env, "SET_DERISK_MM_RATIO")
self.logger.info(
f"{FN} Send {payload=} for trading_account_id={self.get_trading_account_id()}"
)
response: dict = await self._auth_and_post(path, payload)
self.logger.info(f"{FN} Set derisk_mm_ratio {response=}")
return True
async def fetch_open_orders(
self,
symbol: str | None = None,
since: int | None = None,
limit: int | None = None,
params: dict = {},
) -> list[dict]:
"""
Ccxt compliant signature
Fetch open orders for the account.<br>
Private call requires authorization.<br>
See [Open orders](https://api-docs.grvt.io/trading_api/#open-orders)
for details.<br>.
Args:
symbol: get orders for this symbol only.<br>
since: ccxt-compliant argument, NOT SUPPORTED.<br>
limit: ccxt-compliant argument, NOT SUPPORTED.<br>
params: dictionary with parameters. Valid keys:<br>
`kind` (str): instrument kind. Valid values are 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch orders
for all base currencies.<br>
`quote` (str): quote currency. Defaults to all.<br>
Returns:
a list of dictionaries, each dict represent an order.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_open_orders(symbol, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_OPEN_ORDERS")
response: dict = await self._auth_and_post(path, payload)
open_orders: list = response.get("result", [])
if symbol:
open_orders = [
o for o in open_orders if o.get("legs") and o["legs"][0].get("instrument") == symbol
]
return open_orders
async def fetch_order(
self,
id: str | None = None,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature
Private call requires authorization.<br>
See [Get Order](https://api-docs.grvt.io/trading_api/#get-order)
for details.<br>.
Get Order status by either order_id or client_order_id
Args:
id: (str) order_id to fetch.<br>
symbol: (str) NOT SUPPRTED.<br>
params: dictionary with parameters. Valid keys:<br>
`client_order_id` (int): client assigned order ID.<br>
Returns:
dict with order details or {} if order was NOT found.<br>
"""
FN = f"{self._clsname} fetch_order"
self._check_account_auth()
payload = {
"sub_account_id": str(self._trading_account_id),
}
if id:
payload["order_id"] = id
elif "client_order_id" in params:
payload["client_order_id"] = str(params["client_order_id"])
else:
raise GrvtInvalidOrder(f"{FN} requires either order_id or params['client_order_id']")
path = get_grvt_endpoint(self.env, "GET_ORDER")
response: dict = await self._auth_and_post(path, payload)
return response
async def fetch_order_history(self, params: dict = {}) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.<br>
Get Order history of orders by kind/base/quote.<br>
Private call requires authorization.<br>
See [Order History](https://api-docs.grvt.io/trading_api/#order-history)
for details.<br>
Args:
params: dictionary with parameters. Valid keys:<br>
`kind`: (str) - The kind filter to apply. Defaults to all kinds.<br>
`base`: (str) - The base currency filter. Defaults to all base currencies.<br>
`quote`: (str) - The quote currency filter. Defaults to all quote currencies.<br>
`expiration`: (int) The expiration time in nanoseconds. Defaults to all.<br>
`strike_price`: (str) The strike price to apply. Defaults to all strike prices.<br>
`limit`: (int) The limit to query for. Defaults to 500; Max 1000.<br>
`cursor`: (str) The cursor to use for pagination. If nil, return the first page.<br>
Return: a dictionary with keys:
`total` : total number of account history snapshots.<br>
`next` : cursor for the next page.<br>
`result` : a list of dictionaries, each dict represent an order state.<br>.
"""
self._check_account_auth()
payload = self._get_payload_fetch_order_history(params)
path = get_grvt_endpoint(self.env, "GET_ORDER_HISTORY")
response: dict = await self._auth_and_post(path, payload)
return response
async def get_account_summary(
self, type: Literal["sub-account", "funding", "aggregated"] = "sub-account"
) -> dict:
"""
Return: The account summary.
Private call requires authorization.<br>
See [Account Summary](https://api-docs.grvt.io/trading_api/#account_summary)
for details.<br>
Returns: dictionary with account data.<br>.
"""
FN = f"{self._clsname} get_account_summary {type=}"
self._check_account_auth()
payload = {}
if type == "sub-account":
path = get_grvt_endpoint(self.env, "GET_ACCOUNT_SUMMARY")
payload = {"sub_account_id": str(self._trading_account_id)}
elif type == "funding":
path = get_grvt_endpoint(self.env, "GET_FUNDING_ACCOUNT_SUMMARY")
elif type == "aggregated":
path = get_grvt_endpoint(self.env, "GET_AGGREGATED_ACCOUNT_SUMMARY")
else:
raise GrvtInvalidOrder(f"{FN} Invalid account summary type {type}")
response: dict = await self._auth_and_post(path, payload=payload)
sub_account: dict = response.get("result", {})
if not sub_account:
self.logger.info(f"{FN} No account summary for {path=} {payload=}")
return sub_account
async def fetch_balance(
self, type: Literal["sub-account", "funding", "aggregated"] = "sub-account"
) -> dict:
"""
Ccxt compliant signature
Fetch balances for the account.<br>
Private call requires authorization.<br>
See [Account Summary](https://api-docs.grvt.io/trading_api/#sub-account_summary)
for details.<br>.
Args:
type: (str) - The type of account to fetch balances for. Defaults to 'sub-account'.
Valid values: 'sub-account', 'funding', 'aggregated'.
Returns: dictionary with ccxt-compliant balance data https://docs.ccxt.com/#/README?id=account-balance.<br>.
"""
account_summary: dict = await self.get_account_summary(type)
return self._get_balances_from_account_summary(account_summary)
async def fetch_account_history(self, params: dict = {}, limit: int = 500) -> dict:
"""
HISTORICAL data.<br>
Get account history.<br>
Private call requires authorization.<br>
See [Account History](https://api-docs.grvt.io/trading_api/#account-history)
for details.<br>.
Args:
limit: maximum number of account snapshots per page to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`start_time` (int): fetch orders since this timestamp in nanoseconds.<br>
`end_time` (int): fetch orders until this timestamp in nanoseconds.<br>
`cursor` (str): cursor for the pagination. If cursor is present then we ignore
`start_time` and `end_time`.<br>
Returns:
a dictionary with keys:
`total` : total number of account history snapshots.<br>
`next` : cursor for the next page.<br>
`result` : list of account history snapshots.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_account_history(limit, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_ACCOUNT_HISTORY")
response: dict = await self._auth_and_post(path, payload=payload)
return response
async def fetch_positions(self, symbols: list[str] = [], params={}) -> list[dict]:
"""
Ccxt compliant signature
Fetch positions for the account.<br>
Private call requires authorization.<br>
See [Positions](https://api-docs.grvt.io/trading_api/#positions)
for details.<br>.
Args:
symbols: list(str) get positions for these symbols only.<br>
Returns: list of dictionaries, each dict represent a position.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_positions(symbols, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_POSITIONS")
response: dict = await self._auth_and_post(path, payload)
positions: list = response.get("result", [])
if symbols:
self.logger.info(f"fetch_positions filter positions by {symbols=}")
positions = [p for p in positions if p.get("instrument") in symbols]
return positions
async def fetch_my_trades(
self,
symbol: str | None = None,
since: int | None = None,
limit: int | None = None,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature, HISTORICAL data.<br>
Fetch past trades for the account.<br>
Private call requires authorization.<br>
See [Private Trade History](https://api-docs.grvt.io/trading_api/#private-trade-history)
for details.<br>.
Args:
symbol: get trades for this symbol only.<br>
since: fetch trades since this timestamp in nanoseconds.<br>
limit: maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Default: 'USDT'.<br>
Returns:
a dictionary with keys:
`total` : total number of account history snapshots.<br>
`next` : cursor for the next page.<br>
`result` : a list of dictionaries, each dict represent a trade.<br>
"""
self._check_account_auth()
# Prepare request payload
payload = self._get_payload_fetch_my_trades(symbol, since, limit, params)
# Post payload and parse the response
path = get_grvt_endpoint(self.env, "GET_FILL_HISTORY")
response: dict = await self._auth_and_post(path, payload=payload)
if symbol:
# filter result by symbol
trades: list = response.get("result", [])
trades = [t for t in trades if t.get("instrument") == symbol]
response["result"] = trades
return response
# **************** PUBLIC API CALLS
async def load_markets(self) -> dict | None:
self.logger.info("load_markets START")
instruments = await self.fetch_markets(
params={
"kind": GrvtInstrumentKind.PERPETUAL,
}
)
if instruments:
self.markets = {i.get("instrument"): i for i in instruments}
self.logger.info(f"load_markets: loaded {len(self.markets)} markets.")
else:
self.logger.warning("load_markets: No markets found.")
return self.markets
async def fetch_markets(
self,
params: dict = {},
) -> list[dict]:
"""
ccxt-compliant signature
Retrieve the list of all instruments of matching kind, base and quote
supported by the exchange.
Params: dict with keys:<br>
`is_active` (bool) - defaults to True.<br>
`limit` (int) - defaiults to 20.<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Default: 'USDT'.<br>
Returns: list of dictionaries per instrument with keys:<br>
`instrument`: symbol e.g. 'BTC_USDT_Perp'.<br>
`instrument_hash`: hashed symbol for order signing e.g. '0x030501'.<br>
`base`: base currency e.g. 'BTC'.<br>
`quote`: quote currency e.g. 'USDT'.<br>
`kind`: kind of instrument 'PERPETUAL'/'FUTURE'.<br>
'base_decimals': size multiplier for order signing.<br>
`tick_size`: price tick size.<br>
`min_size`: minimum order size.<br>
"""
# Prepare payload
payload = self._get_payload_fetch_markets(params)
# Make the POST request to get all instruments
path = get_grvt_endpoint(self.env, "GET_INSTRUMENTS")
response: dict = await self._auth_and_post(path, payload=payload)
return response.get("result", [])
async def fetch_all_markets(
self,
is_active: bool | None = True,
) -> list[dict]:
"""
Retrieve the list of all instruments supported by the exchange.<br>
Params:<br>
`is_active` (bool) - defaults to True.<br>.
Returns: list of dictionaries per instrument. See fetch_markets().<br>
"""
# Prepare payload
payload = {"is_active": is_active}
# Make the POST request to get all instruments
path = get_grvt_endpoint(self.env, "GET_ALL_INSTRUMENTS")
response: dict = await self._auth_and_post(path, payload=payload)
# Extract and return the list of instruments
return response.get("result", [])
async def fetch_market(self, symbol: str) -> dict:
"""
Retrieve the instrument object for a given symbol.
:param symbol: The symbol of the instrument.
"""
# Make the POST request to get all instruments
path = get_grvt_endpoint(self.env, "GET_INSTRUMENT")
response: dict = await self._auth_and_post(path, payload={"instrument": symbol})
return response.get("result", [])
async def fetch_ticker(self, symbol: str, params: dict = {}) -> dict:
"""
ccxt-compliant signature
Retrieve the ticker of a given symbol.
:param symbol: The instrument name.
:return: The ticker dictionary of the instrument.
"""
# {'event_time': '1724252426000000000', 'instrument': 'BTC_USDT_Perp',
# 'mark_price': '59373870996065', 'index_price': '59395287961367',
# 'last_price': '99000000000000', 'last_size': '9917000000', 'mid_price': '59569000',
# 'best_bid_price': '59866000000000', 'best_bid_size': '23705000000', 'best_ask_price':
# '59273700', 'best_ask_size': '21670', 'funding_rate_curr': 2544, 'funding_rate_avg': 0,
# 'interest_rate': 0, 'forward_price': '0', 'buy_volume_u': '401930000000',
# 'sell_volume_u': '1218289000000', 'buy_volume_q': '34637817515500',
# 'sell_volume_q': '687640900', 'high_price': '343545000', 'low_price': '100000',
# 'open_price': '32554000000000', 'open_interest': '8174350000000',
# 'long_short_ratio': 1.0948905}
path = get_grvt_endpoint(self.env, "GET_TICKER")
response: dict = await self._auth_and_post(path, payload={"instrument": symbol})
return response.get("result", {})
async def fetch_mini_ticker(self, symbol: str) -> dict:
"""
Retrieve the mini-ticker of a given symbol.
:param symbol: The instrument name.
:return: The mini-ticker dictionary of the instrument.
"""
# {'event_time': '1724252426000000000', 'instrument': 'BTC_USDT_Perp',
# 'mark_price': '59373870996065', 'index_price': '59395287961367',
# 'last_price': '99000000000000', 'last_size': '9917000000', 'mid_price': '59569000',
# 'best_bid_price': '59866000000000', 'best_bid_size': '23705000000', 'best_ask_price':
# '59273700000000', 'best_ask_size': '21678000000'}
path = get_grvt_endpoint(self.env, "GET_MINI_TICKER")
response: dict = await self._auth_and_post(path, payload={"instrument": symbol})
return response.get("result", {})
async def fetch_order_book(self, symbol: str, limit: int = 10, params={}) -> dict:
"""
ccxt-compliant signature
Retrieve the order book of a given symbol.
:param symbol: The instrument name.
:return: The order book dictionary of the instrument.
"""
# {'event_time': '0', 'instrument': 'BTC_USDT_Perp',
# 'bids': [{'price': '100000000', 'size': '86353000000', 'num_orders': 4},...]
# 'asks': [{'price': '59273700000000', 'size': '21678000000', 'num_orders': 1}, ...]
payload = {"instrument": symbol, "aggregate": 1}
if limit:
payload["depth"] = limit
path = get_grvt_endpoint(self.env, "GET_ORDER_BOOK")
response: dict = await self._auth_and_post(path, payload=payload)
if self.is_order_book_ccxt_format():
# Convert to ccxt format
return self.convert_grvt_ob_to_ccxt(response.get("result", {}))
return response.get("result", {})
async def fetch_recent_trades(
self,
symbol: str,
limit: int | None = None,
) -> list:
"""
Retrieve the recent trades a given instrument.<br>
:param instrument: The instrument name.
:return: The order book dictionary of the instrument.
"""
# List of {'event_time': '1724248876870635916', 'instrument': 'ETH_USDT_Perp',
# 'is_taker_buyer': True, 'size': '24000000000', 'price': '2600000000000',
# 'mark_price': '2591055564869', 'index_price': '2592459142472', 'interest_rate': 0,
# 'forward_price': '0', 'trade_id': '729726', 'venue': 'ORDERBOOK'}
payload: dict[str, str | int] = {"instrument": symbol}
if limit:
payload["limit"] = limit
path = get_grvt_endpoint(self.env, "GET_TRADES")
response: dict = await self._auth_and_post(path, payload=payload)
return response.get("result", [])
async def fetch_trades(
self,
symbol: str,
since: int | None = None,
limit: int = 10,
params: dict = {},
) -> dict:
"""
Ccxt-compliant signature, HISTORICAL data.<br>
Retrieve trade history of a given instrument.
:param symbol: The instrument name.
:return: dict with field 'result' containing a list of trades.
"""
# List of {'event_time': '1724248876870635916', 'instrument': 'ETH_USDT_Perp',
# 'is_taker_buyer': True, 'size': '24000000000', 'price': '2600000000000',
# 'mark_price': '2591055564869', 'index_price': '2592459142472', 'interest_rate': 0,
# 'forward_price': '0', 'trade_id': '729726', 'venue': 'ORDERBOOK'}
payload: dict = self._get_payload_fetch_trades(
symbol,
since=since,
limit=limit,
params=params,
)
path: str = get_grvt_endpoint(self.env, "GET_TRADE_HISTORY")
response: dict = await self._auth_and_post(path, payload=payload)
return response
async def fetch_funding_rate_history(
self,
symbol: str,
since: int = 0,
limit: int = 1_000,
params: dict = {},
) -> dict:
"""
ccxt-compliant signature, HISTORICAL data.<br>
Retrieve the funding rates history of a given instrument.<br>
Args:
symbol (str): The instrument name.<br>
since (int): fetch trades since this timestamp in nanoseconds.<br>
limit: int - maximum number of trades to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`end_time` (int): end time in nanoseconds.<br>
Returns:
dict with field 'result' containing list of dictionaries repesenting funding rate
at a point in time with fields:<br>
`instrument` (str): instrument name.<br>
'funding_rate' (float): funding rate.<br>
'funding_time' (int): funding time in nanoseconds.<br>
'mark_price' (float): mark price.<br>.
"""
payload: dict[str, str | int] = {"instrument": symbol}
if params.get("cursor"):
payload["cursor"] = params["cursor"]
else:
if since:
payload["start_time"] = str(since)
if params.get("end_time"):
payload["end_time"] = str(params["end_time"])
if limit:
payload["limit"] = int(limit)
path: str = get_grvt_endpoint(self.env, "GET_FUNDING")
response: dict = await self._auth_and_post(path, payload=payload)
return response
async def fetch_ohlcv(
self,
symbol: str,
timeframe: str = "1m",
since: int = 0,
limit: int = 10,
params={},
) -> dict:
"""
ccxt-compliant signature, HISTORICAL data.<br>
Retrieve the ohlc history of a given instrument.<br>
Args:
symbol: The instrument name.<br>
timeframe: The timeframe of the ohlc.
See `ccxt_interval_to_grvt_candlestick_interval`.<br>
since: fetch ohlc since this timestamp in nanoseconds.<br>
limit: maximum number of ohlc to fetch.<br>
params: dictionary with parameters. Valid keys:<br>
`cursor` (str): cursor for the pagination.
If cursor is present then we ignore other filters.<br>
`end_time` (int): end time in nanoseconds.<br>
`candle_type` (str): candle type. Valid values: 'TRADE', 'MARK', 'INDEX'.<br>
Returns:
dict with field 'result' containing a list of dictionaries, each dict representing a candlestick with fields:<br>
`instrument` - instrument name.<br>
`open_time` - start of interval in nanoseconds.<br>
`close_time` - end of interval in nanoseconds.<br>
`open` - opening price.<br>
`close` - closing price.<br>
`high` - highest price.<br>
`low` - lowest price.<br>
`volume_u` - volume in units.<br>
`volume_q` - volume in quote(USDT).<br>
`trades` - number of trades.<br>.
"""
FN: str = f"{self._clsname} fetch_ohlcv"
payload: dict = self._get_payload_fetch_ohlcv(symbol, timeframe, since, limit, params)
self.logger.info(f"{FN} {payload=}")
path: str = get_grvt_endpoint(self.env, "GET_CANDLESTICK")
response: dict = await self._auth_and_post(path, payload=payload)
return response
# Vault Management APIs
async def fetch_vault_manager_investor_history(self, only_own_investments: bool = False) -> dict:
payload: dict = self._get_fetch_vault_manager_investor_history_payload(
vault_id=self.get_trading_account_id(),
only_own_investments=only_own_investments, # Default to False to fetch all investments
)
path: str = get_grvt_endpoint(self.env, "GET_VAULT_MANAGER_INVESTOR_HISTORY")
return await self._auth_and_post(path, payload=payload)
async def fetch_vault_redemption_queue(self):
payload: dict = self._get_fetch_vault_redemption_queue_payload(
vault_id=self.get_trading_account_id()
)
path: str = get_grvt_endpoint(self.env, "GET_VAULT_REDEMPTION_QUEUE")
return await self._auth_and_post(path, payload=payload)
@@ -0,0 +1,75 @@
import logging
from collections.abc import Callable
from .grvt_ccxt import GrvtCcxt
from .grvt_ccxt_env import get_all_grvt_endpoints
from .grvt_ccxt_pro import GrvtCcxtPro
def default_check(return_value: dict) -> str:
if not isinstance(return_value, list | dict):
return "return_value is not a list or dict"
if not return_value:
return "return_value is empty"
return "OK"
def validate_return_values(api: GrvtCcxt | GrvtCcxtPro, result_filename: str) -> None:
logging.info("validate_return_values: START")
endpoint_check_map: dict[str, Callable] = {
"GRAPHQL": default_check,
"AUTH": default_check,
"CREATE_ORDER": default_check,
"CANCEL_ALL_ORDERS": default_check,
"CANCEL_ORDER": default_check,
"GET_OPEN_ORDERS": default_check,
"GET_ACCOUNT_SUMMARY": default_check,
"GET_FUNDING_ACCOUNT_SUMMARY": default_check,
"GET_AGGREGATED_ACCOUNT_SUMMARY": default_check,
"GET_ACCOUNT_HISTORY": default_check,
"GET_POSITIONS": default_check,
"GET_ORDER": default_check,
"GET_ORDER_HISTORY": default_check,
"GET_FILL_HISTORY": default_check,
"GET_ALL_INSTRUMENTS": default_check,
"GET_INSTRUMENTS": default_check,
"GET_INSTRUMENT": default_check,
"GET_TICKER": default_check,
"GET_MINI_TICKER": default_check,
"GET_ORDER_BOOK": default_check,
"GET_TRADES": default_check,
"GET_TRADE_HISTORY": default_check,
"GET_FUNDING": default_check,
"GET_CANDLESTICK": default_check,
}
all_endpoints = get_all_grvt_endpoints(api.env)
end_point_status = {}
for short_name, endpoint in all_endpoints.items():
if not api.was_path_called(endpoint):
logging.info(f"validate_return_values: {short_name=}, {endpoint=}, not called")
end_point_status[short_name] = [endpoint, "not called"]
else:
return_value = api.get_endpoint_return_value(endpoint)
if short_name in endpoint_check_map:
check_function = endpoint_check_map.get(short_name)
if not check_function or not callable(check_function):
logging.error(
f"validate_return_values: {short_name=} "
f"not found in {endpoint_check_map.keys()=}"
)
continue
check_result = check_function(return_value)
logging.info(
f"validate_return_values: {short_name=}, {endpoint=}, {check_result=}"
)
end_point_status[short_name] = [endpoint, check_result]
else:
logging.error(
f"validate_return_values: NO {short_name=} in {endpoint_check_map.keys()=}"
)
end_point_status[short_name] = [endpoint, "no check"]
with open(result_filename, "w") as file_handle:
file_handle.write("NAME, URL, STATUS\n")
for short_name, status in end_point_status.items():
file_handle.write(f"{short_name}, {status[0]}, {status[1]}\n")
logging.info("validate_return_values: END")
@@ -0,0 +1,81 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
from decimal import Decimal
from enum import Enum
from typing import Literal
Num = None | str | float | int | Decimal
Amount = Decimal | int | float | str
GrvtOrderSide = Literal["buy", "sell"]
GrvtOrderType = Literal["limit", "market"]
DURATION_SECOND_IN_NSEC = 1_000_000_000
PRICE_MULTIPLIER = 1_000_000_000
BTC_ETH_SIZE_MULTIPLIER = 1_000_000_000
class GrvtInvalidOrder(Exception):
pass
class CandlestickInterval(Enum):
CI_1_M = "CI_1_M"
CI_3_M = "CI_3_M"
CI_5_M = "CI_5_M"
CI_15_M = "CI_15_M"
CI_30_M = "CI_30_M"
CI_1_H = "CI_1_H"
CI_2_H = "CI_2_H"
CI_4_H = "CI_4_H"
CI_6_H = "CI_6_H"
CI_8_H = "CI_8_H"
CI_12_H = "CI_12_H"
CI_1_D = "CI_1_D"
CI_3_D = "CI_3_D"
CI_5_D = "CI_5_D"
CI_1_W = "CI_1_W"
CI_2_W = "CI_2_W"
CI_3_W = "CI_3_W"
CI_4_W = "CI_4_W"
ccxt_interval_to_grvt_candlestick_interval = {
"1m": CandlestickInterval.CI_1_M,
"3m": CandlestickInterval.CI_3_M,
"5m": CandlestickInterval.CI_5_M,
"15m": CandlestickInterval.CI_15_M,
"30m": CandlestickInterval.CI_30_M,
"1h": CandlestickInterval.CI_1_H,
"2h": CandlestickInterval.CI_2_H,
"4h": CandlestickInterval.CI_4_H,
"6h": CandlestickInterval.CI_6_H,
"8h": CandlestickInterval.CI_8_H,
"12h": CandlestickInterval.CI_12_H,
"1d": CandlestickInterval.CI_1_D,
"3d": CandlestickInterval.CI_3_D,
"5d": CandlestickInterval.CI_5_D,
"1w": CandlestickInterval.CI_1_W,
"2w": CandlestickInterval.CI_2_W,
"3w": CandlestickInterval.CI_3_W,
"4w": CandlestickInterval.CI_4_W,
}
class CandlestickType(Enum):
TRADE = "TRADE"
MARK = "MARK"
INDEX = "INDEX"
MID = "MID"
class GrvtInstrumentKind(Enum):
PERPETUAL = "PERPETUAL"
FUTURE = "FUTURE"
CALL = "CALL"
PUT = "PUT"
@@ -0,0 +1,544 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
import json
import logging
import random
import time
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
from http.cookies import SimpleCookie
from typing import Any
import aiohttp
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data, SignableMessage
from .grvt_ccxt_env import CHAIN_IDS, GrvtEnv
from .grvt_ccxt_types import (
BTC_ETH_SIZE_MULTIPLIER,
DURATION_SECOND_IN_NSEC,
Amount,
GrvtOrderSide,
GrvtOrderType,
Num,
)
def rand_uint32():
return random.randint(0, 2**32 - 1)
class TimeInForce(Enum):
"""
| | Must Fill All | Can Fill Partial |
| - | - | - |
| Must Fill Immediately | FOK | IOC |
| Can Fill Till Time | AON | GTC |.
"""
# GTT - Remains open until it is cancelled, or expired
GOOD_TILL_TIME = "GOOD_TILL_TIME"
# AON - Either fill the whole order or none of it (Block Trades Only)
ALL_OR_NONE = "ALL_OR_NONE"
# IOC - Fill the order as much as possible, when hitting the orderbook. Then cancel it
IMMEDIATE_OR_CANCEL = "IMMEDIATE_OR_CANCEL"
# FOK - Both AoN and IoC. Either fill the full order when hitting the orderbook, or cancel it
FILL_OR_KILL = "FILL_OR_KILL"
class SignTimeInForce(Enum):
GOOD_TILL_TIME = 1
ALL_OR_NONE = 2
IMMEDIATE_OR_CANCEL = 3
FILL_OR_KILL = 4
TIME_IN_FORCE_TO_SIGN_TIME_IN_FORCE = {
TimeInForce.GOOD_TILL_TIME: SignTimeInForce.GOOD_TILL_TIME,
TimeInForce.ALL_OR_NONE: SignTimeInForce.ALL_OR_NONE,
TimeInForce.IMMEDIATE_OR_CANCEL: SignTimeInForce.IMMEDIATE_OR_CANCEL,
TimeInForce.FILL_OR_KILL: SignTimeInForce.FILL_OR_KILL,
}
def get_EIP712_domain_data(env: GrvtEnv) -> dict[str, str | int]:
# DO NOT MODIFY THESE VALUES ##############
return {
"name": "GRVT Exchange",
"version": "0",
"chainId": CHAIN_IDS[env.value],
}
def get_cookie_with_expiration(
path: str, api_key: str | None
) -> dict[str, str | float | None] | None:
"""
Authenticates and retrieves the session cookie, its expiration time and grvt-account-id token.
:return: The session cookie.
"""
FN = f"get_cookie_with_expiration {path=}"
if api_key:
data = {}
try:
data = {"api_key": api_key}
session = requests.Session()
return_value = session.post(
path,
json=data,
headers={"Content-Type": "application/json"},
timeout=5,
)
if return_value.ok:
cookie = SimpleCookie()
cookie.load(return_value.headers.get("Set-Cookie", ""))
cookie_value: str = cookie["gravity"].value
cookie_expiry: datetime = datetime.strptime(
cookie["gravity"]["expires"],
"%a, %d %b %Y %H:%M:%S %Z",
)
grvt_account_id: str = return_value.headers.get("X-Grvt-Account-Id", "")
logging.info(
f"{FN} OK response {cookie_value=} {cookie_expiry=} {grvt_account_id=}"
)
return {
"gravity": cookie_value,
"expires": cookie_expiry.timestamp(),
"X-Grvt-Account-Id": grvt_account_id,
}
logging.warning(f"{FN} Invalid return_value {data=} {path=} {return_value=}")
return None
except Exception as e:
logging.error(f"{FN} Error getting cookie: {e}")
return None
else:
return None
async def get_cookie_with_expiration_async(
path: str, api_key: str | None
) -> dict[str, str | float | None] | None:
"""
Authenticates and retrieves the session cookie, its expiration time and grvt-account-id token.
:return: The session cookie.
"""
FN = f"get_cookie_with_expiration_async {path=}"
if api_key:
data = {}
try:
data = {"api_key": api_key}
logging.info(f"{FN} ask for cookie {path=} {data=}")
async with aiohttp.ClientSession() as session:
async with session.post(url=path, json=data, timeout=5) as return_value:
logging.info(f"{FN} {return_value=}")
if return_value.ok:
cookie = SimpleCookie()
cookie.load(return_value.headers.get("Set-Cookie", ""))
cookie_value: str = cookie["gravity"].value
cookie_expiry: datetime = datetime.strptime(
cookie["gravity"]["expires"],
"%a, %d %b %Y %H:%M:%S %Z",
)
grvt_account_id: str = return_value.headers.get("X-Grvt-Account-Id", "")
logging.info(
f"{FN} OK response {cookie_value=} {cookie_expiry=} {grvt_account_id=}"
)
return {
"gravity": cookie_value,
"expires": cookie_expiry.timestamp(),
"X-Grvt-Account-Id": grvt_account_id,
}
except Exception as e:
logging.error(f"{FN} Error getting cookie: {e}")
return None
else:
return None
class GrvtKind(Enum):
PERPETUAL = 1
FUTURE = 2
CALL = 3
PUT = 4
SPOT = 5
class GrvtCurrency(Enum):
USD = 1
USDC = 2
USDT = 3
ETH = 4
BTC = 5
def hexlify(data: bytes) -> str:
"""Convert a byte array to a hex string with a 0x prefix."""
return f"0x{data.hex()}"
class EnumEncoder(json.JSONEncoder):
def default(self, o):
"""
Custom JSON encoder for Enum types.
:param obj: Object to serialize.
:return: Serialized object.
"""
if isinstance(o, Enum):
return o.value
return super().default(o)
def get_kuq_from_symbol(symbol: str) -> tuple[str, str, str]:
parts = symbol.split("_")
if len(parts) == 3:
underlying, quote, kind = parts
if kind == "Perp":
kind = "PERPETUAL"
else:
raise ValueError(f"Invalid {symbol=} {kind=}")
elif len(parts) == 4:
underlying, quote, kind, time_str = parts
if kind == "Fut":
kind = "FUTURE"
else:
raise ValueError(f"Invalid {symbol=} {kind=}")
elif len(parts) == 5:
underlying, quote, kind, time_str, strike_price = parts
if kind in {"Call", "Put"}:
kind = kind.upper()
else:
raise ValueError(f"Invalid {symbol=} {kind=}")
else:
raise ValueError(f"Invalid {symbol=}")
return kind, underlying, quote
# Custom types
EIP712_ORDER_MESSAGE_TYPE = {
"Order": [
{"name": "subAccountID", "type": "uint64"},
{"name": "isMarket", "type": "bool"},
{"name": "timeInForce", "type": "uint8"},
{"name": "postOnly", "type": "bool"},
{"name": "reduceOnly", "type": "bool"},
{"name": "legs", "type": "OrderLeg[]"},
{"name": "nonce", "type": "uint32"},
{"name": "expiration", "type": "int64"},
],
"OrderLeg": [
{"name": "assetID", "type": "uint256"},
{"name": "contractSize", "type": "uint64"},
{"name": "limitPrice", "type": "uint64"},
{"name": "isBuyingContract", "type": "bool"},
],
}
@dataclass
class GrvtSignature:
# The address (public key) of the wallet signing the payload
signer: str
r: str
s: str
v: int
# Timestamp after which this signature expires, expressed in unix nanoseconds.
# Must be capped at 30 days
expiration: str
"""
Users can randomly generate this value, used as a signature deconflicting key.
ie. You can send the same exact instruction twice with different nonces.
When the same nonce is used, the same payload will generate the same signature.
Our system will consider the payload a duplicate, and ignore it.
"""
nonce: int
@dataclass
class OrderMetadata:
"""
Metadata fields are used to support Backend only operations.
Hence, fields in here are never signed, and is never transmitted to the smart contract.
"""
"""
`client_order_id`: A unique identifier of an active order, specified by the client
This is used to identify the order in the client's system
This value must be unique for all active orders in a subaccount,
otehrwise amendment / cancellation will not work as expected
Gravity UI will generate a random clientOrderID for each order in the range [0, 2^63 - 1]
To prevent any conflicts, client machines should generate a random clientOrderID
in the range [2^63, 2^64 - 1].
When GRVT Backend receives an order with duplicate `client_order_id`, it will reject the order
with rejectReason set to duplicate `client_order_id`.
"""
client_order_id: str
# [Filled by GRVT Backend] Time at which the order was received by GRVT in unix nanoseconds
create_time: str | None = None
@dataclass
class GrvtOrderLeg:
# The instrument to trade in this leg
instrument: str
# The total number of contracts to trade in this leg, expressed in base currency units.
size: Decimal
# Specifies if the order leg is a buy or sell
is_buying_asset: bool
"""
The limit price of the order leg, expressed in `9` decimals.
This is the number of quote currency units to pay/receive for this leg.
This should be `null/0` if the order is a market order
"""
limit_price: Decimal
@dataclass
class GrvtOrder:
"""
Order is a typed payload used throughout the GRVT platform to express all orders.
GRVT orders are capable of expressing both single-legged, and multi-legged orders by default.
All fields in the Order payload (except `id`, `metadata`, and `state`) are trustlessly enforced
on our Hyperchain.
This minimizes the amount of trust users have to offer to GRVT.
"""
# The subaccount initiating the order
sub_account_id: str
# Supported time_in_force : GTT, IOC, FOK:<ul>
time_in_force: TimeInForce
legs: list[GrvtOrderLeg]
# The signature approving this order
signature: GrvtSignature
# Order Metadata, ignored by the smart contract, and unsigned by the client
metadata: OrderMetadata
# is_market: If the order is a market order
is_market: bool
post_only: bool = False
# If True, Order must reduce the position size, or be cancelled
reduce_only: bool = False
def get_signable_message(
order: GrvtOrder, env: GrvtEnv, instruments: dict[str, dict]
) -> bytes | None:
FN = f"get_signable_message {order=}"
size_multiplier = BTC_ETH_SIZE_MULTIPLIER
PRICE_MULTIPLIER = 1_000_000_000
legs = []
for leg in order.legs:
instrument = instruments.get(leg.instrument)
if not instrument or not isinstance(instrument, dict):
logging.error(f"{FN}: {leg.instrument=} not found in {instruments=}")
return None
if "base_decimals" not in instrument:
logging.error(f"{FN}: no 'base_decimals' in {instrument=}")
return None
size_multiplier = 10 ** instrument["base_decimals"]
if "instrument_hash" not in instrument:
logging.error(f"{FN}: no 'instrument_hash' in {instrument=}")
return None
legs.append(
{
"assetID": instrument["instrument_hash"],
"contractSize": int(Decimal(leg.size) * Decimal(size_multiplier)),
"limitPrice": int(Decimal(leg.limit_price) * Decimal(PRICE_MULTIPLIER)),
"isBuyingContract": leg.is_buying_asset,
}
)
message_data = {
"subAccountID": order.sub_account_id,
"isMarket": order.is_market or False,
"timeInForce": TIME_IN_FORCE_TO_SIGN_TIME_IN_FORCE[order.time_in_force].value,
"postOnly": order.post_only or False,
"reduceOnly": order.reduce_only or False,
"legs": legs,
"nonce": order.signature.nonce,
"expiration": order.signature.expiration,
}
domain_data: dict[str, str | int]= get_EIP712_domain_data(env)
logging.info(f"{FN} {domain_data=}\n{EIP712_ORDER_MESSAGE_TYPE=}\n{message_data=}")
return encode_typed_data(domain_data, EIP712_ORDER_MESSAGE_TYPE, message_data)
def get_order_payload(
order: GrvtOrder, private_key: str, env: GrvtEnv, instruments: dict[str, dict]
) -> dict:
signable_message = get_signable_message(order, env, instruments)
if signable_message is None:
raise ValueError("Failed to create signable message")
signed_message = Account.sign_message(signable_message, private_key)
order.signature.s = "0x" + signed_message.s.to_bytes(32, byteorder="big").hex()
order.signature.r = "0x" + signed_message.r.to_bytes(32, byteorder="big").hex()
order.signature.v = signed_message.v
order.signature.signer = Account.from_key(private_key).address
return {
"order": {
"sub_account_id": str(order.sub_account_id),
"is_market": order.is_market,
"time_in_force": order.time_in_force.name,
"post_only": order.post_only,
"reduce_only": order.reduce_only,
"legs": [
{
"instrument": leg.instrument,
"size": str(leg.size),
"limit_price": str(leg.limit_price),
"is_buying_asset": bool(leg.is_buying_asset),
}
for leg in order.legs
],
"signature": {
"r": order.signature.r,
"s": order.signature.s,
"v": order.signature.v,
"expiration": order.signature.expiration,
"nonce": order.signature.nonce,
"signer": order.signature.signer,
},
"metadata": {
"client_order_id": order.metadata.client_order_id,
},
}
}
def get_order_rpc_payload(
order: GrvtOrder,
private_key: str,
env: GrvtEnv,
instruments: dict[str, dict],
version: str = "v1",
) -> dict:
order_payload = get_order_payload(order, private_key, env, instruments)
return {
"jsonrpc": "2.0",
"method": f"{version}/create_order",
"params": order_payload,
}
def get_grvt_order(
sub_account_id: str,
symbol: str,
order_type: GrvtOrderType,
side: GrvtOrderSide,
amount: Amount,
limit_price: Num,
order_duration_secs: float = 5 * 60,
params: dict = {},
) -> GrvtOrder:
"""
Creates an order for a specified symbol with the given limit price and size.
Args:
symbol .
limit_price (int): The limit price for the order.
size (float): The size of the order.
is_buying_asset(bool) : Buy or Sell.
Returns:
Order: The created perpetual order.
"""
limit_price = limit_price or 0
is_buying_asset = side == "buy"
is_market = order_type == "market"
leg = GrvtOrderLeg(
instrument=symbol,
size=round(Decimal(amount), 9),
is_buying_asset=is_buying_asset,
limit_price=round(Decimal(limit_price), 9),
)
# create an expiry time
time_in_force = TimeInForce.GOOD_TILL_TIME
if "time_in_force" in params:
time_in_force = TimeInForce[params["time_in_force"]]
post_only: bool = False
if "post_only" in params:
post_only = params["post_only"]
reduce_only: bool = False
if "reduce_only" in params:
reduce_only = params["reduce_only"]
expiry_ns: int = 0
if order_duration_secs:
expiry_ns = time.time_ns() + int(order_duration_secs * DURATION_SECOND_IN_NSEC)
if "client_order_id" in params:
client_order_id = int(params["client_order_id"])
else:
client_order_id = rand_uint32()
signature = GrvtSignature(
signer="",
r="",
s="",
v=0,
expiration=str(expiry_ns),
nonce=rand_uint32(),
)
metadata = OrderMetadata(client_order_id=str(client_order_id))
return GrvtOrder(
sub_account_id=sub_account_id,
time_in_force=time_in_force,
legs=[leg],
signature=signature,
metadata=metadata,
is_market=is_market,
post_only=post_only,
reduce_only=reduce_only,
)
def sign_derisk_mm_ratio_request(
env: GrvtEnv, sub_account_id: int, ratio: str, private_key_hex: str
):
"""
Generate a signature for setting the derisk to maintenance margin ratio.
:param sub_account_id: The sub-account ID to set the ratio for.
:param ratio: The derisk to maintenance margin ratio as a string (e.g., "2.0").
:param private_key_hex: The private key in hexadecimal format.
:return: A dictionary containing the signature for the payload.
"""
derisk_ratio_int = int(Decimal(ratio) * 1_000_000)
expiration_ns = int((time.time() + 86400) * 1_000_000_000)
nonce = random.randint(1, 2**32 - 1)
domain_data = get_EIP712_domain_data(env)
types = {
"SetDeriskToMaintenanceMarginRatio": [
{"name": "subAccountID", "type": "uint64"},
{"name": "deriskToMaintenanceMarginRatio", "type": "uint32"},
{"name": "nonce", "type": "uint32"},
{"name": "expiration", "type": "int64"},
]
}
signature_payload = {
"subAccountID": sub_account_id,
"deriskToMaintenanceMarginRatio": derisk_ratio_int,
"nonce": nonce,
"expiration": expiration_ns,
}
message = encode_typed_data(domain_data, types, signature_payload)
signed = Account.sign_message(message, private_key_hex)
signer = Account.from_key(private_key_hex)
return {
"signer": signer.address.lower(),
"r": hex(signed.r),
"s": hex(signed.s),
"v": signed.v,
"expiration": str(expiration_ns),
"nonce": nonce,
}
@@ -0,0 +1,785 @@
# ruff: noqa: D200
# ruff: noqa: D204
# ruff: noqa: D205
# ruff: noqa: D404
# ruff: noqa: W291
# ruff: noqa: D400
# ruff: noqa: E501
import asyncio
import json
import logging
import traceback
from asyncio.events import AbstractEventLoop
from collections.abc import Callable
from decimal import Decimal
import websockets
# import requests
# from env import ENDPOINTS
from .grvt_ccxt_env import (
GRVT_WS_STREAMS,
GrvtEnv,
GrvtWSEndpointType,
get_grvt_ws_endpoint,
is_trading_ws_endpoint,
)
from .grvt_ccxt_pro import GrvtCcxtPro
from .grvt_ccxt_types import (
GrvtInvalidOrder,
GrvtOrderSide,
GrvtOrderType,
Num,
)
from .grvt_ccxt_utils import get_order_rpc_payload
WS_READ_TIMEOUT = 5
class GrvtCcxtWS(GrvtCcxtPro):
"""
GrvtCcxtPro class to interact with Grvt Rest API and WebSockets in asynchronous mode.
Args:
env: GrvtCcxtPro (DEV, TESTNET, PROD)
parameters: dict with trading_account_id, private_key, api_key etc
Examples:
>>> from grvt_api_pro import GrvtCcxtPro
>>> from grvt_env import GrvtEnv
>>> grvt = GrvtCcxtPro(env=GrvtEnv.TESTNET)
>>> await grvt.fetch_markets()
"""
def __init__(
self,
env: GrvtEnv,
loop: AbstractEventLoop,
logger: logging.Logger | None = None,
parameters: dict = {},
):
"""Initialize the GrvtCcxt instance."""
super().__init__(env, logger, parameters)
self._loop = loop
self._clsname: str = type(self).__name__
self.api_ws_version = parameters.get("api_ws_version", "v1")
self.force_reconnect_flag: bool = False
self.ws: dict[GrvtWSEndpointType, websockets.WebSocketClientProtocol | None] = {}
self.callbacks: dict[GrvtWSEndpointType, dict[str, dict[str, Callable]]] = {}
self.subscribed_streams: dict[GrvtWSEndpointType, dict] = {}
self.api_url: dict[GrvtWSEndpointType, str] = {}
self._last_message: dict[str, dict] = {}
self._request_id = 0
self.endpoint_types = [
GrvtWSEndpointType.MARKET_DATA,
GrvtWSEndpointType.TRADE_DATA,
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
]
# Initialize dictionaries for each endpoint type
for grvt_endpoint_type in self.endpoint_types:
self.api_url[grvt_endpoint_type] = get_grvt_ws_endpoint(
self.env.value, grvt_endpoint_type
)
self.callbacks[grvt_endpoint_type] = {}
self.subscribed_streams[grvt_endpoint_type] = {}
self.ws[grvt_endpoint_type] = None
self._loop.create_task(self._read_messages(grvt_endpoint_type))
self.logger.info(f"{self._clsname} initialized {self.api_url=}")
self.logger.info(f"{self._clsname} initialized {self.ws=}")
def __repr__(self) -> str:
return f"{self._clsname} {self.env=} {self.api_ws_version=}"
async def __aexit__(self):
for grvt_endpoint_type in self.endpoint_types:
await self._close_connection(grvt_endpoint_type)
def force_reconnect(self) -> None:
self.force_reconnect_flag = True
async def initialize(self):
"""
Prepares the GrvtCcxtPro instance and connects to WS server.
"""
await self.load_markets()
await self.refresh_cookie()
self._loop.create_task(self.connect_all_channels())
def is_connection_open(self, grvt_endpoint_type: GrvtWSEndpointType) -> bool:
return (
self.ws[grvt_endpoint_type] is not None and self.ws[grvt_endpoint_type].open
)
def is_endpoint_connected(self, grvt_endpoint_type: GrvtWSEndpointType) -> bool:
"""
For MARKET_DATA returns True if connection is open.
for TRADE_DATA returns True if one of the following is true:
1. No cookie - this means this is public connection and we can't connect to TRADE_DATA
2. Connection to TRADE_DATA is open
"""
if grvt_endpoint_type in [
GrvtWSEndpointType.MARKET_DATA,
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
]:
return self.is_connection_open(grvt_endpoint_type)
if grvt_endpoint_type in [
GrvtWSEndpointType.TRADE_DATA,
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
]:
return bool(not self._cookie or self.is_connection_open(grvt_endpoint_type))
raise ValueError(f"Unknown endpoint type {grvt_endpoint_type}")
def are_endpoints_connected(
self, grvt_endpoint_types: list[GrvtWSEndpointType]
) -> bool:
return all(
self.is_endpoint_connected(endpoint) for endpoint in grvt_endpoint_types
)
async def connect_all_channels(self) -> None:
"""
Connects to all channels that are possible to connect.
If cookie is NOT available, it will NOT connect to GrvtWSEndpointType.TRADE_DATA
For trading connection: run this method after cookie is available.
"""
FN = "connect_all_channels"
while True:
try:
for end_point_type in self.endpoint_types:
if (
not self.is_endpoint_connected(end_point_type)
or self.force_reconnect_flag
):
await self._reconnect(end_point_type)
all_are_connected = self.are_endpoints_connected(self.endpoint_types)
self.logger.info(
f"{FN} Connection status: {all_are_connected=} {self.force_reconnect_flag=}"
)
self.force_reconnect_flag = False
except Exception as e:
self.logger.exception(f"{FN} {e=}")
finally:
await asyncio.sleep(5)
async def connect_channel(self, grvt_endpoint_type: GrvtWSEndpointType) -> bool:
FN = f"{self._clsname} connect_channel {grvt_endpoint_type}"
try:
if self.is_endpoint_connected(grvt_endpoint_type):
self.logger.info(f"{FN} Already connected")
return True
self.subscribed_streams[grvt_endpoint_type] = {}
extra_headers = {}
if self._cookie:
extra_headers = {"Cookie": f"gravity={self._cookie['gravity']}"}
if self._cookie["X-Grvt-Account-Id"]:
extra_headers.update(
{"X-Grvt-Account-Id": self._cookie["X-Grvt-Account-Id"]}
)
if grvt_endpoint_type in [
GrvtWSEndpointType.TRADE_DATA,
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
]:
if self._cookie:
self.ws[grvt_endpoint_type] = await websockets.connect(
uri=self.api_url[grvt_endpoint_type],
extra_headers=extra_headers,
logger=self.logger,
open_timeout=5,
)
self.logger.info(
f"{FN} Connected to {self.api_url[grvt_endpoint_type]} {extra_headers=}"
)
else:
self.logger.info(f"{FN} Waiting for cookie.")
elif grvt_endpoint_type in [
GrvtWSEndpointType.MARKET_DATA,
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
]:
self.ws[grvt_endpoint_type] = await websockets.connect(
uri=self.api_url[grvt_endpoint_type],
extra_headers=extra_headers,
logger=self.logger,
open_timeout=5,
)
self.logger.info(f"{FN} Connected to {self.api_url[grvt_endpoint_type]} {extra_headers=}")
except (
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosed,
) as e:
self.logger.info(f"{FN} connection already closed:{e}")
self.ws[grvt_endpoint_type] = None
except Exception as e:
self.logger.warning(f"{FN} error:{e} traceback:{traceback.format_exc()}")
self.ws[grvt_endpoint_type] = None
# return True if connection successful
return self.is_endpoint_connected(grvt_endpoint_type)
async def _close_connection(self, grvt_endpoint_type: GrvtWSEndpointType):
try:
if self.ws[grvt_endpoint_type]:
self.logger.info(f"{self._clsname} Closing connection...")
await self.ws[grvt_endpoint_type].close()
self.subscribed_streams[grvt_endpoint_type] = {}
self.logger.info(f"{self._clsname} Connection closed")
else:
self.logger.info(f"{self._clsname} No connection to close")
except Exception:
self.logger.exception(
f"{self._clsname} Error when closing connection {traceback.format_exc()}"
)
async def _reconnect(self, grvt_endpoint_type: GrvtWSEndpointType):
FN = f"{self._clsname} _reconnect {grvt_endpoint_type=}"
try:
self.logger.info(f"{FN} STARTS")
await self._close_connection(grvt_endpoint_type)
success: bool = await self.connect_channel(grvt_endpoint_type)
if success:
await self._resubscribe(grvt_endpoint_type)
except Exception:
self.logger.exception(f"{FN} failed {traceback.format_exc()}")
async def _resubscribe(self, grvt_endpoint_type: GrvtWSEndpointType):
if self.is_connection_open(grvt_endpoint_type):
for versioned_stream in self.callbacks[grvt_endpoint_type]:
for selector in self.callbacks[grvt_endpoint_type][versioned_stream]:
self.logger.info(
f"{self._clsname} _resubscribe {grvt_endpoint_type=}"
f" {versioned_stream=}/{selector=}"
)
await self._subscribe_to_stream(
grvt_endpoint_type, versioned_stream, selector
)
else:
self.logger.warning(f"{self._clsname} _resubscribe - No connection.")
# **************** PUBLIC API CALLS
def is_stream_subscribed(
self, grvt_endpoint_type: GrvtWSEndpointType, stream: str
) -> bool:
versioned_stream = self.get_versioned_stream(stream)
return self.subscribed_streams.get(grvt_endpoint_type, {}).get(
versioned_stream, False
)
def _check_susbcribed_stream(
self, grvt_endpoint_type: GrvtWSEndpointType, message: dict
) -> None:
stream_subscribed: str = ""
if "stream" in message:
stream_subscribed = message["stream"]
elif "result" in message and "stream" in message["result"]:
stream_subscribed = message.get("result", {}).get("stream", "")
if stream_subscribed:
if not self.subscribed_streams[grvt_endpoint_type].get(stream_subscribed):
self.logger.info(
f"{self._clsname} subscribed to stream:{stream_subscribed}"
)
self.subscribed_streams[grvt_endpoint_type][stream_subscribed] = True
async def _read_messages(self, grvt_endpoint_type: GrvtWSEndpointType):
FN = f"{self._clsname} _read_messages {grvt_endpoint_type.value}"
while True:
if self.is_connection_open(grvt_endpoint_type):
try:
self.logger.debug(f"{FN} waiting for message")
response = await asyncio.wait_for(
self.ws[grvt_endpoint_type].recv(), timeout=WS_READ_TIMEOUT
)
message = json.loads(response)
self.logger.debug(f"{FN} received {message=}")
self._check_susbcribed_stream(grvt_endpoint_type, message)
if "feed" in message:
stream_subscribed: str | None = message.get("stream")
selector: str = message.get("selector")
if stream_subscribed is None:
self.logger.warning(f"{FN} missing stream in {message=}")
if selector is None:
self.logger.warning(f"{FN} missing selector in {message=}")
if stream_subscribed and selector:
callback = (
self.callbacks[grvt_endpoint_type]
.get(stream_subscribed, {})
.get(selector, None)
)
if callback:
await callback(message)
stream: str = self.get_non_versioned_stream(
stream_subscribed
)
self._last_message[stream] = message
else:
self.logger.warning(
f"{FN} No callback for {stream_subscribed=}/{selector=}"
)
elif "jsonrpc" in message:
"""
{'jsonrpc': '', 'result': {'result':
{'order_id': '0x00', 'sub_account_id': '8751933338735530',
'is_market': False, 'time_in_force': 'GOOD_TILL_TIME', 'post_only': False,
'reduce_only': False, 'legs': [{'instrument': 'BTC_USDT_Perp', 'size': '0.001',
'limit_price': '50000.0', 'is_buying_asset': True}],
'signature': {'signer': '0x2989e3783e2ae05f9a1538dd411a22a4cd9554ad',
'r': '0xa566702c1e5557ab96e8d5197b6871456765a80556bba46c9d4928bd573ca66c',
's': '0x6f6e0be6dca125643fce884ca28c0ae341b201efe49e10a9626859517b4a09af',
'v': 28, 'expiration': '1729005262433997000', 'nonce': 3898454329},
'metadata': {'client_order_id': '123', 'create_time': '1728918862633971628'},
'state': {'status': 'OPEN', 'reject_reason': 'UNSPECIFIED',
'book_size': ['0.001'], 'traded_size': ['0.0'], 'update_time': '1728918862633971628'}}},
'id': 2}
"""
self.logger.debug(f"{FN} jsonrpc result:{message.get('result')}")
else:
self.logger.info(f"{FN} Non-actionable message:{message}")
except (
websockets.exceptions.ConnectionClosedError,
websockets.exceptions.ConnectionClosedOK,
):
self.logger.exception(
f"{FN} connection closed {traceback.format_exc()}"
)
await self._reconnect(grvt_endpoint_type)
except asyncio.TimeoutError: # noqa: UP041
self.logger.debug(f"{FN} Timeout {WS_READ_TIMEOUT} secs")
pass
except Exception:
self.logger.exception(
f"{FN} connection failed {traceback.format_exc()}"
)
await asyncio.sleep(1)
else:
self.logger.info(f"{FN} connection not open")
await asyncio.sleep(2)
async def _send(self, end_point_type: GrvtWSEndpointType, message: str):
try:
if self.ws[end_point_type] and self.ws[end_point_type].open:
self.logger.info(
f"{self._clsname} _send() {end_point_type=}"
f" url:{self.api_url[end_point_type]} {message=}"
)
await self.ws[end_point_type].send(message)
except websockets.exceptions.ConnectionClosedError as e:
self.logger.info(f"{self._clsname} _send() Restarted connection {e}")
await self._reconnect(end_point_type)
if self.ws[end_point_type]:
self.logger.info(
f"{self._clsname} _send() RESEND on RECONNECT {end_point_type=}"
f" url:{self.api_url[end_point_type]} {message=}"
)
await self.ws[end_point_type].send(message)
except Exception:
self.logger.exception(f"{self._clsname} send failed {traceback.format_exc()}")
await self._reconnect(end_point_type)
def _construct_selector(self, stream: str, params: dict) -> str:
feed: str = ""
# ******** Market Data ********
if stream.endswith(("mini.s", "mini.d", "ticker.s", "ticker.d")):
feed = f"{params.get('instrument', '')}@{params.get('rate', '500')}"
if stream.endswith("book.s"):
feed = (
f"{params.get('instrument', '')}@{params.get('rate', '500')}-"
f"{params.get('depth', '10')}"
)
if stream.endswith("book.d"):
feed = f"{params.get('instrument', '')}@{params.get('rate', '500')}"
if stream.endswith("trade"):
feed = f"{params.get('instrument', '')}@{params.get('limit', '50')}"
if stream.endswith("candle"):
feed = (
f"{params.get('instrument', '')}@{params.get('interval', 'CI_1_M')}-"
f"{params.get('type', 'TRADE')}"
)
# ******** Trade Data ********
if stream.endswith(("order", "state", "position", "fill")):
if not params:
feed = f"{self._trading_account_id}"
elif params.get("instrument"):
feed = f"{self._trading_account_id}-{params.get('instrument', '')}"
else:
feed = (
f"{self._trading_account_id}-{params.get('kind', '')}-"
f"{params.get('base', '')}-{params.get('quote', '')}"
)
# Deposit, Transfer, Withdrawal
if stream.endswith(("deposit", "transfer", "withdrawal")):
feed = ""
# f"{params.get('sub_account_id', '')}-{params.get('main_account_id', '')}"
return feed
async def subscribe(
self,
stream: str,
callback: Callable,
ws_end_point_type: GrvtWSEndpointType | None = None,
params: dict = {},
) -> None:
"""
Subscribe to a stream with optional parameters.
Call the callback function when a message is received.
callback function should have the following signature:
(dict) -> None.
"""
FN = f"{self._clsname} subscribe {stream=}"
if not ws_end_point_type: # use default endpoint type
ws_end_point_type = GRVT_WS_STREAMS.get(stream)
if not ws_end_point_type:
self.logger.error(f"{FN} unknown GrvtWSEndpointType for {stream=}")
return
is_trade_data = is_trading_ws_endpoint(ws_end_point_type)
if is_trade_data and not self._trading_account_id:
self.logger.error(
f"{FN} {stream=} is a trading data connection. Requires trading_account_id."
)
return
# create selector string and register callback
selector: str = self._construct_selector(stream, params)
versioned_stream: str = self.get_versioned_stream(stream)
if versioned_stream not in self.callbacks[ws_end_point_type]:
self.callbacks[ws_end_point_type][versioned_stream] = {}
self.callbacks[ws_end_point_type][versioned_stream][selector] = callback
self.logger.info(
f"{FN} {params=} {ws_end_point_type=}/{versioned_stream=}/{selector=} callback:{callback}"
)
# check if connection is open and subscribe
if self.is_connection_open(ws_end_point_type):
await self._subscribe_to_stream(ws_end_point_type, versioned_stream, selector)
else:
self.logger.info(f"{FN} Connection not open. Will subscribe on connect.")
async def re_subscribe_stream(
self,
stream: str,
callback: Callable,
ws_end_point_type: GrvtWSEndpointType | None = None,
params: dict = {},
) -> None:
""" This method should be called in a separate task -
otherwise it will block the event loop for 5 seconds.
Unsubscribe from a specific stream and subscribe again with optional parameters.
Call the callback function when a message is received.
callback function should have the following signature:
(dict) -> None.
"""
FN = f"{self._clsname} re_subscribe {stream=}"
if not ws_end_point_type: # use default endpoint type
ws_end_point_type = GRVT_WS_STREAMS.get(stream)
if not ws_end_point_type:
self.logger.error(f"{FN} unknown GrvtWSEndpointType for {stream=}")
return
if not self.is_connection_open(ws_end_point_type):
self.logger.info(f"{FN} {ws_end_point_type=} not open. Try again after connect.")
return
is_trade_data = is_trading_ws_endpoint(ws_end_point_type)
if is_trade_data and not self._trading_account_id:
self.logger.error(
f"{FN} {stream=} is a trading data connection. Requires trading_account_id."
)
return
# create selector string and register callback
selector: str = self._construct_selector(stream, params)
versioned_stream: str = self.get_versioned_stream(stream)
if versioned_stream not in self.callbacks[ws_end_point_type]:
self.callbacks[ws_end_point_type][versioned_stream] = {}
self.callbacks[ws_end_point_type][versioned_stream][selector] = callback
# self.logger.info(
# f"{FN} {params=} {ws_end_point_type=}/{versioned_stream=}/{selector=} callback:{callback}"
# )
await self._unsubscribe_to_stream(ws_end_point_type, versioned_stream, selector)
await asyncio.sleep(5) # wait for unsubscribe to complete
await self._subscribe_to_stream(ws_end_point_type, versioned_stream, selector)
def get_versioned_stream(self, stream: str) -> str:
return (
stream if self.api_ws_version == "v0" else f"{self.api_ws_version}.{stream}"
)
def get_non_versioned_stream(self, versioned_stream: str) -> str:
if self.api_ws_version == "v0":
return versioned_stream
return versioned_stream.split(".")[1]
async def _subscribe_to_stream(
self,
ws_end_point_type: GrvtWSEndpointType,
versioned_stream: str,
selector: str,
) -> None:
FN = (
f"{self._clsname} _subscribe_to_stream {ws_end_point_type=}"
f" {versioned_stream=} {selector=}"
)
self._request_id += 1
if ws_end_point_type in [
GrvtWSEndpointType.TRADE_DATA,
GrvtWSEndpointType.MARKET_DATA,
]: # Legacy subscription
subscribe_json = json.dumps(
{
"request_id": self._request_id,
"stream": versioned_stream,
"feed": [selector],
"method": "subscribe",
"is_full": True,
}
)
self.logger.info(f"{FN} {versioned_stream=} {subscribe_json=}")
else: # RPC WS format
self._request_id += 1
subscribe_json = json.dumps(
{
"jsonrpc": "2.0",
"method": "subscribe",
"params": {
"stream": versioned_stream,
"selectors": [selector],
},
"id": self._request_id,
}
)
self.logger.info(f"{FN} {versioned_stream=} {subscribe_json=}")
await self._send(ws_end_point_type, subscribe_json)
stream: str = self.get_non_versioned_stream(versioned_stream)
if stream not in self._last_message:
self._last_message[stream] = {}
async def _unsubscribe_to_stream(
self,
ws_end_point_type: GrvtWSEndpointType,
versioned_stream: str,
selector: str,
) -> None:
FN = (
f"{self._clsname} _unsubscribe_to_stream {ws_end_point_type=}"
f" {versioned_stream=} {selector=}"
)
self._request_id += 1
if ws_end_point_type in [
GrvtWSEndpointType.TRADE_DATA,
GrvtWSEndpointType.MARKET_DATA,
]: # Legacy subscription
subscribe_json = json.dumps(
{
"request_id": self._request_id,
"stream": versioned_stream,
"feed": [selector],
"method": "unsubscribe",
"is_full": True,
}
)
self.logger.info(f"{FN} {versioned_stream=} {subscribe_json=}")
else: # RPC WS format
self._request_id += 1
subscribe_json = json.dumps(
{
"jsonrpc": "2.0",
"method": "unsubscribe",
"params": {
"stream": versioned_stream,
"selectors": [selector],
},
"id": self._request_id,
}
)
self.logger.info(f"{FN} {versioned_stream=} {subscribe_json=}")
await self._send(ws_end_point_type, subscribe_json)
def jsonrpc_wrap_payload(
self, payload: dict, method: str, version: str = "v1"
) -> dict:
"""
Wrap the payload in JSON-RPC format.
"""
self._request_id += 1
return {
"jsonrpc": "2.0",
"method": f"{version}/{method}",
"params": payload,
"id": self._request_id,
}
async def send_rpc_message(
self, end_point_type: GrvtWSEndpointType, message: dict
) -> None:
"""
Send a message to the server.
"""
await self._send(end_point_type, json.dumps(message))
self.logger.info(f"{self._clsname} send_rpc_message {end_point_type=} {message=}")
async def rpc_create_order(
self,
symbol: str,
order_type: GrvtOrderType,
side: GrvtOrderSide,
amount: float | Decimal | str | int,
price: Num = None,
params={},
) -> dict:
"""
Create an order.
"""
FN = f"{self._clsname} rpc_create_order"
if not self.is_endpoint_connected(GrvtWSEndpointType.TRADE_DATA_RPC_FULL):
raise GrvtInvalidOrder("Trade data connection not available.")
order = self._get_order_with_validations(
symbol, order_type, side, amount, price, params
)
self.logger.info(f"{FN} {order=}")
payload = get_order_rpc_payload(order, self._private_key, self.env, self.markets)
self._request_id += 1
payload["id"] = self._request_id
self.logger.info(f"{FN} {payload=}")
await self.send_rpc_message(GrvtWSEndpointType.TRADE_DATA_RPC_FULL, payload)
return payload
async def rpc_create_limit_order(
self,
symbol: str,
side: GrvtOrderSide,
amount: float | Decimal | str | int,
price: Num,
params={},
) -> dict:
return await self.rpc_create_order(symbol, "limit", side, amount, price, params)
async def rpc_cancel_all_orders(
self,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature BUT lacks symbol
Cancel all orders for a sub-account.
params: dictionary with parameters. Valid keys:<br>
`kind` (str): instrument kind. Valid values: 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch
orders for all base currencies.<br>
`quote` (str): quote currency. Defaults to all.<br>
"""
self._check_account_auth()
# FN = f"{self._clsname} rpc_cancel_all_orders"
payload: dict = self._get_payload_cancel_all_orders(params)
jsonrpc_payload: dict = self.jsonrpc_wrap_payload(payload, method="cancel_all_orders")
await self.send_rpc_message(
GrvtWSEndpointType.TRADE_DATA_RPC_FULL, jsonrpc_payload
)
return jsonrpc_payload
async def rpc_cancel_order(
self,
id: str | None = None,
symbol: str | None = None,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature
Cancel specific order for the account by sending JsonRpc call on WebSocket.<br>
Private call requires authorization.<br>
See [Cancel order](https://api-docs.grvt.io/trading_api/#cancel-order)
for details.<br>.
Args:
id (str): exchange assigned order ID<br>
symbol (str): trading symbol<br>
params:
* client_order_id (str): client assigned order ID<br>
* time_to_live_ms (str): lifetime of cancel requiest in millisecs<br>
Returns:
payload used to cancel order.<br>
"""
FN = f"{self._clsname} rpc_cancel_order"
if not self.is_endpoint_connected(GrvtWSEndpointType.TRADE_DATA_RPC_FULL):
raise GrvtInvalidOrder("Trade data connection not available.")
self._check_account_auth()
# Prepare payload
payload: dict = {
"sub_account_id": str(self._trading_account_id),
}
if id:
payload["order_id"] = str(id)
elif "client_order_id" in params:
payload["client_order_id"] = str(params["client_order_id"])
else:
raise GrvtInvalidOrder(f"{FN} requires either order_id or client_order_id")
if "time_to_live_ms" in params:
payload["time_to_live_ms"] = str(params["time_to_live_ms"])
# Send cancel requiest
jsonrpc_payload = self.jsonrpc_wrap_payload(payload, method="cancel_order")
await self.send_rpc_message(
GrvtWSEndpointType.TRADE_DATA_RPC_FULL, jsonrpc_payload
)
return jsonrpc_payload
async def rpc_fetch_open_orders(
self,
params: dict = {},
) -> dict:
"""
Fetch open orders for the account.<br>
Private call requires authorization.<br>
See [Open orders](https://api-docs.grvt.io/trading_api/#open-orders)
for details.<br>.
Fetches open orders for the account.<br>
Sends JsonRpc call on WebSocket.<br>
Args:
params: dictionary with parameters. Valid keys:<br>
`kind` (str): instrument kind. Valid values are 'PERPETUAL'.<br>
`base` (str): base currency. If missing/empty then fetch orders
for all base currencies.<br>
`quote` (str): quote currency. Defaults to all.<br>
Returns:
payload used to fetch open orders.<br><br>
"""
self._check_account_auth()
# Prepare request payload
payload: dict = self._get_payload_fetch_open_orders(symbol=None, params=params)
jsonrpc_payload: dict = self.jsonrpc_wrap_payload(payload, method="open_orders")
await self.send_rpc_message(
GrvtWSEndpointType.TRADE_DATA_RPC_FULL, jsonrpc_payload
)
return jsonrpc_payload
async def rpc_fetch_order(
self,
id: str | None = None,
symbol: str | None = None,
params: dict = {},
) -> dict:
"""
Ccxt compliant signature.<br>
Private call requires authorization.<br>
See [Get Order](https://api-docs.grvt.io/trading_api/#get-order)
for details.<br>.
Get Order status by either order_id or client_order_id.<br>
Sends JsonRpc call on WebSocket.<br>
Args:
id: (str) order_id to fetch.<br>
symbol: (str) NOT SUPPRTED.<br>
params: dictionary with parameters. Valid keys:<br>
`client_order_id` (int): client assigned order ID.<br>
Returns:
payload used to fetch order.<br>
"""
FN = f"{self._clsname} rpc_fetch_order"
self._check_account_auth()
payload = {
"sub_account_id": str(self._trading_account_id),
}
if id:
payload["order_id"] = id
elif "client_order_id" in params:
payload["client_order_id"] = str(params["client_order_id"])
else:
raise GrvtInvalidOrder(
f"{FN} requires either order_id or params['client_order_id']"
)
jsonrpc_payload = self.jsonrpc_wrap_payload(payload, method="order")
await self.send_rpc_message(
GrvtWSEndpointType.TRADE_DATA_RPC_FULL, jsonrpc_payload
)
return jsonrpc_payload
@@ -0,0 +1,25 @@
from dataclasses import dataclass
from .grvt_raw_types import Signature, TransferType
@dataclass
class Transfer:
# The account to transfer from
from_account_id: str
# The subaccount to transfer from (0 if transferring from main account)
from_sub_account_id: str
# The account to deposit into
to_account_id: str
# The subaccount to transfer to (0 if transferring to main account)
to_sub_account_id: str
# The token currency to transfer
currency: str
# The number of tokens to transfer
num_tokens: str
# The signature of the transfer
signature: Signature
# The type of transfer
transfer_type: TransferType
# The metadata of the transfer
transfer_metadata: str
@@ -0,0 +1,365 @@
from enum import Enum
from dacite import Config, from_dict
from . import grvt_raw_types as types
from .grvt_raw_base import GrvtApiConfig, GrvtError, GrvtRawAsyncBase
# mypy: disable-error-code="no-any-return"
class GrvtRawAsync(GrvtRawAsyncBase):
def __init__(self, config: GrvtApiConfig):
super().__init__(config)
self.md_rpc = self.env.market_data.rpc_endpoint
self.td_rpc = self.env.trade_data.rpc_endpoint
async def get_instrument_v1(
self, req: types.ApiGetInstrumentRequest
) -> types.ApiGetInstrumentResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/instrument", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetInstrumentResponse, resp, Config(cast=[Enum]))
async def get_all_instruments_v1(
self, req: types.ApiGetAllInstrumentsRequest
) -> types.ApiGetAllInstrumentsResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/all_instruments", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetAllInstrumentsResponse, resp, Config(cast=[Enum]))
async def get_filtered_instruments_v1(
self, req: types.ApiGetFilteredInstrumentsRequest
) -> types.ApiGetFilteredInstrumentsResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/instruments", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiGetFilteredInstrumentsResponse, resp, Config(cast=[Enum])
)
async def get_currency_v1(
self, req: types.ApiGetCurrencyRequest
) -> types.ApiGetCurrencyResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/currency", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetCurrencyResponse, resp, Config(cast=[Enum]))
async def mini_ticker_v1(
self, req: types.ApiMiniTickerRequest
) -> types.ApiMiniTickerResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/mini", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiMiniTickerResponse, resp, Config(cast=[Enum]))
async def ticker_v1(
self, req: types.ApiTickerRequest
) -> types.ApiTickerResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/ticker", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTickerResponse, resp, Config(cast=[Enum]))
async def orderbook_levels_v1(
self, req: types.ApiOrderbookLevelsRequest
) -> types.ApiOrderbookLevelsResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/book", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiOrderbookLevelsResponse, resp, Config(cast=[Enum]))
async def trade_v1(
self, req: types.ApiTradeRequest
) -> types.ApiTradeResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/trade", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTradeResponse, resp, Config(cast=[Enum]))
async def trade_history_v1(
self, req: types.ApiTradeHistoryRequest
) -> types.ApiTradeHistoryResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/trade_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTradeHistoryResponse, resp, Config(cast=[Enum]))
async def candlestick_v1(
self, req: types.ApiCandlestickRequest
) -> types.ApiCandlestickResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/kline", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiCandlestickResponse, resp, Config(cast=[Enum]))
async def funding_rate_v1(
self, req: types.ApiFundingRateRequest
) -> types.ApiFundingRateResponse | GrvtError:
resp = await self._post(False, self.md_rpc + "/full/v1/funding", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiFundingRateResponse, resp, Config(cast=[Enum]))
async def create_order_v1(
self, req: types.ApiCreateOrderRequest
) -> types.ApiCreateOrderResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/create_order", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiCreateOrderResponse, resp, Config(cast=[Enum]))
async def cancel_order_v1(
self, req: types.ApiCancelOrderRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/cancel_order", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def cancel_all_orders_v1(
self, req: types.ApiCancelAllOrdersRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/cancel_all_orders", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def get_order_v1(
self, req: types.ApiGetOrderRequest
) -> types.ApiGetOrderResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/order", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetOrderResponse, resp, Config(cast=[Enum]))
async def open_orders_v1(
self, req: types.ApiOpenOrdersRequest
) -> types.ApiOpenOrdersResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/open_orders", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiOpenOrdersResponse, resp, Config(cast=[Enum]))
async def order_history_v1(
self, req: types.ApiOrderHistoryRequest
) -> types.ApiOrderHistoryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/order_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiOrderHistoryResponse, resp, Config(cast=[Enum]))
async def cancel_on_disconnect_v1(
self, req: types.ApiCancelOnDisconnectRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/cancel_on_disconnect", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def fill_history_v1(
self, req: types.ApiFillHistoryRequest
) -> types.ApiFillHistoryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/fill_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiFillHistoryResponse, resp, Config(cast=[Enum]))
async def positions_v1(
self, req: types.ApiPositionsRequest
) -> types.ApiPositionsResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/positions", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiPositionsResponse, resp, Config(cast=[Enum]))
async def funding_payment_history_v1(
self, req: types.ApiFundingPaymentHistoryRequest
) -> types.ApiFundingPaymentHistoryResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/funding_payment_history", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiFundingPaymentHistoryResponse, resp, Config(cast=[Enum])
)
async def deposit_history_v1(
self, req: types.ApiDepositHistoryRequest
) -> types.ApiDepositHistoryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/deposit_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiDepositHistoryResponse, resp, Config(cast=[Enum]))
async def transfer_v1(
self, req: types.ApiTransferRequest
) -> types.ApiTransferResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/transfer", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTransferResponse, resp, Config(cast=[Enum]))
async def transfer_history_v1(
self, req: types.ApiTransferHistoryRequest
) -> types.ApiTransferHistoryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/transfer_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTransferHistoryResponse, resp, Config(cast=[Enum]))
async def withdrawal_v1(
self, req: types.ApiWithdrawalRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/withdrawal", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def withdrawal_history_v1(
self, req: types.ApiWithdrawalHistoryRequest
) -> types.ApiWithdrawalHistoryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/withdrawal_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiWithdrawalHistoryResponse, resp, Config(cast=[Enum]))
async def sub_account_summary_v1(
self, req: types.ApiSubAccountSummaryRequest
) -> types.ApiSubAccountSummaryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/account_summary", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiSubAccountSummaryResponse, resp, Config(cast=[Enum]))
async def sub_account_history_v1(
self, req: types.ApiSubAccountHistoryRequest
) -> types.ApiSubAccountHistoryResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/account_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiSubAccountHistoryResponse, resp, Config(cast=[Enum]))
async def aggregated_account_summary_v1(
self, req: types.EmptyRequest
) -> types.ApiAggregatedAccountSummaryResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/aggregated_account_summary", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiAggregatedAccountSummaryResponse, resp, Config(cast=[Enum])
)
async def funding_account_summary_v1(
self, req: types.EmptyRequest
) -> types.ApiFundingAccountSummaryResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/funding_account_summary", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiFundingAccountSummaryResponse, resp, Config(cast=[Enum])
)
async def set_derisk_mm_ratio_v1(
self, req: types.ApiSetDeriskToMaintenanceMarginRatioRequest
) -> types.ApiSetDeriskToMaintenanceMarginRatioResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/set_derisk_mm_ratio", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiSetDeriskToMaintenanceMarginRatioResponse, resp, Config(cast=[Enum])
)
async def get_all_initial_leverage_v1(
self, req: types.ApiGetAllInitialLeverageRequest
) -> types.ApiGetAllInitialLeverageResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/get_all_initial_leverage", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiGetAllInitialLeverageResponse, resp, Config(cast=[Enum])
)
async def set_initial_leverage_v1(
self, req: types.ApiSetInitialLeverageRequest
) -> types.ApiSetInitialLeverageResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/set_initial_leverage", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiSetInitialLeverageResponse, resp, Config(cast=[Enum]))
async def vault_burn_tokens_v1(
self, req: types.ApiVaultBurnTokensRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/vault_burn_tokens", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def vault_invest_v1(
self, req: types.ApiVaultInvestRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/vault_invest", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def vault_investor_summary_v1(
self, req: types.ApiVaultInvestorSummaryRequest
) -> types.ApiVaultInvestorSummaryResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/vault_investor_summary", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiVaultInvestorSummaryResponse, resp, Config(cast=[Enum]))
async def vault_redeem_v1(
self, req: types.ApiVaultRedeemRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/vault_redeem", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def vault_redeem_cancel_v1(
self, req: types.ApiVaultRedeemCancelRequest
) -> types.AckResponse | GrvtError:
resp = await self._post(True, self.td_rpc + "/full/v1/vault_redeem_cancel", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
async def vault_redemption_queue_v1(
self, req: types.ApiVaultViewRedemptionQueueRequest
) -> types.ApiVaultViewRedemptionQueueResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/vault_view_redemption_queue", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiVaultViewRedemptionQueueResponse, resp, Config(cast=[Enum])
)
async def query_vault_manager_investor_history_v1(
self, req: types.ApiQueryVaultManagerInvestorHistoryRequest
) -> types.ApiQueryVaultManagerInvestorHistoryResponse | GrvtError:
resp = await self._post(
True, self.td_rpc + "/full/v1/vault_manager_investor_history", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiQueryVaultManagerInvestorHistoryResponse, resp, Config(cast=[Enum])
)
@@ -0,0 +1,267 @@
import dataclasses
import json
import logging
import time
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from http.cookies import SimpleCookie
from typing import Any
import aiohttp
import requests # type: ignore
from eth_account import Account
from .grvt_raw_env import GrvtEnv, GrvtEnvConfig, get_env_config
@dataclass
class GrvtApiConfig:
env: GrvtEnv
trading_account_id: str | None
private_key: str | None
api_key: str | None
logger: logging.Logger | None
@dataclass
class GrvtError:
code: int
message: str
status: int
@dataclass
class GrvtCookie:
gravity: str
expires: datetime
grvt_account_id: str | None = None
class GrvtRawBase:
"""
GrvtRawBase is base class for Grvt Rest API classes.
This should not be used directly, but rather through a derivative API class.
"""
def __init__(self, config: GrvtApiConfig):
self.config = config
self.env: GrvtEnvConfig = get_env_config(config.env)
self.logger: logging.Logger = config.logger or logging.getLogger(__name__)
self._cookie: GrvtCookie | None = None
if self.config.private_key is not None:
self.account: Account = Account.from_key(self.config.private_key)
"""
Cookie handling
"""
def _should_refresh_cookie(self) -> bool:
if not self.config.api_key:
raise ValueError("Attempting to use Authenticated API without API key set")
time_till_expiration = None
if self._cookie and self._cookie.expires:
time_till_expiration = self._cookie.expires.timestamp() - time.time()
is_cookie_fresh = time_till_expiration is not None and time_till_expiration > 5
if not is_cookie_fresh:
self.logger.info(
f"cookie should be refreshed now={time.time()}"
f" {time_till_expiration=} secs"
)
return not is_cookie_fresh
class GrvtRawSyncBase(GrvtRawBase):
def __init__(self, config: GrvtApiConfig):
super().__init__(config)
# Sync API session
self._session: requests.Session = requests.Session()
self._session.headers.update({"Content-Type": "application/json"})
"""
Cookie handling
"""
def _refresh_cookie(self) -> None:
if not self._should_refresh_cookie():
return None
# Get cookie
self._cookie = self._get_cookie(
self.env.edge.rpc_endpoint + "/auth/api_key/login", str(self.config.api_key)
)
self.logger.info(f"refresh_cookie cookie={self._cookie}")
# Update cookie in session
if self._cookie:
self._session.cookies.update({"gravity": self._cookie.gravity})
if self._cookie.grvt_account_id:
self._session.headers.update(
{"X-Grvt-Account-Id": self._cookie.grvt_account_id}
)
return None
def _get_cookie(self, path: str, api_key: str) -> GrvtCookie | None:
FN = f"_get_cookie {path=}"
try:
return_value = self._session.post(
path,
json={"api_key": api_key},
headers={"Content-Type": "application/json"},
timeout=5,
)
self.logger.info(f"{FN} {return_value=}")
if return_value.ok:
cookie = SimpleCookie()
cookie_header = return_value.headers.get("Set-Cookie")
grvt_cookie = return_value.cookies.get("gravity")
self.logger.info(
f"{FN} OK {return_value.headers=} \n "
f"{return_value.cookies=}\n{grvt_cookie=}\n{cookie_header=}"
)
cookie.load(cookie_header)
cookie_value = cookie["gravity"].value
cookie_expiry = datetime.strptime(
cookie["gravity"]["expires"],
"%a, %d %b %Y %H:%M:%S %Z",
)
grvt_account_id: str | None = return_value.headers.get(
"X-Grvt-Account-Id"
)
return GrvtCookie(
gravity=cookie_value,
expires=cookie_expiry,
grvt_account_id=grvt_account_id,
)
return None
except Exception as e:
self.logger.error(f"{FN} Error getting cookie: {e}")
return None
"""
Post handling
"""
def _post(self, is_auth: bool, path: str, req: Any) -> Any:
FN = f"_post {path=}"
# Always see if need to referesh cookie before sending an authenticated request
if is_auth:
self._refresh_cookie()
req_json = json.dumps(req, cls=DataclassJSONEncoder)
resp_json: Any = {}
self.logger.debug(f"{FN} {req_json=}")
resp: requests.Response = self._session.post(path, data=req_json, timeout=5)
try:
resp_json = resp.json()
if not resp.ok:
self.logger.warning(f"{FN} Error {resp_json=}")
else:
self.logger.debug(f"{FN} OK {resp_json=}")
except Exception as err:
self.logger.error(f"{FN} Unable to parse {resp.text=} as json:{err=}")
return resp_json
class GrvtRawAsyncBase(GrvtRawBase):
def __init__(self, config: GrvtApiConfig):
super().__init__(config)
# Async API session
self._session: aiohttp.ClientSession = aiohttp.ClientSession(
headers={"Content-Type": "application/json"}
)
"""
Cookie handling
"""
async def _refresh_cookie(self) -> None:
if not self._should_refresh_cookie():
return None
# Get cookie
self._cookie = await self._get_cookie(
self.env.edge.rpc_endpoint + "/auth/api_key/login", str(self.config.api_key)
)
self.logger.info(f"refresh_cookie cookie={self._cookie}")
# Update cookie in session
if self._cookie:
self._session.cookie_jar.update_cookies({"gravity": self._cookie.gravity})
if self._cookie.grvt_account_id:
self._session.headers.update(
{"X-Grvt-Account-Id": self._cookie.grvt_account_id}
)
return None
async def _get_cookie(self, path: str, api_key: str) -> GrvtCookie | None:
FN = f"_get_cookie {path=}"
try:
data = {"api_key": api_key}
self.logger.info(f"{FN} ask for cookie {path=} {data=}")
async with aiohttp.ClientSession() as session:
async with session.post(url=path, json=data, timeout=5) as return_value:
self.logger.info(f"{FN} {return_value=}")
if return_value.ok:
cookie = SimpleCookie()
cookie_header = return_value.headers.get("Set-Cookie")
grvt_cookie = return_value.cookies.get("gravity")
self.logger.info(
f"{FN} OK {return_value.headers=} \n "
f"{return_value.cookies=}\n{grvt_cookie=}\n{cookie_header=}"
)
cookie.load(cookie_header)
cookie_value = cookie["gravity"].value
cookie_expiry = datetime.strptime(
cookie["gravity"]["expires"],
"%a, %d %b %Y %H:%M:%S %Z",
)
grvt_account_id: str | None = return_value.headers.get(
"X-Grvt-Account-Id"
)
return GrvtCookie(
gravity=cookie_value,
expires=cookie_expiry,
grvt_account_id=grvt_account_id,
)
return None
except Exception as e:
self.logger.error(f"{FN} Error getting cookie: {e}")
return None
"""
Post handling
"""
async def _post(self, is_auth: bool, path: str, req: Any) -> Any:
FN = f"_post {path=}"
# Always see if need to referesh cookie before sending an authenticated request
if is_auth:
await self._refresh_cookie()
req_json = json.dumps(req, cls=DataclassJSONEncoder)
resp_json: Any = {}
self.logger.debug(f"{FN} {req_json=}")
resp: aiohttp.ClientResponse = await self._session.post(
path, data=req_json, timeout=5
)
try:
resp_text = await resp.text()
resp_json = json.loads(resp_text)
if not resp.ok:
self.logger.warning(f"{FN} Error {resp_text=}")
else:
self.logger.debug(f"{FN} OK {resp_text=}")
except Exception as err:
self.logger.error(f"{FN} Unable to parse {resp_text=} as json:{err=}")
return resp_json
class DataclassJSONEncoder(json.JSONEncoder):
def default(self, o: Any) -> Any:
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o) # type: ignore
if isinstance(o, Enum):
return o.value
return super().default(o)
@@ -0,0 +1,77 @@
from dataclasses import dataclass
from enum import Enum
class GrvtEnv(Enum):
DEV = "dev"
STAGING = "staging"
TESTNET = "testnet"
PROD = "prod"
@dataclass
class GrvtEndpointConfig:
rpc_endpoint: str
ws_endpoint: str | None
@dataclass
class GrvtEnvConfig:
edge: GrvtEndpointConfig
trade_data: GrvtEndpointConfig
market_data: GrvtEndpointConfig
chain_id: int
def get_env_config(environment: GrvtEnv) -> GrvtEnvConfig:
match environment:
case GrvtEnv.PROD:
return GrvtEnvConfig(
edge=GrvtEndpointConfig(
rpc_endpoint="https://edge.grvt.io",
ws_endpoint=None,
),
trade_data=GrvtEndpointConfig(
rpc_endpoint="https://trades.grvt.io",
ws_endpoint="wss://trades.grvt.io/ws",
),
market_data=GrvtEndpointConfig(
rpc_endpoint="https://market-data.grvt.io",
ws_endpoint="wss://market-data.grvt.io/ws",
),
chain_id=325,
)
case GrvtEnv.TESTNET:
return GrvtEnvConfig(
edge=GrvtEndpointConfig(
rpc_endpoint=f"https://edge.{environment.value}.grvt.io",
ws_endpoint=None,
),
trade_data=GrvtEndpointConfig(
rpc_endpoint=f"https://trades.{environment.value}.grvt.io",
ws_endpoint=f"wss://trades.{environment.value}.grvt.io/ws",
),
market_data=GrvtEndpointConfig(
rpc_endpoint=f"https://market-data.{environment.value}.grvt.io",
ws_endpoint=f"wss://market-data.{environment.value}.grvt.io/ws",
),
chain_id=326,
)
case GrvtEnv.DEV | GrvtEnv.STAGING:
return GrvtEnvConfig(
edge=GrvtEndpointConfig(
rpc_endpoint=f"https://edge.{environment.value}.gravitymarkets.io",
ws_endpoint=None,
),
trade_data=GrvtEndpointConfig(
rpc_endpoint=f"https://trades.{environment.value}.gravitymarkets.io",
ws_endpoint=f"wss://trades.{environment.value}.gravitymarkets.io/ws",
),
market_data=GrvtEndpointConfig(
rpc_endpoint=f"https://market-data.{environment.value}.gravitymarkets.io",
ws_endpoint=f"wss://market-data.{environment.value}.gravitymarkets.io/ws",
),
chain_id=327 if environment == GrvtEnv.DEV else 328,
)
case _:
raise ValueError(f"Unknown environment={environment}")
@@ -0,0 +1,248 @@
from enum import Enum
from decimal import Decimal
from typing import Any, Optional
from eth_account import Account
from eth_account.messages import encode_typed_data
from .grvt_ccxt_utils import GrvtCurrency
from .grvt_raw_base import GrvtApiConfig, GrvtEnv
from .grvt_raw_types import Instrument, Order, Withdrawal, TimeInForce
from .grvt_fixed_types import Transfer
#########################
# INSTRUMENT CONVERSION #
#########################
PRICE_MULTIPLIER = 1_000_000_000
class SignTimeInForce(Enum):
GOOD_TILL_TIME = 1
ALL_OR_NONE = 2
IMMEDIATE_OR_CANCEL = 3
FILL_OR_KILL = 4
TIME_IN_FORCE_TO_SIGN_TIME_IN_FORCE = {
TimeInForce.GOOD_TILL_TIME: SignTimeInForce.GOOD_TILL_TIME,
TimeInForce.ALL_OR_NONE: SignTimeInForce.ALL_OR_NONE,
TimeInForce.IMMEDIATE_OR_CANCEL: SignTimeInForce.IMMEDIATE_OR_CANCEL,
TimeInForce.FILL_OR_KILL: SignTimeInForce.FILL_OR_KILL,
}
#####################
# EIP-712 chain IDs #
#####################
CHAIN_IDS = {
GrvtEnv.DEV: 327,
GrvtEnv.STAGING: 327,
GrvtEnv.TESTNET: 326,
GrvtEnv.PROD: 325,
}
def get_EIP712_domain_data(env: GrvtEnv, chainId: int | None) -> dict[str, str | int]:
return {
"name": "GRVT Exchange",
"version": "0",
"chainId": chainId or CHAIN_IDS[env],
}
#####################
# Sign Order #
#####################
EIP712_ORDER_MESSAGE_TYPE = {
"Order": [
{"name": "subAccountID", "type": "uint64"},
{"name": "isMarket", "type": "bool"},
{"name": "timeInForce", "type": "uint8"},
{"name": "postOnly", "type": "bool"},
{"name": "reduceOnly", "type": "bool"},
{"name": "legs", "type": "OrderLeg[]"},
{"name": "nonce", "type": "uint32"},
{"name": "expiration", "type": "int64"},
],
"OrderLeg": [
{"name": "assetID", "type": "uint256"},
{"name": "contractSize", "type": "uint64"},
{"name": "limitPrice", "type": "uint64"},
{"name": "isBuyingContract", "type": "bool"},
],
}
def sign_order(
order: Order,
config: GrvtApiConfig,
account: Account,
instruments: dict[str, Instrument],
) -> Order:
if config.private_key is None:
raise ValueError("Private key is not set")
message_data = build_EIP712_order_message_data(order, instruments)
domain_data = get_EIP712_domain_data(config.env, CHAIN_IDS[config.env])
signable_message = encode_typed_data(
domain_data, EIP712_ORDER_MESSAGE_TYPE, message_data
)
signed_message = account.sign_message(signable_message)
order.signature.s = "0x" + signed_message.s.to_bytes(32, byteorder="big").hex()
order.signature.r = "0x" + signed_message.r.to_bytes(32, byteorder="big").hex()
order.signature.v = signed_message.v
order.signature.signer = str(account.address)
return order
def build_EIP712_order_message_data(
order: Order, instruments: dict[str, Instrument]
) -> dict[str, Any]:
legs = []
for leg in order.legs:
instrument = instruments[leg.instrument]
size_multiplier = 10**instrument.base_decimals
# use Decimal() instead of float() to avoid precision loss
# int(float("1.013") * 1e9) = 1012999999
# int(Decimal("1.013") * Decimal(1e9) = 1013000000
size_int = int(Decimal(leg.size) * Decimal(size_multiplier))
price_int = int(Decimal(leg.limit_price) * Decimal(PRICE_MULTIPLIER))
legs.append(
{
"assetID": instrument.instrument_hash,
"contractSize": size_int,
"limitPrice": price_int,
"isBuyingContract": leg.is_buying_asset,
}
)
return {
"subAccountID": order.sub_account_id,
"isMarket": order.is_market or False,
"timeInForce": TIME_IN_FORCE_TO_SIGN_TIME_IN_FORCE[order.time_in_force].value,
"postOnly": order.post_only or False,
"reduceOnly": order.reduce_only or False,
"legs": legs,
"nonce": order.signature.nonce,
"expiration": order.signature.expiration,
}
#####################
# Sign Transfer #
#####################
EIP712_TRANSFER_MESSAGE_TYPE = {
"Transfer": [
{"name": "fromAccount", "type": "address"},
{"name": "fromSubAccount", "type": "uint64"},
{"name": "toAccount", "type": "address"},
{"name": "toSubAccount", "type": "uint64"},
{"name": "tokenCurrency", "type": "uint8"},
{"name": "numTokens", "type": "uint64"},
{"name": "nonce", "type": "uint32"},
{"name": "expiration", "type": "int64"},
],
}
def build_EIP712_transfer_message_data(transfer: Transfer, currencyId: int):
return {
"fromAccount": transfer.from_account_id,
"fromSubAccount": transfer.from_sub_account_id,
"toAccount": transfer.to_account_id,
"toSubAccount": transfer.to_sub_account_id,
"tokenCurrency": currencyId,
"numTokens": int(
Decimal(transfer.num_tokens) * Decimal(1e6)
), # USDT has 6 decimals
"nonce": transfer.signature.nonce,
"expiration": transfer.signature.expiration,
}
def sign_transfer(
transfer: Transfer,
config: GrvtApiConfig,
account: Account,
chainId: int | None = None,
currencyId: int = 3, # currencyId of USDT; refer to Get Currency API
) -> Transfer:
if config.private_key is None:
raise ValueError("Private key is not set")
domain = get_EIP712_domain_data(config.env, chainId)
message_data = build_EIP712_transfer_message_data(transfer, currencyId)
signable_message = encode_typed_data(
domain, EIP712_TRANSFER_MESSAGE_TYPE, message_data
)
signed_message = account.sign_message(signable_message)
transfer.signature.r = "0x" + signed_message.r.to_bytes(32, byteorder="big").hex()
transfer.signature.s = "0x" + signed_message.s.to_bytes(32, byteorder="big").hex()
transfer.signature.v = signed_message.v
transfer.signature.signer = str(account.address)
return transfer
#####################
# Sign Withdrawal #
#####################
EIP712_WITHDRAWAL_MESSAGE_TYPE = {
"Withdrawal": [
{"name": "fromAccount", "type": "address"},
{"name": "toEthAddress", "type": "address"},
{"name": "tokenCurrency", "type": "uint8"},
{"name": "numTokens", "type": "uint64"},
{"name": "nonce", "type": "uint32"},
{"name": "expiration", "type": "int64"},
],
}
def build_EIP712_withdrawal_message_data(withdrawal: Withdrawal, currencyId: int):
return {
"fromAccount": withdrawal.from_account_id,
"toEthAddress": withdrawal.to_eth_address,
"tokenCurrency": currencyId,
"numTokens": int(
Decimal(withdrawal.num_tokens) * Decimal(1e6)
), # USDT has 6 decimals
"nonce": withdrawal.signature.nonce,
"expiration": withdrawal.signature.expiration,
}
def sign_withdrawal(
withdrawal: Withdrawal,
config: GrvtApiConfig,
account: Account,
chainId: int | None = None,
currencyId: int = 3, # currencyId of USDT; refer to Get Currency API
) -> Withdrawal:
if config.private_key is None:
raise ValueError("Private key is not set")
domain = get_EIP712_domain_data(config.env, chainId)
message_data = build_EIP712_withdrawal_message_data(withdrawal, currencyId)
signable_message = encode_typed_data(
domain, EIP712_WITHDRAWAL_MESSAGE_TYPE, message_data
)
signed_message = account.sign_message(signable_message)
withdrawal.signature.r = "0x" + signed_message.r.to_bytes(32, byteorder="big").hex()
withdrawal.signature.s = "0x" + signed_message.s.to_bytes(32, byteorder="big").hex()
withdrawal.signature.v = signed_message.v
withdrawal.signature.signer = str(account.address)
return withdrawal
@@ -0,0 +1,351 @@
from enum import Enum
from dacite import Config, from_dict
from . import grvt_raw_types as types
from .grvt_raw_base import GrvtApiConfig, GrvtError, GrvtRawSyncBase
# mypy: disable-error-code="no-any-return"
class GrvtRawSync(GrvtRawSyncBase):
def __init__(self, config: GrvtApiConfig):
super().__init__(config)
self.md_rpc = self.env.market_data.rpc_endpoint
self.td_rpc = self.env.trade_data.rpc_endpoint
def get_instrument_v1(
self, req: types.ApiGetInstrumentRequest
) -> types.ApiGetInstrumentResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/instrument", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetInstrumentResponse, resp, Config(cast=[Enum]))
def get_all_instruments_v1(
self, req: types.ApiGetAllInstrumentsRequest
) -> types.ApiGetAllInstrumentsResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/all_instruments", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetAllInstrumentsResponse, resp, Config(cast=[Enum]))
def get_filtered_instruments_v1(
self, req: types.ApiGetFilteredInstrumentsRequest
) -> types.ApiGetFilteredInstrumentsResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/instruments", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiGetFilteredInstrumentsResponse, resp, Config(cast=[Enum])
)
def get_currency_v1(
self, req: types.ApiGetCurrencyRequest
) -> types.ApiGetCurrencyResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/currency", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetCurrencyResponse, resp, Config(cast=[Enum]))
def mini_ticker_v1(
self, req: types.ApiMiniTickerRequest
) -> types.ApiMiniTickerResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/mini", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiMiniTickerResponse, resp, Config(cast=[Enum]))
def ticker_v1(
self, req: types.ApiTickerRequest
) -> types.ApiTickerResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/ticker", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTickerResponse, resp, Config(cast=[Enum]))
def orderbook_levels_v1(
self, req: types.ApiOrderbookLevelsRequest
) -> types.ApiOrderbookLevelsResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/book", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiOrderbookLevelsResponse, resp, Config(cast=[Enum]))
def trade_v1(self, req: types.ApiTradeRequest) -> types.ApiTradeResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/trade", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTradeResponse, resp, Config(cast=[Enum]))
def trade_history_v1(
self, req: types.ApiTradeHistoryRequest
) -> types.ApiTradeHistoryResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/trade_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTradeHistoryResponse, resp, Config(cast=[Enum]))
def candlestick_v1(
self, req: types.ApiCandlestickRequest
) -> types.ApiCandlestickResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/kline", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiCandlestickResponse, resp, Config(cast=[Enum]))
def funding_rate_v1(
self, req: types.ApiFundingRateRequest
) -> types.ApiFundingRateResponse | GrvtError:
resp = self._post(False, self.md_rpc + "/full/v1/funding", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiFundingRateResponse, resp, Config(cast=[Enum]))
def create_order_v1(
self, req: types.ApiCreateOrderRequest
) -> types.ApiCreateOrderResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/create_order", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiCreateOrderResponse, resp, Config(cast=[Enum]))
def cancel_order_v1(
self, req: types.ApiCancelOrderRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/cancel_order", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def cancel_all_orders_v1(
self, req: types.ApiCancelAllOrdersRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/cancel_all_orders", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def get_order_v1(
self, req: types.ApiGetOrderRequest
) -> types.ApiGetOrderResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/order", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiGetOrderResponse, resp, Config(cast=[Enum]))
def open_orders_v1(
self, req: types.ApiOpenOrdersRequest
) -> types.ApiOpenOrdersResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/open_orders", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiOpenOrdersResponse, resp, Config(cast=[Enum]))
def order_history_v1(
self, req: types.ApiOrderHistoryRequest
) -> types.ApiOrderHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/order_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiOrderHistoryResponse, resp, Config(cast=[Enum]))
def cancel_on_disconnect_v1(
self, req: types.ApiCancelOnDisconnectRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/cancel_on_disconnect", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def fill_history_v1(
self, req: types.ApiFillHistoryRequest
) -> types.ApiFillHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/fill_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiFillHistoryResponse, resp, Config(cast=[Enum]))
def positions_v1(
self, req: types.ApiPositionsRequest
) -> types.ApiPositionsResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/positions", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiPositionsResponse, resp, Config(cast=[Enum]))
def funding_payment_history_v1(
self, req: types.ApiFundingPaymentHistoryRequest
) -> types.ApiFundingPaymentHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/funding_payment_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiFundingPaymentHistoryResponse, resp, Config(cast=[Enum])
)
def deposit_history_v1(
self, req: types.ApiDepositHistoryRequest
) -> types.ApiDepositHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/deposit_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiDepositHistoryResponse, resp, Config(cast=[Enum]))
def transfer_v1(
self, req: types.ApiTransferRequest
) -> types.ApiTransferResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/transfer", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTransferResponse, resp, Config(cast=[Enum]))
def transfer_history_v1(
self, req: types.ApiTransferHistoryRequest
) -> types.ApiTransferHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/transfer_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiTransferHistoryResponse, resp, Config(cast=[Enum]))
def withdrawal_v1(
self, req: types.ApiWithdrawalRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/withdrawal", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def withdrawal_history_v1(
self, req: types.ApiWithdrawalHistoryRequest
) -> types.ApiWithdrawalHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/withdrawal_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiWithdrawalHistoryResponse, resp, Config(cast=[Enum]))
def sub_account_summary_v1(
self, req: types.ApiSubAccountSummaryRequest
) -> types.ApiSubAccountSummaryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/account_summary", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiSubAccountSummaryResponse, resp, Config(cast=[Enum]))
def sub_account_history_v1(
self, req: types.ApiSubAccountHistoryRequest
) -> types.ApiSubAccountHistoryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/account_history", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiSubAccountHistoryResponse, resp, Config(cast=[Enum]))
def aggregated_account_summary_v1(
self, req: types.EmptyRequest
) -> types.ApiAggregatedAccountSummaryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/aggregated_account_summary", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiAggregatedAccountSummaryResponse, resp, Config(cast=[Enum])
)
def funding_account_summary_v1(
self, req: types.EmptyRequest
) -> types.ApiFundingAccountSummaryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/funding_account_summary", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiFundingAccountSummaryResponse, resp, Config(cast=[Enum])
)
def set_derisk_mm_ratio_v1(
self, req: types.ApiSetDeriskToMaintenanceMarginRatioRequest
) -> types.ApiSetDeriskToMaintenanceMarginRatioResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/set_derisk_mm_ratio", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiSetDeriskToMaintenanceMarginRatioResponse, resp, Config(cast=[Enum])
)
def get_all_initial_leverage_v1(
self, req: types.ApiGetAllInitialLeverageRequest
) -> types.ApiGetAllInitialLeverageResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/get_all_initial_leverage", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiGetAllInitialLeverageResponse, resp, Config(cast=[Enum])
)
def set_initial_leverage_v1(
self, req: types.ApiSetInitialLeverageRequest
) -> types.ApiSetInitialLeverageResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/set_initial_leverage", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiSetInitialLeverageResponse, resp, Config(cast=[Enum]))
def vault_burn_tokens_v1(
self, req: types.ApiVaultBurnTokensRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/vault_burn_tokens", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def vault_invest_v1(
self, req: types.ApiVaultInvestRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/vault_invest", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def vault_investor_summary_v1(
self, req: types.ApiVaultInvestorSummaryRequest
) -> types.ApiVaultInvestorSummaryResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/vault_investor_summary", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.ApiVaultInvestorSummaryResponse, resp, Config(cast=[Enum]))
def vault_redeem_v1(
self, req: types.ApiVaultRedeemRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/vault_redeem", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def vault_redeem_cancel_v1(
self, req: types.ApiVaultRedeemCancelRequest
) -> types.AckResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/vault_redeem_cancel", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(types.AckResponse, resp, Config(cast=[Enum]))
def vault_redemption_queue_v1(
self, req: types.ApiVaultViewRedemptionQueueRequest
) -> types.ApiVaultViewRedemptionQueueResponse | GrvtError:
resp = self._post(True, self.td_rpc + "/full/v1/vault_view_redemption_queue", req)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiVaultViewRedemptionQueueResponse, resp, Config(cast=[Enum])
)
def query_vault_manager_investor_history_v1(
self, req: types.ApiQueryVaultManagerInvestorHistoryRequest
) -> types.ApiQueryVaultManagerInvestorHistoryResponse | GrvtError:
resp = self._post(
True, self.td_rpc + "/full/v1/vault_manager_investor_history", req
)
if resp.get("code"):
return GrvtError(**resp)
return from_dict(
types.ApiQueryVaultManagerInvestorHistoryResponse, resp, Config(cast=[Enum])
)
File diff suppressed because it is too large Load Diff