mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 08:48:07 +00:00
544 lines
18 KiB
Python
544 lines
18 KiB
Python
# 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,
|
|
} |