mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
更新环境配置示例,添加 GRVT 相关的 API 凭证和选项,增强文档以支持新的交易所适配器,确保用户能够正确配置和使用 GRVT 交易功能。
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
UV_VERSION: "0.4.0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Set up uv
|
||||
run: curl -LsSf https://astral.sh/uv/${{ env.UV_VERSION }}/install.sh | sh
|
||||
- name: Restore uv cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.uv-cache
|
||||
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||
uv-${{ runner.os }}
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Test with pytest
|
||||
run: uv run pytest tests --cov=src
|
||||
- name: Minimize uv cache
|
||||
run: uv cache prune --ci
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -0,0 +1,39 @@
|
||||
# name: "codeql"
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ["main"]
|
||||
# pull_request:
|
||||
# branches: ["main"]
|
||||
|
||||
# jobs:
|
||||
# analyze:
|
||||
# name: Analyze
|
||||
# runs-on: ubuntu-24.04
|
||||
# permissions:
|
||||
# actions: read
|
||||
# contents: read
|
||||
# security-events: write
|
||||
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# language: [python]
|
||||
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Initialize CodeQL
|
||||
# uses: github/codeql-action/init@v3
|
||||
# with:
|
||||
# languages: ${{ matrix.language }}
|
||||
# queries: +security-and-quality
|
||||
|
||||
# - name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# - name: Perform CodeQL Analysis
|
||||
# uses: github/codeql-action/analyze@v3
|
||||
# with:
|
||||
# category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,61 @@
|
||||
name: pr
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10.15", "3.10.x"]
|
||||
uv-version: ["0.3.5", "0.4.0"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Set up uv
|
||||
run: curl -LsSf https://astral.sh/uv/${{ matrix.uv-version }}/install.sh | sh
|
||||
- name: Restore uv cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.uv-cache
|
||||
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||
uv-${{ runner.os }}
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Test with pytest
|
||||
run: uv run pytest tests --cov=src
|
||||
- name: Minimize uv cache
|
||||
run: uv cache prune --ci
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -0,0 +1,287 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,visualstudiocode
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,visualstudiocode
|
||||
|
||||
### PyCharm+all ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### PyCharm+all Patch ###
|
||||
# Ignore everything but code style settings and run configurations
|
||||
# that are supposed to be shared within teams.
|
||||
|
||||
.idea/*
|
||||
|
||||
!.idea/codeStyles
|
||||
!.idea/runConfigurations
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# Tests
|
||||
test_results.csv
|
||||
test_results_sync.csv
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,visualstudiocode
|
||||
@@ -0,0 +1,79 @@
|
||||
ci:
|
||||
skip: [pytest]
|
||||
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
|
||||
repos:
|
||||
# general checks (see here: https://pre-commit.com/hooks.html)
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
args: [--allow-multiple-documents]
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
# ruff - linting + formatting
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: "v0.6.3"
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: ruff
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
|
||||
# mypy - lint-like type checking
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.11.2
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: mypy
|
||||
|
||||
# docformatter - formats docstrings to follow PEP 257
|
||||
- repo: https://github.com/pycqa/docformatter
|
||||
rev: v1.7.5
|
||||
hooks:
|
||||
- id: docformatter
|
||||
name: docformatter
|
||||
args:
|
||||
[
|
||||
-r,
|
||||
-i,
|
||||
--pre-summary-newline,
|
||||
--make-summary-multi-line,
|
||||
--wrap-summaries,
|
||||
"90",
|
||||
--wrap-descriptions,
|
||||
"90",
|
||||
src,
|
||||
tests,
|
||||
]
|
||||
exclude: ^(artifacts/.*)$
|
||||
|
||||
# bandit - find common security issues
|
||||
- repo: https://github.com/pycqa/bandit
|
||||
rev: 1.7.9
|
||||
hooks:
|
||||
- id: bandit
|
||||
name: bandit
|
||||
exclude: ^tests/
|
||||
args:
|
||||
- -r
|
||||
- src
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: uv run pytest tests --cov=src
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
# prettier - formatting JS, CSS, JSON, Markdown, ...
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
exclude: ^(uv.lock)$
|
||||
@@ -0,0 +1 @@
|
||||
3.10
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:daily",
|
||||
"group:all",
|
||||
":prConcurrentLimitNone",
|
||||
":prHourlyLimitNone",
|
||||
":prImmediately"
|
||||
],
|
||||
"labels": ["dependencies"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## Version [0.2.1] - 2025-07-24
|
||||
|
||||
### Changes in 0.2.1
|
||||
|
||||
- New methods in ccxt-compatible code for Strategy (Vault) managers to view investment history and current redemption queue of a Vault.
|
||||
- Classes `GrvtCcxt` and `GrvtCcxtPro`
|
||||
- Methods `fetch_vault_manager_investor_history` and `fetch_vault_redemption_queue`
|
||||
|
||||
## Version [0.2.0] - 2025-07-16
|
||||
|
||||
### Changes in 0.2.0
|
||||
|
||||
- additional fixes for removal of explicit enums in `grvt_raw_types.py`
|
||||
- Note: this update would `break existing integrations` for `non-create-order flows` (e.g., transfer/withdrawal history, transfer, get open orders, get-instrument filters) to handle currency strings directly instead of enums.
|
||||
- users will be losing the currency enum when they upgrade, so they would need to call the currencies endpoin (<https://api-docs.grvt.io/market_data_api/#get-currency> ), for which the support has been added in PySDK (`get_currency_v1()` in grvt_raw_async.py and grvt_raw_sync.py), for the metadata attached to the currency strings.
|
||||
|
||||
## Version [0.1.32] - 2025-07-15
|
||||
|
||||
### Changes in 0.1.32
|
||||
|
||||
- removed explicit enums in `grvt_raw_types.py` (no need to update SDK for when new coins are listed)
|
||||
- added vault-related functionality
|
||||
|
||||
## Version [0.1.31] - 2025-07-08
|
||||
|
||||
### Changes in 0.1.31
|
||||
|
||||
- added AVAX in the raw code (testing purposes)
|
||||
|
||||
## Version [0.1.30] - 2025-07-08
|
||||
|
||||
### Changes in 0.1.30
|
||||
|
||||
- added `H` in the **raw** code
|
||||
|
||||
## Version [0.1.29] - 2025-06-30
|
||||
|
||||
### Changes in 0.1.29
|
||||
|
||||
- Added new currencies: `HYPE, UNI, MOODENG, LAUNCHCOIN` in the **raw** code
|
||||
- Improvements and type fixes in `fetch_funding_rate_history()` methods of GrvtCcxt and GrvtCcxtPro.
|
||||
|
||||
## Version [0.1.28] - 2025-06-02
|
||||
|
||||
### Changes in 0.1.28
|
||||
|
||||
- Renamed currency name `AI_16_Z` into `AI16Z` in the **raw** code to match the exchange currency name.
|
||||
|
||||
## Version [0.1.27] - 2025-05-19
|
||||
|
||||
### Changes in 0.1.27
|
||||
|
||||
- `GrvtCcxt` and `GrvtCcxtPro` classes:
|
||||
|
||||
- renamed method `fetch_balances()` to `fetch_balance()` as defined in ccxt.
|
||||
|
||||
## Version [0.1.26] - 2025-05-15
|
||||
|
||||
### Added in 0.1.26
|
||||
|
||||
- `GrvtCcxt` and `GrvtCcxtPro` classes:
|
||||
|
||||
- new method `describe()` - returns a list of public method names
|
||||
- new method `fetch_balances()` - returns dict with balances in ccxt format.
|
||||
- constructor parameter `order_book_ccxt_format: bool = False` . If = True then order book snapshots from `fetch_order_book()` are in ccxt format.
|
||||
|
||||
### Fixed in 0.1.26
|
||||
|
||||
- Issues with typing and lynting errors
|
||||
|
||||
## Version [0.1.25] - 2025-04-25
|
||||
|
||||
### Fixed in 0.1.25
|
||||
|
||||
- Issues with typing and lynting errors
|
||||
- Fixed bug in test_grvt_ccxt.py
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,59 @@
|
||||
# A Self-Documenting Makefile: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sed -e 's/^Makefile://' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
|
||||
.PHONY: run
|
||||
run: ## Run the project
|
||||
uv run python -m pysdk.main
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run the tests
|
||||
uv run pytest tests --cov=src
|
||||
|
||||
.PHONY: precommit
|
||||
precommit: ## Run the pre-commit hooks
|
||||
bash .git/hooks/pre-commit
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run the linter
|
||||
uv run ruff check .
|
||||
|
||||
.PHONY: lint-fix
|
||||
lint-fix: ## Run the linter and fix the issues
|
||||
uv run ruff check --fix --unsafe-fixes .
|
||||
|
||||
.PHONY: format
|
||||
format: ## Run the formatter
|
||||
uv run ruff format .
|
||||
|
||||
.PHONY: typecheck
|
||||
typecheck: ## Run the type checker
|
||||
uv run mypy .
|
||||
|
||||
.PHONY: security
|
||||
security: ## Run the security checker
|
||||
uv run bandit .
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean the project
|
||||
uv run ruff clean .
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the project
|
||||
uv sync --all-extras --dev --frozen
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the project
|
||||
uv build
|
||||
|
||||
.PHONY: publish
|
||||
publish: ## Publish the project
|
||||
python3 build_readme.py
|
||||
make build
|
||||
uv run twine upload --skip-existing dist/*
|
||||
|
||||
# ==============================================================================
|
||||
# always make sure the following is the last line on this file
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -0,0 +1,300 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/astral-sh/uv" target="blank"><img src="https://github.com/astral-sh/uv/blob/8674968a17e5f2ee0dda01d17aaf609f162939ca/docs/assets/logo-letter.svg" height="100" alt="uv logo" /></a>
|
||||
<a href="https://pre-commit.com/" target="blank"><img src="https://pre-commit.com/logo.svg" height="100" alt="pre-commit logo" /></a>
|
||||
<a href="https://github.com/astral-sh/ruff" target="blank"><img src="https://raw.githubusercontent.com/astral-sh/ruff/8c20f14e62ddaf7b6d62674f300f5d19cbdc5acb/docs/assets/bolt.svg" height="100" alt="ruff logo" style="background-color: #ef5552" /></a>
|
||||
<a href="https://bandit.readthedocs.io/" target="blank"><img src="https://raw.githubusercontent.com/pycqa/bandit/main/logo/logo.svg" height="100" alt="bandit logo" /></a>
|
||||
<a href="https://docs.pytest.org/" target="blank"><img src="https://raw.githubusercontent.com/pytest-dev/pytest/main/doc/en/img/pytest_logo_curves.svg" height="100" alt="pytest logo" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.docker.com/" target="blank"><img src="https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png" height="60" alt="Docker logo" /></a>
|
||||
<a href="https://github.com/features/actions" target="blank"><img src="https://avatars.githubusercontent.com/u/44036562" height="60" alt="GitHub Actions logo" /></a>
|
||||
</p>
|
||||
|
||||
# GRVT Python SDK
|
||||
|
||||
[](https://github.com/smarlhens/python-boilerplate/actions/workflows/codeql.yml)
|
||||
[](https://github.com/smarlhens/python-boilerplate/actions/workflows/ci.yml)
|
||||
[](https://github.com/smarlhens/python-boilerplate)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [GRVT Python SDK](#grvt-python-sdk)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [What's in the library](#whats-in-the-library)
|
||||
- [Environments](#environments)
|
||||
- [Files](#files)
|
||||
- [Installation via pip](#installation-via-pip)
|
||||
- [Configuration](#configuration)
|
||||
- [Usage](#usage)
|
||||
- [Contributor's guide](#contributors-guide)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation of a source code](#installation-of-a-source-code)
|
||||
- [Manually run example files](#manually-run-example-files)
|
||||
- [What's in the box ?](#whats-in-the-box-)
|
||||
- [uv](#uv)
|
||||
- [pre-commit](#pre-commit)
|
||||
- [ruff](#ruff)
|
||||
- [mypy](#mypy)
|
||||
- [bandit](#bandit)
|
||||
- [docformatter](#docformatter)
|
||||
- [Testing](#testing)
|
||||
- [Makefile](#makefile)
|
||||
|
||||
---
|
||||
|
||||
## What's in the library
|
||||
|
||||
GRVT Python SDK library provides Python classes and utility methods for easy access to GRVT API endpoints across all environments.
|
||||
|
||||
### Environments
|
||||
|
||||
- `prod` - Production environment.
|
||||
- `testnet` - Testnet environment.
|
||||
- `staging` - Development Integration environment.
|
||||
- `dev` - Development environment.
|
||||
|
||||
SDK Library provides two types of classes:
|
||||
|
||||
1. **Raw** - thin wrapper classes around Rest API.
|
||||
2. **Ccxt-compatible** - classes with ccxt-like methods. Provide access to both Rest API and WebSockets.
|
||||
|
||||
### Files
|
||||
|
||||
Raw access:
|
||||
|
||||
- `grvt_raw_base.py` - base classes for Rest API access.
|
||||
- `grvt_raw_env.py` - definitions of environments for raw access.
|
||||
- `grvt_raw_signing.py` - utility methods for signing orders.
|
||||
- `grvt_raw_sync.py` - class for raw synchronous calls to Rest API.
|
||||
- `grvt_raw_async.py` - class for raw asynchronous calls to Rest API.
|
||||
|
||||
CCXT-compatible access:
|
||||
|
||||
- `grvt_ccxt_utils.py` - utility methods for signing orders.
|
||||
- `grvt_ccxt_env.py` - definitions of environments for ccxt-like access.
|
||||
- `grvt_ccxt_base.py` - base class for Rest API access.
|
||||
- `grvt_ccxt.py` - class for synchronous calls to Rest API.
|
||||
- `grvt_ccxt_pro.py` - class for asynchronous calls to Rest API.
|
||||
- `grvt_ccxt_ws.py` - class for WebSocket calls.
|
||||
|
||||
## Installation via pip
|
||||
|
||||
```bash
|
||||
pip install grvt-pysdk
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Setup these environment variables:
|
||||
|
||||
```bash
|
||||
export GRVT_PRIVATE_KEY="`Secret Private Key` in API setup"
|
||||
export GRVT_API_KEY="`API Key` in API setup"
|
||||
export GRVT_TRADING_ACCOUNT_ID=<`Trading account ID` in API>
|
||||
export GRVT_ENV="testnet"
|
||||
export GRVT_END_POINT_VERSION="v1"
|
||||
export GRVT_WS_STREAM_VERSION="v1"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
**Examples of how to use various methods to connect to GRVT API:**
|
||||
|
||||
- [GRVT CCXT](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_ccxt.py) - Example of usage of CCXT-compatible Python class `GrvtCcxt` with `synchronous` Rest API calls.
|
||||
- [GRVT CCXT Pro](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_ccxt_pro.py) - Example of usage of CCXT.PRO-compatible Python class `GrvtCcxtPro` with `asynchronous` Rest API calls.
|
||||
- [GRVT CCXT WS](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_ccxt_ws.py) - Example of usage of CCXT.PRO-compatible Python class `GrvtCcxtWS` with `asynchronous` Rest API calls + Web Socket subscriptions + JSON RPC calls over Web Sockets.
|
||||
- [GRVT API Sync](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_raw_sync.py) - Synchronous API client for GRVT
|
||||
- [GRVT API Async](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_raw_async.py) - Asynchronous API client for GRVT
|
||||
|
||||
## Contributor's guide
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Python](https://www.python.org/downloads/) **>=3.10.0 < 3.13** (_tested with 3.10.15_)
|
||||
- [pre-commit](https://pre-commit.com/#install)
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/) **>=0.3.3** (_tested with 0.4.0_)
|
||||
- [docker](https://docs.docker.com/get-docker/) (_optional_)
|
||||
|
||||
### Installation of a source code
|
||||
|
||||
1. Clone the git repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/grvt-technologies/grvt-pysdk.git
|
||||
```
|
||||
|
||||
2. Go into the project directory
|
||||
|
||||
```bash
|
||||
cd grvt-pysdk/
|
||||
```
|
||||
|
||||
3. Checkout working branch
|
||||
|
||||
```bash
|
||||
git checkout <branch>
|
||||
```
|
||||
|
||||
4. Install dependencies
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
5. Enable pre-commit hooks
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Manually run example files
|
||||
|
||||
Example files run extensive testing of the SDK API classes and log details about details of using GRVT API.
|
||||
|
||||
1. Change to tests folder
|
||||
|
||||
```bash
|
||||
cd tests/pysdk
|
||||
```
|
||||
|
||||
2. Run example of using synchronous CCXT-compatible calls to `Rest API` via `grvt_ccxt.py`, class `GrvtCcxt`
|
||||
|
||||
```bash
|
||||
uv run python3 test_grvt_ccxt.py
|
||||
```
|
||||
|
||||
3. Run example of using asynchronous CCXT-compatible calls to `Rest API` via `grvt_ccxt_pro.py`, class `GrvtCcxtPro`
|
||||
|
||||
```bash
|
||||
uv run python3 test_grvt_ccxt_pro.py
|
||||
```
|
||||
|
||||
4. Run example of using WebSockets subscriptions and JSON RPC calls via `grvt_ccxt_ws.py`, class `GrvtCcxtWS`
|
||||
|
||||
```bash
|
||||
uv run python3 test_grvt_ccxt_ws.py
|
||||
```
|
||||
|
||||
### What's in the box ?
|
||||
|
||||
#### uv
|
||||
|
||||
[uv](https://github.com/astral-sh/uv) is an extremely fast Python package and project manager, written in Rust.
|
||||
|
||||
**pyproject.toml file** ([`pyproject.toml`](pyproject.toml)): orchestrate your project and its dependencies
|
||||
**uv.lock file** ([`uv.lock`](uv.lock)): ensure that the package versions are consistent for everyone
|
||||
working on your project
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://docs.astral.sh/uv/).
|
||||
|
||||
#### pre-commit
|
||||
|
||||
[pre-commit](https://pre-commit.com/) is a framework for managing and maintaining multi-language pre-commit hooks.
|
||||
|
||||
**.pre-commit-config.yaml file** ([`.pre-commit-config.yaml`](.pre-commit-config.yaml)): describes what repositories and
|
||||
hooks are installed
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://pre-commit.com/).
|
||||
|
||||
#### ruff
|
||||
|
||||
[ruff](https://github.com/astral-sh/ruff) is an extremely fast Python linter, written in Rust.
|
||||
|
||||
Rules are defined in the [`pyproject.toml`](pyproject.toml).
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://github.com/astral-sh/ruff#configuration).
|
||||
|
||||
#### mypy
|
||||
|
||||
[mypy](http://mypy-lang.org/) is an optional static type checker for Python that aims to combine the benefits of
|
||||
dynamic (or "duck") typing and static typing.
|
||||
|
||||
Rules are defined in the [`pyproject.toml`](pyproject.toml).
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://mypy.readthedocs.io/).
|
||||
|
||||
#### bandit
|
||||
|
||||
[bandit](https://bandit.readthedocs.io/) is a tool designed to find common security issues in Python code.
|
||||
|
||||
Rules are defined in the [`pyproject.toml`](pyproject.toml).
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://bandit.readthedocs.io/).
|
||||
|
||||
#### docformatter
|
||||
|
||||
[docformatter](https://github.com/PyCQA/docformatter) is a tool designed to format docstrings to
|
||||
follow [PEP 257](https://peps.python.org/pep-0257/).
|
||||
|
||||
Options are defined in the [`.pre-commit-config.yaml`](.pre-commit-config.yaml).
|
||||
|
||||
---
|
||||
|
||||
#### Testing
|
||||
|
||||
We are using [pytest](https://docs.pytest.org/) & [pytest-cov](https://github.com/pytest-dev/pytest-cov) to write tests.
|
||||
|
||||
To run tests with coverage:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
```text
|
||||
Name Stmts Miss Cover
|
||||
-------------------------------------------------------------
|
||||
src/pysdk/__init__.py 0 0 100%
|
||||
src/pysdk/grvt_ccxt.py 238 21 91%
|
||||
src/pysdk/grvt_ccxt_base.py 196 57 71%
|
||||
src/pysdk/grvt_ccxt_env.py 48 12 75%
|
||||
src/pysdk/grvt_ccxt_logging_selector.py 15 7 53%
|
||||
src/pysdk/grvt_ccxt_pro.py 245 69 72%
|
||||
src/pysdk/grvt_ccxt_test_utils.py 30 4 87%
|
||||
src/pysdk/grvt_ccxt_types.py 41 0 100%
|
||||
src/pysdk/grvt_ccxt_utils.py 237 89 62%
|
||||
src/pysdk/grvt_ccxt_ws.py 275 238 13%
|
||||
src/pysdk/grvt_raw_async.py 149 109 27%
|
||||
src/pysdk/grvt_raw_base.py 154 69 55%
|
||||
src/pysdk/grvt_raw_env.py 27 5 81%
|
||||
src/pysdk/grvt_raw_signing.py 33 17 48%
|
||||
src/pysdk/grvt_raw_sync.py 149 109 27%
|
||||
src/pysdk/grvt_raw_types.py 1031 0 100%
|
||||
-------------------------------------------------------------
|
||||
TOTAL 2868 806 72%
|
||||
|
||||
|
||||
=================================================================================================== 8 passed in 58.20s ====================================================================================================
|
||||
```
|
||||
|
||||
#### Makefile
|
||||
|
||||
We are using [Makefile](https://www.gnu.org/software/make/manual/make.html) to manage the project.
|
||||
|
||||
To see the list of commands:
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
```bash
|
||||
➜ grvt-pysdk git:(main) ✗ make
|
||||
help Show this help
|
||||
run Run the project
|
||||
test Run the tests
|
||||
precommit Run the pre-commit hooks
|
||||
lint Run the linter
|
||||
format Run the formatter
|
||||
typecheck Run the type checker
|
||||
security Run the security checker
|
||||
clean Clean the project
|
||||
install Install the project
|
||||
build Build the project
|
||||
publish Publish the project
|
||||
```
|
||||
|
||||
---
|
||||
@@ -0,0 +1,382 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/astral-sh/uv" target="blank"><img src="https://github.com/astral-sh/uv/blob/8674968a17e5f2ee0dda01d17aaf609f162939ca/docs/assets/logo-letter.svg" height="100" alt="uv logo" /></a>
|
||||
<a href="https://pre-commit.com/" target="blank"><img src="https://pre-commit.com/logo.svg" height="100" alt="pre-commit logo" /></a>
|
||||
<a href="https://github.com/astral-sh/ruff" target="blank"><img src="https://raw.githubusercontent.com/astral-sh/ruff/8c20f14e62ddaf7b6d62674f300f5d19cbdc5acb/docs/assets/bolt.svg" height="100" alt="ruff logo" style="background-color: #ef5552" /></a>
|
||||
<a href="https://bandit.readthedocs.io/" target="blank"><img src="https://raw.githubusercontent.com/pycqa/bandit/main/logo/logo.svg" height="100" alt="bandit logo" /></a>
|
||||
<a href="https://docs.pytest.org/" target="blank"><img src="https://raw.githubusercontent.com/pytest-dev/pytest/main/doc/en/img/pytest_logo_curves.svg" height="100" alt="pytest logo" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.docker.com/" target="blank"><img src="https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png" height="60" alt="Docker logo" /></a>
|
||||
<a href="https://github.com/features/actions" target="blank"><img src="https://avatars.githubusercontent.com/u/44036562" height="60" alt="GitHub Actions logo" /></a>
|
||||
</p>
|
||||
|
||||
# GRVT Python SDK
|
||||
|
||||
[](https://github.com/smarlhens/python-boilerplate/actions/workflows/codeql.yml)
|
||||
[](https://github.com/smarlhens/python-boilerplate/actions/workflows/ci.yml)
|
||||
[](https://github.com/smarlhens/python-boilerplate)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [GRVT Python SDK](#grvt-python-sdk)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [What's in the library](#whats-in-the-library)
|
||||
- [Environments](#environments)
|
||||
- [Files](#files)
|
||||
- [Installation via pip](#installation-via-pip)
|
||||
- [Configuration](#configuration)
|
||||
- [Usage](#usage)
|
||||
- [Contributor's guide](#contributors-guide)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation of a source code](#installation-of-a-source-code)
|
||||
- [Manually run example files](#manually-run-example-files)
|
||||
- [What's in the box ?](#whats-in-the-box-)
|
||||
- [uv](#uv)
|
||||
- [pre-commit](#pre-commit)
|
||||
- [ruff](#ruff)
|
||||
- [mypy](#mypy)
|
||||
- [bandit](#bandit)
|
||||
- [docformatter](#docformatter)
|
||||
- [Testing](#testing)
|
||||
- [Makefile](#makefile)
|
||||
|
||||
---
|
||||
|
||||
## What's in the library
|
||||
|
||||
GRVT Python SDK library provides Python classes and utility methods for easy access to GRVT API endpoints across all environments.
|
||||
|
||||
### Environments
|
||||
|
||||
- `prod` - Production environment.
|
||||
- `testnet` - Testnet environment.
|
||||
- `staging` - Development Integration environment.
|
||||
- `dev` - Development environment.
|
||||
|
||||
SDK Library provides two types of classes:
|
||||
|
||||
1. **Raw** - thin wrapper classes around Rest API.
|
||||
2. **Ccxt-compatible** - classes with ccxt-like methods. Provide access to both Rest API and WebSockets.
|
||||
|
||||
### Files
|
||||
|
||||
Raw access:
|
||||
|
||||
- `grvt_raw_base.py` - base classes for Rest API access.
|
||||
- `grvt_raw_env.py` - definitions of environments for raw access.
|
||||
- `grvt_raw_signing.py` - utility methods for signing orders.
|
||||
- `grvt_raw_sync.py` - class for raw synchronous calls to Rest API.
|
||||
- `grvt_raw_async.py` - class for raw asynchronous calls to Rest API.
|
||||
|
||||
CCXT-compatible access:
|
||||
|
||||
- `grvt_ccxt_utils.py` - utility methods for signing orders.
|
||||
- `grvt_ccxt_env.py` - definitions of environments for ccxt-like access.
|
||||
- `grvt_ccxt_base.py` - base class for Rest API access.
|
||||
- `grvt_ccxt.py` - class for synchronous calls to Rest API.
|
||||
- `grvt_ccxt_pro.py` - class for asynchronous calls to Rest API.
|
||||
- `grvt_ccxt_ws.py` - class for WebSocket calls.
|
||||
|
||||
## Installation via pip
|
||||
|
||||
```bash
|
||||
pip install grvt-pysdk
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Setup these environment variables:
|
||||
|
||||
```bash
|
||||
export GRVT_PRIVATE_KEY="`Secret Private Key` in API setup"
|
||||
export GRVT_API_KEY="`API Key` in API setup"
|
||||
export GRVT_TRADING_ACCOUNT_ID=<`Trading account ID` in API>
|
||||
export GRVT_ENV="testnet"
|
||||
export GRVT_END_POINT_VERSION="v1"
|
||||
export GRVT_WS_STREAM_VERSION="v1"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
**Examples of how to use various methods to connect to GRVT API:**
|
||||
|
||||
- [GRVT CCXT](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_ccxt.py) - Example of usage of CCXT-compatible Python class `GrvtCcxt` with `synchronous` Rest API calls.
|
||||
- [GRVT CCXT Pro](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_ccxt_pro.py) - Example of usage of CCXT.PRO-compatible Python class `GrvtCcxtPro` with `asynchronous` Rest API calls.
|
||||
- [GRVT CCXT WS](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_ccxt_ws.py) - Example of usage of CCXT.PRO-compatible Python class `GrvtCcxtWS` with `asynchronous` Rest API calls + Web Socket subscriptions + JSON RPC calls over Web Sockets.
|
||||
- [GRVT API Sync](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_raw_sync.py) - Synchronous API client for GRVT
|
||||
- [GRVT API Async](https://github.com/gravity-technologies/grvt-pysdk/blob/main/tests/pysdk/test_grvt_raw_async.py) - Asynchronous API client for GRVT
|
||||
|
||||
## Contributor's guide
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Python](https://www.python.org/downloads/) **>=3.10.0 < 3.13** (_tested with 3.10.15_)
|
||||
- [pre-commit](https://pre-commit.com/#install)
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/) **>=0.3.3** (_tested with 0.4.0_)
|
||||
- [docker](https://docs.docker.com/get-docker/) (_optional_)
|
||||
|
||||
### Installation of a source code
|
||||
|
||||
1. Clone the git repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/grvt-technologies/grvt-pysdk.git
|
||||
```
|
||||
|
||||
2. Go into the project directory
|
||||
|
||||
```bash
|
||||
cd grvt-pysdk/
|
||||
```
|
||||
|
||||
3. Checkout working branch
|
||||
|
||||
```bash
|
||||
git checkout <branch>
|
||||
```
|
||||
|
||||
4. Install dependencies
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
5. Enable pre-commit hooks
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Manually run example files
|
||||
|
||||
Example files run extensive testing of the SDK API classes and log details about details of using GRVT API.
|
||||
|
||||
1. Change to tests folder
|
||||
|
||||
```bash
|
||||
cd tests/pysdk
|
||||
```
|
||||
|
||||
2. Run example of using synchronous CCXT-compatible calls to `Rest API` via `grvt_ccxt.py`, class `GrvtCcxt`
|
||||
|
||||
```bash
|
||||
uv run python3 test_grvt_ccxt.py
|
||||
```
|
||||
|
||||
3. Run example of using asynchronous CCXT-compatible calls to `Rest API` via `grvt_ccxt_pro.py`, class `GrvtCcxtPro`
|
||||
|
||||
```bash
|
||||
uv run python3 test_grvt_ccxt_pro.py
|
||||
```
|
||||
|
||||
4. Run example of using WebSockets subscriptions and JSON RPC calls via `grvt_ccxt_ws.py`, class `GrvtCcxtWS`
|
||||
|
||||
```bash
|
||||
uv run python3 test_grvt_ccxt_ws.py
|
||||
```
|
||||
|
||||
### What's in the box ?
|
||||
|
||||
#### uv
|
||||
|
||||
[uv](https://github.com/astral-sh/uv) is an extremely fast Python package and project manager, written in Rust.
|
||||
|
||||
**pyproject.toml file** ([`pyproject.toml`](pyproject.toml)): orchestrate your project and its dependencies
|
||||
**uv.lock file** ([`uv.lock`](uv.lock)): ensure that the package versions are consistent for everyone
|
||||
working on your project
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://docs.astral.sh/uv/).
|
||||
|
||||
#### pre-commit
|
||||
|
||||
[pre-commit](https://pre-commit.com/) is a framework for managing and maintaining multi-language pre-commit hooks.
|
||||
|
||||
**.pre-commit-config.yaml file** ([`.pre-commit-config.yaml`](.pre-commit-config.yaml)): describes what repositories and
|
||||
hooks are installed
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://pre-commit.com/).
|
||||
|
||||
#### ruff
|
||||
|
||||
[ruff](https://github.com/astral-sh/ruff) is an extremely fast Python linter, written in Rust.
|
||||
|
||||
Rules are defined in the [`pyproject.toml`](pyproject.toml).
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://github.com/astral-sh/ruff#configuration).
|
||||
|
||||
#### mypy
|
||||
|
||||
[mypy](http://mypy-lang.org/) is an optional static type checker for Python that aims to combine the benefits of
|
||||
dynamic (or "duck") typing and static typing.
|
||||
|
||||
Rules are defined in the [`pyproject.toml`](pyproject.toml).
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://mypy.readthedocs.io/).
|
||||
|
||||
#### bandit
|
||||
|
||||
[bandit](https://bandit.readthedocs.io/) is a tool designed to find common security issues in Python code.
|
||||
|
||||
Rules are defined in the [`pyproject.toml`](pyproject.toml).
|
||||
|
||||
For more configuration options and details, see the [configuration docs](https://bandit.readthedocs.io/).
|
||||
|
||||
#### docformatter
|
||||
|
||||
[docformatter](https://github.com/PyCQA/docformatter) is a tool designed to format docstrings to
|
||||
follow [PEP 257](https://peps.python.org/pep-0257/).
|
||||
|
||||
Options are defined in the [`.pre-commit-config.yaml`](.pre-commit-config.yaml).
|
||||
|
||||
---
|
||||
|
||||
#### Testing
|
||||
|
||||
We are using [pytest](https://docs.pytest.org/) & [pytest-cov](https://github.com/pytest-dev/pytest-cov) to write tests.
|
||||
|
||||
To run tests with coverage:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
```text
|
||||
Name Stmts Miss Cover
|
||||
-------------------------------------------------------------
|
||||
src/pysdk/__init__.py 0 0 100%
|
||||
src/pysdk/grvt_ccxt.py 238 21 91%
|
||||
src/pysdk/grvt_ccxt_base.py 196 57 71%
|
||||
src/pysdk/grvt_ccxt_env.py 48 12 75%
|
||||
src/pysdk/grvt_ccxt_logging_selector.py 15 7 53%
|
||||
src/pysdk/grvt_ccxt_pro.py 245 69 72%
|
||||
src/pysdk/grvt_ccxt_test_utils.py 30 4 87%
|
||||
src/pysdk/grvt_ccxt_types.py 41 0 100%
|
||||
src/pysdk/grvt_ccxt_utils.py 237 89 62%
|
||||
src/pysdk/grvt_ccxt_ws.py 275 238 13%
|
||||
src/pysdk/grvt_raw_async.py 149 109 27%
|
||||
src/pysdk/grvt_raw_base.py 154 69 55%
|
||||
src/pysdk/grvt_raw_env.py 27 5 81%
|
||||
src/pysdk/grvt_raw_signing.py 33 17 48%
|
||||
src/pysdk/grvt_raw_sync.py 149 109 27%
|
||||
src/pysdk/grvt_raw_types.py 1031 0 100%
|
||||
-------------------------------------------------------------
|
||||
TOTAL 2868 806 72%
|
||||
|
||||
|
||||
=================================================================================================== 8 passed in 58.20s ====================================================================================================
|
||||
```
|
||||
|
||||
#### Makefile
|
||||
|
||||
We are using [Makefile](https://www.gnu.org/software/make/manual/make.html) to manage the project.
|
||||
|
||||
To see the list of commands:
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
```bash
|
||||
➜ grvt-pysdk git:(main) ✗ make
|
||||
help Show this help
|
||||
run Run the project
|
||||
test Run the tests
|
||||
precommit Run the pre-commit hooks
|
||||
lint Run the linter
|
||||
format Run the formatter
|
||||
typecheck Run the type checker
|
||||
security Run the security checker
|
||||
clean Clean the project
|
||||
install Install the project
|
||||
build Build the project
|
||||
publish Publish the project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Changelog
|
||||
|
||||
# Changelog
|
||||
|
||||
## Version [0.2.1] - 2025-07-24
|
||||
|
||||
### Changes in 0.2.1
|
||||
|
||||
- New methods in ccxt-compatible code for Strategy (Vault) managers to view investment history and current redemption queue of a Vault.
|
||||
- Classes `GrvtCcxt` and `GrvtCcxtPro`
|
||||
- Methods `fetch_vault_manager_investor_history` and `fetch_vault_redemption_queue`
|
||||
|
||||
## Version [0.2.0] - 2025-07-16
|
||||
|
||||
### Changes in 0.2.0
|
||||
|
||||
- additional fixes for removal of explicit enums in `grvt_raw_types.py`
|
||||
- Note: this update would `break existing integrations` for `non-create-order flows` (e.g., transfer/withdrawal history, transfer, get open orders, get-instrument filters) to handle currency strings directly instead of enums.
|
||||
- users will be losing the currency enum when they upgrade, so they would need to call the currencies endpoin (<https://api-docs.grvt.io/market_data_api/#get-currency> ), for which the support has been added in PySDK (`get_currency_v1()` in grvt_raw_async.py and grvt_raw_sync.py), for the metadata attached to the currency strings.
|
||||
|
||||
## Version [0.1.32] - 2025-07-15
|
||||
|
||||
### Changes in 0.1.32
|
||||
|
||||
- removed explicit enums in `grvt_raw_types.py` (no need to update SDK for when new coins are listed)
|
||||
- added vault-related functionality
|
||||
|
||||
## Version [0.1.31] - 2025-07-08
|
||||
|
||||
### Changes in 0.1.31
|
||||
|
||||
- added AVAX in the raw code (testing purposes)
|
||||
|
||||
## Version [0.1.30] - 2025-07-08
|
||||
|
||||
### Changes in 0.1.30
|
||||
|
||||
- added `H` in the **raw** code
|
||||
|
||||
## Version [0.1.29] - 2025-06-30
|
||||
|
||||
### Changes in 0.1.29
|
||||
|
||||
- Added new currencies: `HYPE, UNI, MOODENG, LAUNCHCOIN` in the **raw** code
|
||||
- Improvements and type fixes in `fetch_funding_rate_history()` methods of GrvtCcxt and GrvtCcxtPro.
|
||||
|
||||
## Version [0.1.28] - 2025-06-02
|
||||
|
||||
### Changes in 0.1.28
|
||||
|
||||
- Renamed currency name `AI_16_Z` into `AI16Z` in the **raw** code to match the exchange currency name.
|
||||
|
||||
## Version [0.1.27] - 2025-05-19
|
||||
|
||||
### Changes in 0.1.27
|
||||
|
||||
- `GrvtCcxt` and `GrvtCcxtPro` classes:
|
||||
|
||||
- renamed method `fetch_balances()` to `fetch_balance()` as defined in ccxt.
|
||||
|
||||
## Version [0.1.26] - 2025-05-15
|
||||
|
||||
### Added in 0.1.26
|
||||
|
||||
- `GrvtCcxt` and `GrvtCcxtPro` classes:
|
||||
|
||||
- new method `describe()` - returns a list of public method names
|
||||
- new method `fetch_balances()` - returns dict with balances in ccxt format.
|
||||
- constructor parameter `order_book_ccxt_format: bool = False` . If = True then order book snapshots from `fetch_order_book()` are in ccxt format.
|
||||
|
||||
### Fixed in 0.1.26
|
||||
|
||||
- Issues with typing and lynting errors
|
||||
|
||||
## Version [0.1.25] - 2025-04-25
|
||||
|
||||
### Fixed in 0.1.25
|
||||
|
||||
- Issues with typing and lynting errors
|
||||
- Fixed bug in test_grvt_ccxt.py
|
||||
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
readme = Path("README.md").read_text()
|
||||
changelog = Path("CHANGELOG.md").read_text()
|
||||
|
||||
combined = f"{readme}\n\n## Changelog\n\n{changelog}"
|
||||
|
||||
Path("README_PYPI.md").write_text(combined)
|
||||
print("✅ Combined README.md and CHANGELOG.md into README_PYPI.md")
|
||||
@@ -0,0 +1,106 @@
|
||||
[project]
|
||||
name = "grvt-pysdk"
|
||||
version = "0.2.1"
|
||||
|
||||
description = "GRVT Python SDK"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [
|
||||
{ name = "GRVT", email = "contact@grvt.io" },
|
||||
]
|
||||
readme = { file = "README_PYPI.md", content-type = "text/markdown" }
|
||||
dependencies = [
|
||||
"aiohttp>=3.10.11",
|
||||
"backports-weakref>=1.0.post1",
|
||||
"dacite>=1.8.1",
|
||||
"dataclasses-json>=0.6.7",
|
||||
"eth-account>=0.13.4",
|
||||
"inflection>=0.5.1",
|
||||
"requests>=2.32.3",
|
||||
"websockets==13.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://github.com/gravity-technologies/grvt-pysdk"
|
||||
repository = "https://github.com/gravity-technologies/grvt-pysdk"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/pysdk"]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=8.3.2",
|
||||
"pytest-cov>=5.0.0",
|
||||
"mypy>=1.11.2",
|
||||
"bandit>=1.7.9",
|
||||
"docformatter>=1.7.5",
|
||||
"ruff>=0.6.2",
|
||||
"twine>=5.1.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-vvv"
|
||||
testpaths = "tests"
|
||||
|
||||
[tool.ruff]
|
||||
extend-exclude = [
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
]
|
||||
target-version = "py312"
|
||||
line-length = 90
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = [
|
||||
"C4",
|
||||
"D200",
|
||||
"D201",
|
||||
"D204",
|
||||
"D205",
|
||||
"D206",
|
||||
"D210",
|
||||
"D211",
|
||||
"D213",
|
||||
"D300",
|
||||
"D400",
|
||||
"D402",
|
||||
"D403",
|
||||
"D404",
|
||||
"D419",
|
||||
"E",
|
||||
"F",
|
||||
"G010",
|
||||
"I001",
|
||||
"INP001",
|
||||
"N805",
|
||||
"PERF101",
|
||||
"PERF102",
|
||||
"PERF401",
|
||||
"PERF402",
|
||||
"PGH004",
|
||||
"PGH005",
|
||||
"PIE794",
|
||||
"PIE796",
|
||||
"PIE807",
|
||||
"PIE810",
|
||||
"RET502",
|
||||
"RET503",
|
||||
"RET504",
|
||||
"RET505",
|
||||
"RUF015",
|
||||
"RUF100",
|
||||
"S101",
|
||||
"T20",
|
||||
"UP",
|
||||
"W",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
files = ["src", "tests"]
|
||||
strict = "true"
|
||||
@@ -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
@@ -0,0 +1,316 @@
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pysdk.grvt_ccxt import GrvtCcxt
|
||||
from pysdk.grvt_ccxt_env import GrvtEnv
|
||||
from pysdk.grvt_ccxt_logging_selector import logger
|
||||
from pysdk.grvt_ccxt_test_utils import validate_return_values
|
||||
from pysdk.grvt_ccxt_types import DURATION_SECOND_IN_NSEC, GrvtOrderSide
|
||||
from pysdk.grvt_ccxt_utils import rand_uint32
|
||||
|
||||
|
||||
def get_open_orders(api: GrvtCcxt) -> list[dict]:
|
||||
open_orders: list[dict] = api.fetch_open_orders(
|
||||
symbol="BTC_USDT_Perp",
|
||||
params={"kind": "PERPETUAL"},
|
||||
)
|
||||
logger.info(f"open_orders: {open_orders=}")
|
||||
return open_orders
|
||||
|
||||
|
||||
def fetch_order_history(api: GrvtCcxt) -> dict:
|
||||
order_history: dict = api.fetch_order_history(
|
||||
params={"kind": "PERPETUAL", "limit": 3},
|
||||
)
|
||||
logger.info(f"order_history: {order_history=}")
|
||||
return order_history
|
||||
|
||||
def fetch_funding_history(api: GrvtCcxt) -> dict:
|
||||
start_date: datetime = datetime.strptime("2025-05-01T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
|
||||
funding_history: dict = api.fetch_funding_rate_history(
|
||||
symbol="BTC_USDT_Perp",
|
||||
since=int(start_date.timestamp() * DURATION_SECOND_IN_NSEC), # Convert to nanoseconds
|
||||
limit=500,
|
||||
)
|
||||
results: list = funding_history.get("result", [])
|
||||
if results:
|
||||
logger.info(f"funding_history: START={results[0]}")
|
||||
logger.info(f"funding_history: END={results[-1]}")
|
||||
else:
|
||||
logger.info(f"funding_history: No results found in {funding_history=}")
|
||||
return funding_history
|
||||
|
||||
|
||||
def cancel_orders(api: GrvtCcxt, open_orders: list) -> int:
|
||||
FN = "cancel_orders"
|
||||
order_count = 0
|
||||
for order_dict in open_orders:
|
||||
client_order_id = order_dict["metadata"].get("client_order_id")
|
||||
if client_order_id:
|
||||
# Cancel
|
||||
logger.info(f"{FN} cancel order by id:{order_dict['order_id']}")
|
||||
success = api.cancel_order(
|
||||
id=order_dict["order_id"], params={"time_to_live_ms": "1000"}
|
||||
)
|
||||
order_count += int(success)
|
||||
else:
|
||||
logger.warning(f"{FN} client_order_id not found in {order_dict=}")
|
||||
return order_count
|
||||
|
||||
def show_derisk_mm_ratios(api: GrvtCcxt, keyword: str) -> None:
|
||||
"""Show the current derisking market making ratios."""
|
||||
FN = "show_derisk_mm_ratios"
|
||||
acc_summary = api.get_account_summary(type="sub-account")
|
||||
maintenance_margin = acc_summary.get("maintenance_margin")
|
||||
derisk_margin = acc_summary.get("derisk_margin")
|
||||
derisk_ratio = acc_summary.get("derisk_to_maintenance_margin_ratio")
|
||||
logger.info(f"{FN} {keyword} {maintenance_margin=}")
|
||||
logger.info(f"{FN} {keyword} {derisk_margin=}")
|
||||
logger.info(f"{FN} {keyword} {derisk_ratio=}")
|
||||
logger.info(f"sub-account summary:\n{acc_summary}")
|
||||
|
||||
def set_derisk_mm_ratio(api: GrvtCcxt, ratio: str = "1.4") -> None:
|
||||
"""Set the derisking market making ratio."""
|
||||
FN = f"set_derisk_mm_ratio {ratio=}"
|
||||
logger.info(f"{FN} START")
|
||||
show_derisk_mm_ratios(api, "BEFORE")
|
||||
api.set_derisk_mm_ratio(ratio)
|
||||
show_derisk_mm_ratios(api, "AFTER")
|
||||
|
||||
|
||||
def cancel_all_orders(api: GrvtCcxt) -> bool:
|
||||
FN = "cancel_all_orders"
|
||||
logger.info(f"{FN} START")
|
||||
cancel_response = api.cancel_all_orders()
|
||||
logger.info(f"{FN} {cancel_response=}")
|
||||
return cancel_response
|
||||
|
||||
|
||||
def print_instruments(api: GrvtCcxt):
|
||||
logger.info("print_instruments: START")
|
||||
if not api.markets:
|
||||
return
|
||||
for market in list(api.markets.values())[:3]:
|
||||
logger.info(f"{market=}")
|
||||
instrument = market["instrument"]
|
||||
logger.info(f"fetch_market: {instrument=}, {api.fetch_market(instrument)}")
|
||||
logger.info(f"fetch_mini_ticker: {instrument=}, {api.fetch_mini_ticker(instrument)}")
|
||||
logger.info(f"fetch_ticker: {instrument=}, {api.fetch_ticker(instrument)}")
|
||||
logger.info(f"fetch_order_book {instrument=}, {api.fetch_order_book(instrument, limit=10)}")
|
||||
logger.info(
|
||||
f"fetch_recent_trades {instrument=}, {api.fetch_recent_trades(instrument, limit=5)}"
|
||||
)
|
||||
logger.info(f"fetch_trades {instrument=}, {api.fetch_trades(instrument, limit=5)}")
|
||||
logger.info(
|
||||
f"fetch_funding_rate_history {instrument=}, "
|
||||
f"{api.fetch_funding_rate_history(instrument, limit=5)}"
|
||||
)
|
||||
for type in ["TRADE", "MARK", "INDEX", "MID"]:
|
||||
ohlc = api.fetch_ohlcv(
|
||||
instrument, timeframe="5m", limit=5, params={"candle_type": type}
|
||||
)
|
||||
logger.info(f"fetch_ohlcv {type} {instrument=}, {ohlc}")
|
||||
|
||||
|
||||
def send_order(api: GrvtCcxt, side: GrvtOrderSide, client_order_id: int) -> dict:
|
||||
price = 94_000 if side == "buy" else 95_000
|
||||
send_order_response: dict = api.create_order(
|
||||
symbol="BTC_USDT_Perp",
|
||||
order_type="limit",
|
||||
side=side,
|
||||
amount=0.01,
|
||||
price=price,
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
logger.info(f"send order: {send_order_response=} {client_order_id=}")
|
||||
return send_order_response
|
||||
|
||||
|
||||
def send_mkt_order(
|
||||
api: GrvtCcxt, symbol: str, side: GrvtOrderSide, amount: Decimal, client_order_id: int
|
||||
) -> dict:
|
||||
send_order_response: dict = api.create_order(
|
||||
symbol=symbol,
|
||||
order_type="market",
|
||||
side=side,
|
||||
amount=amount,
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
logger.info(f"send mkt order: {send_order_response=} {client_order_id=}")
|
||||
return send_order_response
|
||||
|
||||
|
||||
# Test scenarios, called by the __main__ test routine
|
||||
def send_fetch_order(api: GrvtCcxt):
|
||||
client_order_id = rand_uint32()
|
||||
_ = send_order(api, side="buy", client_order_id=client_order_id)
|
||||
time.sleep(0.1)
|
||||
order_status = api.fetch_order(
|
||||
id=None,
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
logger.info(f"result of fetch_order: {order_status=}")
|
||||
|
||||
|
||||
def check_cancel_check_orders(api: GrvtCcxt):
|
||||
logger.info("check_cancel_check_orders: START")
|
||||
open_orders = get_open_orders(api)
|
||||
if open_orders:
|
||||
cancel_orders(api, open_orders)
|
||||
get_open_orders(api)
|
||||
|
||||
|
||||
def fetch_my_trades(api: GrvtCcxt):
|
||||
logger.info("fetch_my_trades: START")
|
||||
my_trades = api.fetch_my_trades(
|
||||
symbol="BTC_USDT_Perp",
|
||||
limit=10,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"my_trades: num trades:{len(my_trades)}")
|
||||
logger.info(f"my_trades: {my_trades=}")
|
||||
|
||||
|
||||
def cancel_send_order(api: GrvtCcxt):
|
||||
FN = "cancel_send_order"
|
||||
logger.info(f"{FN}: START")
|
||||
client_order_id: int = rand_uint32()
|
||||
logger.info(f"{FN} cancel order by {client_order_id=}")
|
||||
result = api.cancel_order(
|
||||
params={"client_order_id": client_order_id, "time_to_live_ms": "1000"}
|
||||
)
|
||||
logger.info(f"{FN} cancel_order: {result=}")
|
||||
order_response = send_mkt_order(
|
||||
api,
|
||||
symbol="BTC_USDT_Perp",
|
||||
side="sell",
|
||||
amount=Decimal("0.01"),
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if order_response:
|
||||
time.sleep(0.1)
|
||||
# Get status
|
||||
logger.info(f"{FN} fetch_order by {client_order_id=}")
|
||||
order_status = api.fetch_order(params={"client_order_id": client_order_id})
|
||||
logger.info(f"{FN} {order_status=}")
|
||||
else:
|
||||
logger.warning(f"{FN}: order_response is None")
|
||||
|
||||
|
||||
def send_fetch_mkt_order(api: GrvtCcxt):
|
||||
FN = "send_fetch_mkt_order"
|
||||
logger.info(f"{FN}: START")
|
||||
client_order_id: int = rand_uint32()
|
||||
order_response = send_mkt_order(
|
||||
api,
|
||||
symbol="BTC_USDT_Perp",
|
||||
side="sell",
|
||||
amount=Decimal("0.01"),
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if order_response:
|
||||
time.sleep(0.1)
|
||||
# Get status
|
||||
logger.info(f"{FN} fetch_order by {client_order_id=}")
|
||||
order_status = api.fetch_order(params={"client_order_id": client_order_id})
|
||||
logger.info(f"{FN} {order_status=}")
|
||||
else:
|
||||
logger.warning(f"{FN}: order_response is None")
|
||||
|
||||
|
||||
def print_markets(api: GrvtCcxt):
|
||||
logger.info("print_markets: START")
|
||||
if api.markets:
|
||||
logger.info(f"MARKETS:{len(api.markets)}")
|
||||
for market in api.markets.values():
|
||||
logger.info(f"MARKET:{market}")
|
||||
|
||||
|
||||
def fetch_all_markets(api: GrvtCcxt):
|
||||
logger.info("fetch_all_markets: START")
|
||||
instruments = api.fetch_all_markets()
|
||||
logger.info(f"fetch_all_markets: num instruments={len(instruments)}")
|
||||
|
||||
|
||||
def print_account_summary(api: GrvtCcxt):
|
||||
try:
|
||||
logger.info(f"sub-account summary:\n{api.get_account_summary(type='sub-account')}")
|
||||
logger.info(f"funding-account summary:\n{api.get_account_summary(type='funding')}")
|
||||
logger.info(f"aggregated-account summary:\n{api.get_account_summary(type='aggregated')}")
|
||||
logger.info(f"fetch_balance:\n{api.fetch_balance()}")
|
||||
except Exception as e:
|
||||
logger.error(f"account summary failed: {e}")
|
||||
|
||||
|
||||
def print_account_history(api: GrvtCcxt):
|
||||
try:
|
||||
hist = api.fetch_account_history(params={})
|
||||
logger.info(f"account history:\n{hist}")
|
||||
except Exception as e:
|
||||
logger.error(f"account history failed: {e}")
|
||||
|
||||
|
||||
def print_positions(api: GrvtCcxt):
|
||||
try:
|
||||
logger.info(f"positions:\n{api.fetch_positions(symbols=['BTC_USDT_Perp'])}")
|
||||
except Exception as e:
|
||||
logger.error(f"positions failed: {e}")
|
||||
|
||||
|
||||
def print_description(api: GrvtCcxt):
|
||||
try:
|
||||
logger.info(f"print_description: {api.describe()}")
|
||||
except Exception as e:
|
||||
logger.error(f"print_description failed: {e}")
|
||||
|
||||
|
||||
def test_grvt_ccxt():
|
||||
params = {
|
||||
"api_key": os.getenv("GRVT_API_KEY"),
|
||||
"trading_account_id": os.getenv("GRVT_TRADING_ACCOUNT_ID"),
|
||||
"private_key": os.getenv("GRVT_PRIVATE_KEY"),
|
||||
}
|
||||
env = GrvtEnv(os.getenv("GRVT_ENV", "testnet"))
|
||||
test_api = GrvtCcxt(env, logger, parameters=params, order_book_ccxt_format=True)
|
||||
function_list = [
|
||||
print_description,
|
||||
# -------- MARKET related
|
||||
fetch_all_markets,
|
||||
print_markets,
|
||||
print_instruments,
|
||||
print_account_summary,
|
||||
print_account_history,
|
||||
# print_positions,
|
||||
# -------- TRADE related
|
||||
# fetch_my_trades,
|
||||
fetch_order_history,
|
||||
fetch_funding_history,
|
||||
# # -------- order related
|
||||
send_fetch_order,
|
||||
fetch_my_trades,
|
||||
print_positions,
|
||||
check_cancel_check_orders,
|
||||
cancel_send_order,
|
||||
send_fetch_mkt_order,
|
||||
get_open_orders,
|
||||
send_fetch_order,
|
||||
get_open_orders,
|
||||
cancel_all_orders,
|
||||
get_open_orders,
|
||||
# Derisk MM ratio
|
||||
set_derisk_mm_ratio,
|
||||
]
|
||||
for f in function_list:
|
||||
try:
|
||||
f(test_api)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {f.__name__}: {e} {traceback.format_exc()}")
|
||||
validate_return_values(test_api, "test_results_sync.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_grvt_ccxt()
|
||||
@@ -0,0 +1,300 @@
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pysdk.grvt_ccxt_env import GrvtEnv
|
||||
from pysdk.grvt_ccxt_logging_selector import logger
|
||||
from pysdk.grvt_ccxt_pro import GrvtCcxtPro
|
||||
from pysdk.grvt_ccxt_test_utils import validate_return_values
|
||||
from pysdk.grvt_ccxt_types import DURATION_SECOND_IN_NSEC, GrvtOrderSide
|
||||
from pysdk.grvt_ccxt_utils import rand_uint32
|
||||
|
||||
|
||||
# Utility functions , not called directly by the __main__ test routine
|
||||
async def get_open_orders(api: GrvtCcxtPro) -> list[dict]:
|
||||
open_orders = await api.fetch_open_orders(
|
||||
|
||||
symbol="BTC_USDT_Perp",
|
||||
params={"kind": "PERPETUAL"},
|
||||
)
|
||||
logger.info(f"open_orders: {open_orders=}")
|
||||
return open_orders
|
||||
|
||||
|
||||
async def fetch_order_history(api: GrvtCcxtPro) -> dict:
|
||||
order_history: dict = await api.fetch_order_history(
|
||||
params={"kind": "PERPETUAL", "limit": 3},
|
||||
)
|
||||
logger.info(f"order_history: {order_history=}")
|
||||
return order_history
|
||||
|
||||
async def fetch_funding_history(api: GrvtCcxtPro) -> dict:
|
||||
start_date: datetime = datetime.strptime("2025-05-01T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
|
||||
funding_history: dict = await api.fetch_funding_rate_history(
|
||||
symbol="BTC_USDT_Perp",
|
||||
since=int(start_date.timestamp() * DURATION_SECOND_IN_NSEC), # Convert to nanoseconds
|
||||
limit=500,
|
||||
)
|
||||
results: list = funding_history.get("result", [])
|
||||
if results:
|
||||
logger.info(f"funding_history: START={results[0]}")
|
||||
logger.info(f"funding_history: END={results[-1]}")
|
||||
else:
|
||||
logger.info(f"funding_history: No results found in {funding_history=}")
|
||||
return funding_history
|
||||
|
||||
|
||||
async def cancel_orders(api: GrvtCcxtPro, open_orders: list) -> int:
|
||||
FN = "cancel_orders"
|
||||
logger.info(f"{FN} START")
|
||||
order_count: int = 0
|
||||
for order_dict in open_orders:
|
||||
client_order_id = order_dict["metadata"].get("client_order_id")
|
||||
if client_order_id:
|
||||
# Cancel
|
||||
logger.info(f"{FN} cancel order by id:{order_dict['order_id']}")
|
||||
await api.cancel_order(id=order_dict["order_id"])
|
||||
order_count += 1
|
||||
else:
|
||||
logger.warning(f"{FN} client_order_id not found in {order_dict=}")
|
||||
return order_count
|
||||
|
||||
async def show_derisk_mm_ratios(api: GrvtCcxtPro, keyword: str) -> None:
|
||||
"""Show the current derisking market making ratios."""
|
||||
FN = "show_derisk_mm_ratios"
|
||||
acc_summary = await api.get_account_summary(type="sub-account")
|
||||
maintenance_margin = acc_summary.get("maintenance_margin")
|
||||
derisk_margin = acc_summary.get("derisk_margin")
|
||||
derisk_ratio = acc_summary.get("derisk_to_maintenance_margin_ratio")
|
||||
logger.info(f"{FN} {keyword} {maintenance_margin=}")
|
||||
logger.info(f"{FN} {keyword} {derisk_margin=}")
|
||||
logger.info(f"{FN} {keyword} {derisk_ratio=}")
|
||||
logger.info(f"sub-account summary:\n{acc_summary}")
|
||||
|
||||
async def set_derisk_mm_ratio(api: GrvtCcxtPro, ratio: str = "1.5") -> None:
|
||||
"""Set the derisking market making ratio."""
|
||||
FN = f"set_derisk_mm_ratio {ratio=}"
|
||||
logger.info(f"{FN} START")
|
||||
await show_derisk_mm_ratios(api, "BEFORE")
|
||||
await api.set_derisk_mm_ratio(ratio)
|
||||
await show_derisk_mm_ratios(api, "AFTER")
|
||||
|
||||
async def cancel_all_orders(api: GrvtCcxtPro) -> bool:
|
||||
FN = "cancel_all_orders"
|
||||
logger.info(f"{FN} START")
|
||||
cancel_response = await api.cancel_all_orders()
|
||||
logger.info(f"{FN} {cancel_response=}")
|
||||
return cancel_response
|
||||
|
||||
|
||||
async def print_instruments(api: GrvtCcxtPro):
|
||||
logger.info("print_instruments: START")
|
||||
if not api.markets:
|
||||
return
|
||||
for market in list(api.markets.values())[:3]:
|
||||
logger.info(f"{market=}")
|
||||
instrument = market["instrument"]
|
||||
logger.info(f"fetch_mini_ticker: {instrument=}, {await api.fetch_mini_ticker(instrument)}")
|
||||
logger.info(f"fetch_ticker: {instrument=}, {await api.fetch_ticker(instrument)}")
|
||||
logger.info(
|
||||
f"fetch_order_book {instrument=}, {await api.fetch_order_book(instrument, limit=10)}"
|
||||
)
|
||||
logger.info(
|
||||
f"fetch_recent_trades {instrument=}, "
|
||||
f"{await api.fetch_recent_trades(instrument, limit=7)}"
|
||||
)
|
||||
logger.info(f"fetch_trades {instrument=}, {await api.fetch_trades(instrument, limit=5)}")
|
||||
logger.info(
|
||||
f"fetch_funding_rate_history {instrument=}, "
|
||||
f"{await api.fetch_funding_rate_history(instrument, limit=5)}"
|
||||
)
|
||||
for type in ["TRADE", "MARK", "INDEX", "MID"]:
|
||||
ohlc = await api.fetch_ohlcv(
|
||||
instrument, timeframe="1m", limit=5, params={"candle_type": type}
|
||||
)
|
||||
logger.info(f"fetch_ohlcv {type} {instrument=}, {ohlc}")
|
||||
|
||||
|
||||
async def send_order(api: GrvtCcxtPro, side: GrvtOrderSide, client_order_id: int) -> dict:
|
||||
price = 64_000 if side == "buy" else 65_000
|
||||
send_order_response = await api.create_order(
|
||||
symbol="BTC_USDT_Perp",
|
||||
order_type="limit",
|
||||
side=side,
|
||||
amount=0.01,
|
||||
price=price,
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
logger.info(f"send order: {send_order_response=} {client_order_id=}")
|
||||
return send_order_response
|
||||
|
||||
|
||||
# Test scenarios, called by the __main__ test routine
|
||||
async def send_fetch_order(api: GrvtCcxtPro):
|
||||
client_order_id = rand_uint32()
|
||||
_ = await send_order(api, side="buy", client_order_id=client_order_id)
|
||||
order_status = await api.fetch_order(
|
||||
id=None,
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
logger.info(f"result of fetch_order: {order_status=}")
|
||||
|
||||
|
||||
async def send_mkt_order(
|
||||
api: GrvtCcxtPro, symbol: str, side: GrvtOrderSide, amount: Decimal, client_order_id: int
|
||||
) -> dict:
|
||||
send_order_response = await api.create_order(
|
||||
symbol=symbol,
|
||||
order_type="market",
|
||||
side=side,
|
||||
amount=amount,
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
logger.info(f"send mkt order: {send_order_response=} {client_order_id=}")
|
||||
return send_order_response
|
||||
|
||||
|
||||
async def check_cancel_check_orders(api: GrvtCcxtPro):
|
||||
logger.info("check_cancel_check_orders: START")
|
||||
open_orders = await get_open_orders(api)
|
||||
if open_orders:
|
||||
await cancel_orders(api, open_orders)
|
||||
await get_open_orders(api)
|
||||
|
||||
|
||||
async def fetch_my_trades(api: GrvtCcxtPro):
|
||||
logger.info("fetch_my_trades: START")
|
||||
my_trades = await api.fetch_my_trades(
|
||||
symbol="BTC_USDT_Perp",
|
||||
limit=10,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"my_trades: num trades:{len(my_trades)}")
|
||||
logger.info(f"my_trades: {my_trades=}")
|
||||
|
||||
|
||||
async def cancel_send_order(api: GrvtCcxtPro):
|
||||
FN = "cancel_send_order"
|
||||
logger.info(f"{FN}: START")
|
||||
client_order_id: int = rand_uint32()
|
||||
logger.info(f"{FN} cancel order by {client_order_id=}")
|
||||
result = await api.cancel_order(
|
||||
params={"client_order_id": client_order_id, "time_to_live_ms": "1000"}
|
||||
)
|
||||
logger.info(f"{FN} cancel_order: {result=}")
|
||||
order_response = await send_mkt_order(
|
||||
api,
|
||||
symbol="BTC_USDT_Perp",
|
||||
side="sell",
|
||||
amount=Decimal("0.01"),
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if order_response:
|
||||
# Get status
|
||||
logger.info(f"{FN} fetch_order by {client_order_id=}")
|
||||
order_status = await api.fetch_order(params={"client_order_id": client_order_id})
|
||||
logger.info(f"{FN} {order_status=}")
|
||||
|
||||
|
||||
async def print_markets(api: GrvtCcxtPro):
|
||||
logger.info("print_markets: START")
|
||||
if api.markets:
|
||||
logger.info(f"MARKETS:{len(api.markets)}")
|
||||
for market in api.markets.values():
|
||||
logger.info(f"MARKET:{market}")
|
||||
|
||||
|
||||
async def fetch_all_markets(api: GrvtCcxtPro):
|
||||
logger.info("fetch_all_markets: START")
|
||||
instruments = await api.fetch_all_markets()
|
||||
logger.info(f"fetch_all_markets: num instruments={len(instruments)}")
|
||||
|
||||
|
||||
async def print_account_summary(api: GrvtCcxtPro):
|
||||
try:
|
||||
logger.info("print_account_summary: START")
|
||||
logger.info(f"sub-account summary:\n{await api.get_account_summary(type='sub-account')}")
|
||||
logger.info(f"funding-account summary:\n{await api.get_account_summary(type='funding')}")
|
||||
logger.info(
|
||||
f"aggregated-account summary:\n{await api.get_account_summary(type='aggregated')}"
|
||||
)
|
||||
logger.info(f"fetch_balance:\n{await api.fetch_balance()}")
|
||||
except Exception as e:
|
||||
logger.error(f"account summary failed: {e}")
|
||||
|
||||
|
||||
async def print_account_history(api: GrvtCcxtPro):
|
||||
try:
|
||||
hist = await api.fetch_account_history(params={})
|
||||
logger.info(f"account history:\n{hist}")
|
||||
except Exception as e:
|
||||
logger.error(f"account history failed: {e}")
|
||||
|
||||
|
||||
async def print_positions(api: GrvtCcxtPro):
|
||||
try:
|
||||
logger.info(f"positions:\n{await api.fetch_positions(symbols=['BTC_USDT_Perp'])}")
|
||||
except Exception as e:
|
||||
logger.error(f"positions failed: {e}")
|
||||
|
||||
|
||||
async def print_description(api: GrvtCcxtPro):
|
||||
try:
|
||||
logger.info(f"print_description: {api.describe()}")
|
||||
except Exception as e:
|
||||
logger.error(f"print_description failed: {e}")
|
||||
|
||||
|
||||
async def grvt_ccxt_pro():
|
||||
params = {
|
||||
"api_key": os.getenv("GRVT_API_KEY"),
|
||||
"trading_account_id": os.getenv("GRVT_TRADING_ACCOUNT_ID"),
|
||||
"private_key": os.getenv("GRVT_PRIVATE_KEY"),
|
||||
}
|
||||
env = GrvtEnv(os.getenv("GRVT_ENV", "testnet"))
|
||||
test_api = GrvtCcxtPro(env, logger, parameters=params, order_book_ccxt_format=True)
|
||||
await test_api.load_markets()
|
||||
await asyncio.sleep(2)
|
||||
function_list = [
|
||||
print_description,
|
||||
# -------- MARKET related
|
||||
fetch_all_markets,
|
||||
print_markets,
|
||||
print_instruments,
|
||||
print_account_summary,
|
||||
print_account_history,
|
||||
print_positions,
|
||||
# Order / Trade history
|
||||
fetch_my_trades,
|
||||
fetch_order_history,
|
||||
fetch_funding_history,
|
||||
# Trade related
|
||||
send_fetch_order,
|
||||
check_cancel_check_orders,
|
||||
fetch_my_trades,
|
||||
fetch_order_history,
|
||||
print_positions,
|
||||
cancel_send_order,
|
||||
get_open_orders,
|
||||
send_fetch_order,
|
||||
get_open_orders,
|
||||
cancel_all_orders,
|
||||
get_open_orders,
|
||||
set_derisk_mm_ratio,
|
||||
]
|
||||
for f in function_list:
|
||||
try:
|
||||
await f(test_api)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {f.__name__}: {e} {traceback.format_exc()}")
|
||||
validate_return_values(test_api, "test_results.csv")
|
||||
|
||||
|
||||
def test_grvt_ccxt_pro() -> None:
|
||||
asyncio.run(grvt_ccxt_pro())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_grvt_ccxt_pro()
|
||||
@@ -0,0 +1,52 @@
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from pysdk.grvt_ccxt import GrvtCcxt
|
||||
from pysdk.grvt_ccxt_env import GrvtEnv
|
||||
from pysdk.grvt_ccxt_logging_selector import logger
|
||||
|
||||
|
||||
def call_vault_manager_investor_history(api: GrvtCcxt):
|
||||
FN = "call_vault_manager_investor_history"
|
||||
logger.info(f"{FN}: START")
|
||||
try:
|
||||
history = api.fetch_vault_manager_investor_history()
|
||||
# Expect dict with result key and list of dicts with history items
|
||||
# [{'event_time': '1752057360756255849', 'off_chain_account_id': 'ACC:2s**fW',
|
||||
# 'vault_id': '2002239639', 'type': 'VAULT_REDEEM', 'price': '0.998912', 'size': '1900.0',
|
||||
# 'realized_pnl': '-0.077452', 'performance_fee': '0.0'}, ...]
|
||||
logger.info(f"{FN}: {history=}")
|
||||
except Exception as e:
|
||||
logger.error(f"{FN} failed: {e}")
|
||||
|
||||
|
||||
def call_vault_redemption_queue(api: GrvtCcxt):
|
||||
FN = "call_vault_redemption_queue"
|
||||
logger.info(f"{FN}: START")
|
||||
try:
|
||||
redemption_queue = api.fetch_vault_redemption_queue()
|
||||
logger.info(f"{FN}: {redemption_queue=}")
|
||||
except Exception as e:
|
||||
logger.error(f"{FN} failed: {e}")
|
||||
|
||||
|
||||
def test_grvt_ccxt_vault():
|
||||
params = {
|
||||
"api_key": os.getenv("GRVT_API_KEY"),
|
||||
"trading_account_id": os.getenv("GRVT_TRADING_ACCOUNT_ID"),
|
||||
"private_key": os.getenv("GRVT_PRIVATE_KEY"),
|
||||
}
|
||||
env = GrvtEnv(os.getenv("GRVT_ENV", "testnet"))
|
||||
test_api = GrvtCcxt(env, logger, parameters=params, order_book_ccxt_format=True)
|
||||
function_list = [
|
||||
call_vault_manager_investor_history,
|
||||
call_vault_redemption_queue,
|
||||
]
|
||||
for f in function_list:
|
||||
try:
|
||||
f(test_api)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {f.__name__}: {e} {traceback.format_exc()}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_grvt_ccxt_vault()
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from pysdk.grvt_ccxt_env import GrvtEnv
|
||||
from pysdk.grvt_ccxt_logging_selector import logger
|
||||
from pysdk.grvt_ccxt_pro import GrvtCcxtPro
|
||||
|
||||
|
||||
async def call_vault_manager_investor_history(api: GrvtCcxtPro):
|
||||
FN = "call_vault_manager_investor_history"
|
||||
logger.info(f"{FN}: START")
|
||||
try:
|
||||
history = await api.fetch_vault_manager_investor_history()
|
||||
# Expect dict with result key and list of dicts with history items
|
||||
# [{'event_time': '1752057360756255849', 'off_chain_account_id': 'ACC:2s**fW',
|
||||
# 'vault_id': '2002239639', 'type': 'VAULT_REDEEM', 'price': '0.998912', 'size': '1900.0',
|
||||
# 'realized_pnl': '-0.077452', 'performance_fee': '0.0'}, ...]
|
||||
logger.info(f"{FN}: {history=}")
|
||||
except Exception as e:
|
||||
logger.error(f"{FN} failed: {e}")
|
||||
|
||||
|
||||
async def call_vault_redemption_queue(api: GrvtCcxtPro):
|
||||
FN = "call_vault_redemption_queue"
|
||||
logger.info(f"{FN}: START")
|
||||
try:
|
||||
redemption_queue = await api.fetch_vault_redemption_queue()
|
||||
logger.info(f"{FN}: {redemption_queue=}")
|
||||
except Exception as e:
|
||||
logger.error(f"{FN} failed: {e}")
|
||||
|
||||
|
||||
async def grvt_ccxt_vault_pro():
|
||||
params = {
|
||||
"api_key": os.getenv("GRVT_API_KEY"),
|
||||
"trading_account_id": os.getenv("GRVT_TRADING_ACCOUNT_ID"),
|
||||
"private_key": os.getenv("GRVT_PRIVATE_KEY"),
|
||||
}
|
||||
env = GrvtEnv(os.getenv("GRVT_ENV", "testnet"))
|
||||
test_api = GrvtCcxtPro(env, logger, parameters=params, order_book_ccxt_format=True)
|
||||
await test_api.load_markets()
|
||||
await asyncio.sleep(2)
|
||||
function_list = [
|
||||
call_vault_manager_investor_history,
|
||||
call_vault_redemption_queue,
|
||||
]
|
||||
for f in function_list:
|
||||
try:
|
||||
await f(test_api)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {f.__name__}: {e} {traceback.format_exc()}")
|
||||
|
||||
|
||||
def test_grvt_ccxt_vault_pro() -> None:
|
||||
asyncio.run(grvt_ccxt_vault_pro())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_grvt_ccxt_vault_pro()
|
||||
@@ -0,0 +1,297 @@
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from pysdk.grvt_ccxt_env import GrvtEnv, GrvtWSEndpointType
|
||||
from pysdk.grvt_ccxt_logging_selector import logger
|
||||
from pysdk.grvt_ccxt_types import GrvtOrderSide
|
||||
from pysdk.grvt_ccxt_utils import rand_uint32
|
||||
from pysdk.grvt_ccxt_ws import GrvtCcxtWS
|
||||
|
||||
|
||||
# Utility functions , not called directly by the __main__ test routine
|
||||
async def callback_general(message: dict) -> None:
|
||||
message.get("params", {}).get("channel")
|
||||
logger.info(f"callback_general(): message:{message}")
|
||||
|
||||
|
||||
async def grvt_ws_subscribe(api: GrvtCcxtWS, args_list: dict) -> None:
|
||||
"""Subscribes to Websocket channels/feeds in args list."""
|
||||
for stream, (callback, ws_endpoint_type, params) in args_list.items():
|
||||
logger.info(f"Subscribing to {stream} {params=}")
|
||||
await api.subscribe(
|
||||
stream=stream,
|
||||
callback=callback,
|
||||
ws_end_point_type=ws_endpoint_type,
|
||||
params=params,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def subscribe(loop) -> GrvtCcxtWS:
|
||||
"""Subscribe to Websocket channels and feeds."""
|
||||
params = {
|
||||
"api_key": os.getenv("GRVT_API_KEY"),
|
||||
"trading_account_id": os.getenv("GRVT_TRADING_ACCOUNT_ID"),
|
||||
"api_ws_version": os.getenv("GRVT_WS_STREAM_VERSION", "v1"),
|
||||
}
|
||||
if os.getenv("GRVT_PRIVATE_KEY"):
|
||||
params["private_key"] = os.getenv("GRVT_PRIVATE_KEY")
|
||||
env = GrvtEnv(os.getenv("GRVT_ENV", "testnet"))
|
||||
|
||||
test_api = GrvtCcxtWS(env, loop, logger, parameters=params)
|
||||
await test_api.initialize()
|
||||
pub_args_dict = {
|
||||
# ********* Market Data *********
|
||||
"mini.s": (
|
||||
callback_general,
|
||||
None, # use deafult endpoint
|
||||
{"instrument": "BTC_USDT_Perp"},
|
||||
),
|
||||
"mini.d": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
|
||||
{"instrument": "BTC_USDT_Perp", "rate": 0},
|
||||
),
|
||||
"ticker.s": (
|
||||
callback_general,
|
||||
None, # use deafult endpoint
|
||||
{"instrument": "BTC_USDT_Perp"},
|
||||
),
|
||||
"ticker.d": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
|
||||
{"instrument": "BTC_USDT_Perp"},
|
||||
),
|
||||
"book.s": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
|
||||
{"instrument": "BTC_USDT_Perp"},
|
||||
),
|
||||
"book.d": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
|
||||
{"instrument": "BTC_USDT_Perp"},
|
||||
),
|
||||
"trade": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
|
||||
{"instrument": "BTC_USDT_Perp"},
|
||||
),
|
||||
"candle": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.MARKET_DATA_RPC_FULL,
|
||||
{
|
||||
"instrument": "BTC_USDT_Perp",
|
||||
"interval": "CI_1_M",
|
||||
"type": "TRADE",
|
||||
},
|
||||
),
|
||||
}
|
||||
prv_args_dict = {
|
||||
# ********* Trade Data *********
|
||||
"position": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
|
||||
{},
|
||||
),
|
||||
"order": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
|
||||
{
|
||||
"instrument": "BTC_USDT_Perp",
|
||||
},
|
||||
),
|
||||
"cancel": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
|
||||
{},
|
||||
),
|
||||
"state": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
|
||||
{
|
||||
"instrument": "BTC_USDT_Perp",
|
||||
},
|
||||
),
|
||||
"fill": (
|
||||
callback_general,
|
||||
GrvtWSEndpointType.TRADE_DATA_RPC_FULL,
|
||||
{
|
||||
"instrument": "BTC_USDT_Perp",
|
||||
},
|
||||
),
|
||||
"deposit": (callback_general, GrvtWSEndpointType.TRADE_DATA, {}),
|
||||
"transfer": (callback_general, GrvtWSEndpointType.TRADE_DATA, {}),
|
||||
"withdrawal": (callback_general, GrvtWSEndpointType.TRADE_DATA, {}),
|
||||
}
|
||||
try:
|
||||
if "private_key" in params:
|
||||
await grvt_ws_subscribe(test_api, {**pub_args_dict, **prv_args_dict})
|
||||
else: # not private_key , subscribe to public feeds only
|
||||
await grvt_ws_subscribe(test_api, pub_args_dict)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in grvt_ws_subscribe: {e} {traceback.format_exc()}")
|
||||
return test_api
|
||||
|
||||
|
||||
async def rpc_create_order(
|
||||
test_api: GrvtCcxtWS, side: GrvtOrderSide, price: str, client_order_id: str = ""
|
||||
) -> str:
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
if not client_order_id:
|
||||
client_order_id = str(rand_uint32())
|
||||
payload = await test_api.rpc_create_order(
|
||||
symbol="BTC_USDT_Perp",
|
||||
order_type="limit",
|
||||
side=side,
|
||||
amount=0.001,
|
||||
price=price,
|
||||
params={
|
||||
"client_order_id": client_order_id,
|
||||
# "time_in_force": "IMMEDIATE_OR_CANCEL",
|
||||
"time_in_force": "GOOD_TILL_TIME",
|
||||
},
|
||||
)
|
||||
logger.info(f"rpc_create_order: {payload=}")
|
||||
return client_order_id
|
||||
return ""
|
||||
|
||||
|
||||
async def rpc_create_mkt_order(
|
||||
test_api: GrvtCcxtWS, symbol: str, side: GrvtOrderSide, client_order_id: str = ""
|
||||
) -> str:
|
||||
FN = "rpc_create_mkt_order"
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
if not client_order_id:
|
||||
client_order_id = str(rand_uint32())
|
||||
payload = await test_api.rpc_create_order(
|
||||
symbol=symbol,
|
||||
order_type="market",
|
||||
side=side,
|
||||
amount=0.001,
|
||||
params={
|
||||
"client_order_id": client_order_id,
|
||||
"time_in_force": "GOOD_TILL_TIME",
|
||||
},
|
||||
)
|
||||
logger.info(f"{FN}: {payload=}")
|
||||
return client_order_id
|
||||
return ""
|
||||
|
||||
|
||||
async def rpc_fetch_order(test_api: GrvtCcxtWS, client_order_id: str) -> None:
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
payload = await test_api.rpc_fetch_order(
|
||||
params={
|
||||
"client_order_id": client_order_id,
|
||||
},
|
||||
)
|
||||
logger.info(f"rpc_fetch_order: {payload=}")
|
||||
|
||||
|
||||
async def rpc_fetch_open_orders(test_api: GrvtCcxtWS) -> None:
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
payload: dict = await test_api.rpc_fetch_open_orders()
|
||||
logger.info(f"rpc_fetch_open_orders: {payload=}")
|
||||
|
||||
|
||||
async def rpc_cancel_order(
|
||||
test_api: GrvtCcxtWS, client_order_id: str, time_to_live_ms: str = ""
|
||||
) -> None:
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
params: dict = {"client_order_id": client_order_id}
|
||||
if time_to_live_ms:
|
||||
params["time_to_live_ms"] = time_to_live_ms
|
||||
payload = await test_api.rpc_cancel_order(params=params)
|
||||
logger.info(f"rpc_cancel_order: {payload=}")
|
||||
|
||||
|
||||
async def rpc_cancel_all_orders(test_api: GrvtCcxtWS) -> None:
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
payload: dict = await test_api.rpc_cancel_all_orders()
|
||||
logger.info(f"rpc_cancel_all_orders: {payload=}")
|
||||
|
||||
|
||||
async def cancel_send_rpc_order(test_api: GrvtCcxtWS) -> None:
|
||||
FN = "cancel_send_rpc_order"
|
||||
"""Cancels order then sends an order to be canceled."""
|
||||
if test_api and test_api._private_key:
|
||||
cloid = str(rand_uint32())
|
||||
logger.info(f"{FN} {cloid=}")
|
||||
await rpc_cancel_order(test_api, cloid, time_to_live_ms="0")
|
||||
await asyncio.sleep(1)
|
||||
await rpc_cancel_order(test_api, cloid, time_to_live_ms="5000")
|
||||
await asyncio.sleep(0.01)
|
||||
await rpc_create_mkt_order(
|
||||
test_api, symbol="BTC_USDT_Perp", side="buy", client_order_id=cloid
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
await rpc_fetch_order(test_api, cloid)
|
||||
|
||||
|
||||
async def send_check_cancel_rpc_order(test_api: GrvtCcxtWS) -> None:
|
||||
if test_api and test_api._private_key:
|
||||
# Send order
|
||||
cloid = await rpc_create_order(test_api, side="buy", price="60000")
|
||||
if cloid:
|
||||
await rpc_fetch_open_orders(test_api)
|
||||
await rpc_fetch_order(test_api, cloid)
|
||||
await asyncio.sleep(5)
|
||||
await rpc_cancel_order(test_api, cloid)
|
||||
cloid = await rpc_create_order(test_api, side="sell", price="70000")
|
||||
if cloid:
|
||||
await rpc_fetch_open_orders(test_api)
|
||||
await rpc_fetch_order(test_api, cloid)
|
||||
await asyncio.sleep(5)
|
||||
await rpc_cancel_all_orders(test_api)
|
||||
|
||||
|
||||
async def send_rpc_messages(test_api: GrvtCcxtWS) -> None:
|
||||
"""Sends test RPC messages for send/fetch/cancel orders."""
|
||||
await send_check_cancel_rpc_order(test_api)
|
||||
await cancel_send_rpc_order(test_api)
|
||||
|
||||
|
||||
async def shutdown(loop, test_api: GrvtCcxtWS) -> None:
|
||||
"""Clean up resources and stop the bot gracefully."""
|
||||
import time
|
||||
logger.info("Shutting down gracefully...")
|
||||
if test_api:
|
||||
for stream, message in test_api._last_message.items():
|
||||
logger.info(f"Last message: {stream=} {message=}")
|
||||
logger.info("Delete GrvtCcxtWS...")
|
||||
del test_api # Close the websocket connection and session
|
||||
time.sleep(3) # Allow time for cleanup
|
||||
await asyncio.sleep(5) # Allow time for cleanup
|
||||
logger.info("Cancelling all tasks...")
|
||||
tasks = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task(loop)]
|
||||
_ = [task.cancel() for task in tasks]
|
||||
logger.info(f"Cancelling {len(tasks)=}")
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info("Shutdown complete.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.get_event_loop()
|
||||
test_api = loop.run_until_complete(subscribe(loop))
|
||||
if not test_api:
|
||||
logger.error("Failed to subscribe to Websocket channels.")
|
||||
sys.exit(1)
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(
|
||||
sig, lambda: asyncio.create_task(shutdown(loop, test_api))
|
||||
)
|
||||
loop.run_until_complete(asyncio.sleep(5))
|
||||
loop.run_until_complete(send_rpc_messages(test_api))
|
||||
loop.run_forever()
|
||||
loop.close()
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
|
||||
from pysdk import grvt_raw_types
|
||||
from pysdk.grvt_raw_async import GrvtRawAsync
|
||||
from pysdk.grvt_raw_base import GrvtError
|
||||
|
||||
from .test_raw_utils import (
|
||||
get_config,
|
||||
get_test_order,
|
||||
get_test_transfer,
|
||||
get_test_withdrawal,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_instruments() -> None:
|
||||
api = GrvtRawAsync(config=get_config())
|
||||
resp = await api.get_all_instruments_v1(
|
||||
grvt_raw_types.ApiGetAllInstrumentsRequest(is_active=True)
|
||||
)
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected results to be non-null")
|
||||
if len(resp.result) == 0:
|
||||
raise ValueError("Expected results to be non-empty")
|
||||
|
||||
|
||||
async def open_orders() -> None:
|
||||
api = GrvtRawAsync(config=get_config())
|
||||
|
||||
# Skip test if trading account id is not set
|
||||
if api.config.trading_account_id is None or api.config.api_key is None:
|
||||
return None # Skip test if configs are not set
|
||||
|
||||
resp = await api.open_orders_v1(
|
||||
grvt_raw_types.ApiOpenOrdersRequest(
|
||||
sub_account_id=str(api.config.trading_account_id),
|
||||
kind=[grvt_raw_types.Kind.PERPETUAL],
|
||||
base=["BTC", "ETH"],
|
||||
quote=["USDT"],
|
||||
)
|
||||
)
|
||||
if isinstance(resp, GrvtError):
|
||||
api.logger.error(f"Received error: {resp}")
|
||||
return None
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected orders to be non-null")
|
||||
if len(resp.result) == 0:
|
||||
api.logger.info("Expected orders to be non-empty")
|
||||
|
||||
|
||||
async def create_order_with_signing() -> None:
|
||||
api = GrvtRawAsync(config=get_config())
|
||||
|
||||
inst_resp = await api.get_all_instruments_v1(
|
||||
grvt_raw_types.ApiGetAllInstrumentsRequest(is_active=True)
|
||||
)
|
||||
if isinstance(inst_resp, GrvtError):
|
||||
raise ValueError(f"Received error: {inst_resp}")
|
||||
|
||||
order = get_test_order(api, {inst.instrument: inst for inst in inst_resp.result})
|
||||
if order is None:
|
||||
return None # Skip test if configs are not set
|
||||
resp = await api.create_order_v1(grvt_raw_types.ApiCreateOrderRequest(order=order))
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected order to be non-null")
|
||||
|
||||
|
||||
async def transfer_with_signing_async() -> None:
|
||||
api = GrvtRawAsync(config=get_config())
|
||||
transfer = get_test_transfer(api)
|
||||
|
||||
if transfer is None:
|
||||
return None # Skip test if configs are not set
|
||||
|
||||
resp = await api.transfer_v1(
|
||||
grvt_raw_types.ApiTransferRequest(
|
||||
transfer.from_account_id,
|
||||
transfer.from_sub_account_id,
|
||||
transfer.to_account_id,
|
||||
transfer.to_sub_account_id,
|
||||
transfer.currency,
|
||||
transfer.num_tokens,
|
||||
transfer.signature,
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected transfer response to be non-null")
|
||||
|
||||
|
||||
async def withdrawal_with_signing_async() -> None:
|
||||
api = GrvtRawAsync(config=get_config())
|
||||
withdrawal = get_test_withdrawal(api)
|
||||
|
||||
if withdrawal is None:
|
||||
return None # Skip test if configs are not set
|
||||
|
||||
resp = await api.withdrawal_v1(
|
||||
grvt_raw_types.ApiWithdrawalRequest(
|
||||
withdrawal.from_account_id,
|
||||
withdrawal.to_eth_address,
|
||||
withdrawal.currency,
|
||||
withdrawal.num_tokens,
|
||||
withdrawal.signature,
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected withdrawal response to be non-null")
|
||||
|
||||
|
||||
def test_get_all_instruments() -> None:
|
||||
asyncio.run(get_all_instruments())
|
||||
|
||||
|
||||
def test_open_orders() -> None:
|
||||
asyncio.run(open_orders())
|
||||
|
||||
|
||||
def test_create_order_with_signing() -> None:
|
||||
asyncio.run(create_order_with_signing())
|
||||
|
||||
|
||||
def test_transfer_with_signing_async() -> None:
|
||||
asyncio.run(transfer_with_signing_async())
|
||||
|
||||
|
||||
def test_withdrawal_with_signing_async() -> None:
|
||||
asyncio.run(withdrawal_with_signing_async())
|
||||
@@ -0,0 +1,400 @@
|
||||
import logging
|
||||
import traceback
|
||||
from pprint import pprint
|
||||
|
||||
from eth_account import Account
|
||||
|
||||
from pysdk.grvt_fixed_types import Transfer
|
||||
from pysdk.grvt_raw_base import GrvtApiConfig
|
||||
from pysdk.grvt_raw_env import GrvtEnv
|
||||
from pysdk.grvt_raw_signing import sign_order, sign_transfer
|
||||
from pysdk.grvt_raw_types import (
|
||||
Instrument,
|
||||
InstrumentSettlementPeriod,
|
||||
Kind,
|
||||
Order,
|
||||
OrderLeg,
|
||||
OrderMetadata,
|
||||
Signature,
|
||||
TimeInForce,
|
||||
TransferType,
|
||||
)
|
||||
|
||||
# Setup logger
|
||||
logging.basicConfig()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def test_sign_order_table():
|
||||
private_key = "f7934647276a6e1fa0af3f4467b4b8ddaf45d25a7368fa1a295eef49a446819d"
|
||||
sub_account_id = "8289849667772468"
|
||||
expiry = 1730800479321350000
|
||||
nonce = 828700936
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "test decimal precision 1, 3 decimals",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.013",
|
||||
limit_price="68900.5",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"want_r": "0xb00512d986a718b15136a8ba23de1c1ec84bbdb9958629cbbe4909bae620bb04",
|
||||
"want_s": "0x79f706de61c68cc14d7734594b5d8689df2b2a7b25951f9a3f61d799f4327ffc",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "test decimal precision 2, 9 decimals",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.123123123",
|
||||
limit_price="68900.777123479",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"want_r": "0x365ec79d299c8bcd5f2acff89faf741a90ca02a4b8a6b1b1a5d4f3d16130f9f0",
|
||||
"want_s": "0x465129bca7855f008ea5bc22fe3ee630e4a8e3b9b99c1745631deef29957048a",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "test decimal precision 3, round down",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.1231231234",
|
||||
limit_price="68900.7771234794",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"want_r": "0x365ec79d299c8bcd5f2acff89faf741a90ca02a4b8a6b1b1a5d4f3d16130f9f0",
|
||||
"want_s": "0x465129bca7855f008ea5bc22fe3ee630e4a8e3b9b99c1745631deef29957048a",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "test decimal precision 4, round down",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.1231231239",
|
||||
limit_price="68900.7771234799",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"want_r": "0x365ec79d299c8bcd5f2acff89faf741a90ca02a4b8a6b1b1a5d4f3d16130f9f0",
|
||||
"want_s": "0x465129bca7855f008ea5bc22fe3ee630e4a8e3b9b99c1745631deef29957048a",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
# {
|
||||
# "name": "no private key",
|
||||
# "order": Order(),
|
||||
# "want_error": ValueError("Private key is not set")
|
||||
# },
|
||||
# {
|
||||
# "name": "decimal precision test",
|
||||
# "order": Order(
|
||||
# sub_account_id="123",
|
||||
# time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
# legs=[
|
||||
# OrderLeg(
|
||||
# instrument="BTC_USDT_Perp",
|
||||
# size="1.013",
|
||||
# limit_price="64170.7",
|
||||
# is_buying_asset=True
|
||||
# )
|
||||
# ],
|
||||
# signature=Signature(
|
||||
# expiration=expiry,
|
||||
# nonce=nonce
|
||||
# )
|
||||
# ),
|
||||
# "want_error": None
|
||||
# }
|
||||
]
|
||||
|
||||
account = Account.from_key(private_key)
|
||||
|
||||
instruments = {
|
||||
"BTC_USDT_Perp": Instrument(
|
||||
instrument="BTC_USDT_Perp",
|
||||
instrument_hash="0x030501",
|
||||
base="BTC",
|
||||
quote="USDT",
|
||||
kind=Kind.PERPETUAL,
|
||||
venues=[],
|
||||
settlement_period=InstrumentSettlementPeriod.DAILY,
|
||||
tick_size="0.00000001",
|
||||
min_size="0.00000001",
|
||||
create_time="123",
|
||||
base_decimals=9,
|
||||
quote_decimals=9,
|
||||
max_position_size="1000000",
|
||||
)
|
||||
}
|
||||
|
||||
for tc in test_cases:
|
||||
config = GrvtApiConfig(
|
||||
env=GrvtEnv.TESTNET,
|
||||
private_key=private_key,
|
||||
trading_account_id=sub_account_id,
|
||||
api_key="not-needed",
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
signed_order = sign_order(tc["order"], config, account, instruments)
|
||||
pprint(signed_order)
|
||||
|
||||
# Verify signature fields are populated
|
||||
assert signed_order.signature.signer == str(account.address)
|
||||
|
||||
# Compare r, s, v values with expected values
|
||||
if "want_r" in tc:
|
||||
assert (
|
||||
signed_order.signature.r == tc["want_r"]
|
||||
), f"Test '{tc['name']}' failed: r value mismatch"
|
||||
if "want_s" in tc:
|
||||
assert (
|
||||
signed_order.signature.s == tc["want_s"]
|
||||
), f"Test '{tc['name']}' failed: s value mismatch"
|
||||
if "want_v" in tc:
|
||||
assert (
|
||||
signed_order.signature.v == tc["want_v"]
|
||||
), f"Test '{tc['name']}' failed: v value mismatch"
|
||||
|
||||
|
||||
def test_sign_transfer_table():
|
||||
chainId = 1
|
||||
private_key = "f7934647276a6e1fa0af3f4467b4b8ddaf45d25a7368fa1a295eef49a446819d"
|
||||
main_account_id = "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1"
|
||||
sub_account_id = "8289849667772468"
|
||||
expiry = "1730800479321350000"
|
||||
nonce = 828700936
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "Transfer $1 from main account to sub account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id="0",
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id=sub_account_id,
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"want_r": "0x21c7d7a8e225cb146c80dc79bbe818f915536817f1343e974cfdbe2bfc952cf1",
|
||||
"want_s": "0x6de83999555f6236e5a56c86876defe00b4776c428ab5a4d7f997d290baaea10",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1.5 from main account to sub account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id="0",
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id=sub_account_id,
|
||||
currency="USDT",
|
||||
num_tokens="1.5",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"want_r": "0xe0a9c66d8d11c3a9ae3624e150cbbdf85d542722cac5255cad4e50af5ac1ddcb",
|
||||
"want_s": "0x06769e5284352ead5735b8b11f9e9510d024bbb889a828889db4cb04132b52aa",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1 from sub account to main account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id=sub_account_id,
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"want_r": "0xc1214ee17dbc14f183297b9dd3f93120b16e633691817ee26045451bc629101c",
|
||||
"want_s": "0x2de27d226a3d3188742629ab222d430d7989d6ea3e6a86bc259606e371123df3",
|
||||
"want_v": 27,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1.5 from sub account to main account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id=sub_account_id,
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1.5",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"want_r": "0xb9b80dfd4b0d53e64b6dd1067d7d936c79a8c3966175bcefb2021cc71d08116f",
|
||||
"want_s": "0x3cbe955c9f56e41f70c658e02df07873e77d347aa5c422943b87fdfd94293ae6",
|
||||
"want_v": 27,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1 external",
|
||||
"transfer": Transfer(
|
||||
from_account_id="0x922a4874196806460fc63b5bcbff45f94c87f76f",
|
||||
from_sub_account_id="0",
|
||||
to_account_id="0x6a3434fce60ff567f60d80fb98f2f981e9b081fd",
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"want_r": "0x185ad129ca0de3584fcd91f6df0c25d8065411041db117c50dabd057249a1a43",
|
||||
"want_s": "0x58b1979c1f8c65970578bc2756f17a0b5c7352c27007811800dcd8966351647d",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1.5 external revert",
|
||||
"transfer": Transfer(
|
||||
from_account_id="0x6a3434fce60ff567f60d80fb98f2f981e9b081fd",
|
||||
from_sub_account_id="0",
|
||||
to_account_id="0x922a4874196806460fc63b5bcbff45f94c87f76f",
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1.5",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"want_r": "0xbbdd4726fef5cddb6eeb5fac07ed95702673293133d66481777a6a3ee82adc12",
|
||||
"want_s": "0x0ad3592ea3ebf428b260c56723386383253481bc188d9aeb87d9b21b85069821",
|
||||
"want_v": 28,
|
||||
"want_error": None,
|
||||
},
|
||||
]
|
||||
|
||||
account = Account.from_key(private_key)
|
||||
config = GrvtApiConfig(
|
||||
env=GrvtEnv.TESTNET,
|
||||
private_key=private_key,
|
||||
trading_account_id=sub_account_id,
|
||||
api_key="not-needed",
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
for tc in test_cases:
|
||||
signed = sign_transfer(tc["transfer"], config, account, chainId)
|
||||
pprint(signed)
|
||||
|
||||
# Verify signature fields are populated
|
||||
assert signed.signature.signer == str(account.address)
|
||||
|
||||
# Compare r, s, v values with expected values
|
||||
if "want_r" in tc:
|
||||
assert (
|
||||
signed.signature.r == tc["want_r"]
|
||||
), f"Test '{tc['name']}' failed: r value mismatch"
|
||||
if "want_s" in tc:
|
||||
assert (
|
||||
signed.signature.s == tc["want_s"]
|
||||
), f"Test '{tc['name']}' failed: s value mismatch"
|
||||
if "want_v" in tc:
|
||||
assert (
|
||||
signed.signature.v == tc["want_v"]
|
||||
), f"Test '{tc['name']}' failed: v value mismatch"
|
||||
|
||||
|
||||
def main():
|
||||
functions = [
|
||||
test_sign_order_table,
|
||||
test_sign_transfer_table,
|
||||
]
|
||||
for f in functions:
|
||||
try:
|
||||
f()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {f.__name__}: {e} {traceback.format_exc()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,837 @@
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from eth_account import Account
|
||||
from eth_account.messages import encode_typed_data
|
||||
|
||||
from pysdk.grvt_fixed_types import Transfer
|
||||
from pysdk.grvt_raw_base import GrvtApiConfig
|
||||
from pysdk.grvt_raw_env import GrvtEnv
|
||||
from pysdk.grvt_raw_signing import (
|
||||
EIP712_ORDER_MESSAGE_TYPE,
|
||||
EIP712_TRANSFER_MESSAGE_TYPE,
|
||||
build_EIP712_order_message_data,
|
||||
build_EIP712_transfer_message_data,
|
||||
get_EIP712_domain_data,
|
||||
sign_order,
|
||||
)
|
||||
from pysdk.grvt_raw_types import (
|
||||
Instrument,
|
||||
InstrumentSettlementPeriod,
|
||||
Kind,
|
||||
Order,
|
||||
OrderLeg,
|
||||
OrderMetadata,
|
||||
Signature,
|
||||
TimeInForce,
|
||||
TransferType,
|
||||
)
|
||||
|
||||
# Setup logger
|
||||
logging.basicConfig()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def test_sign_order_table():
|
||||
# Generated using https://key.tokenpocket.pro/#/?network=ETH
|
||||
# NOTE: `0x` hexadecimal prefix removed
|
||||
public_key = "ee2060eECaC18beC7F8F670D751801294911E445"
|
||||
private_key = "c0663ca94684aead40c41a1cb3a94b68a24296e87245be3a186e882a29a15ee0"
|
||||
|
||||
sub_account_id = "8289849667772468"
|
||||
expiry = 1730800479321350000
|
||||
nonce = 828700936
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "test decimal precision 1, 3 decimals",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.013",
|
||||
limit_price="68900.5",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"subAccountID": "8289849667772468",
|
||||
"isMarket": false,
|
||||
"timeInForce": 1,
|
||||
"postOnly": false,
|
||||
"reduceOnly": false,
|
||||
"legs": [
|
||||
{
|
||||
"assetID": "0x030501",
|
||||
"contractSize": 1013000000,
|
||||
"limitPrice": 68900500000000,
|
||||
"isBuyingContract": false
|
||||
}
|
||||
],
|
||||
"nonce": 828700936,
|
||||
"expiration": 1730800479321350000
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 326
|
||||
}""",
|
||||
# Per EIP-191: https://eips.ethereum.org/EIPS/eip-191
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "1254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16",
|
||||
"body": "41650c08ab6e720f899307d7c2b4381a10c1301888375cec2dfefd6a583859eb"
|
||||
}""",
|
||||
# EIP-712 signable message format: https://eips.ethereum.org/EIPS/eip-712
|
||||
# Pre-image of the signed message's message hash
|
||||
# encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19"‖ version ‖ domainSeparator ‖ hashStruct(message)
|
||||
"digest_input": "0x19011254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f1641650c08ab6e720f899307d7c2b4381a10c1301888375cec2dfefd6a583859eb",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "03cb7ca7b353969ab2c00ff92fd472f81f59a84d28fa1aa39128176f21062982",
|
||||
"r": 14615867946748605809126568669694142791729730263440553623296112010401671344650,
|
||||
"s": 41758084966111141828834491248882149351523023670747687501271185591898818786045,
|
||||
"v": 28,
|
||||
"signature": "205049c0db6e38fd88e4e46be81db7bc354520d52ec6268d210fa35b86c4f20a5c523d0ff8e6f12542fdcf34a6e6e2c547986b7c8fa53f86a5e656d9ab44b2fd1c"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "test decimal precision 2, 9 decimals",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.123123123",
|
||||
limit_price="68900.777123479",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"subAccountID": "8289849667772468",
|
||||
"isMarket": false,
|
||||
"timeInForce": 1,
|
||||
"postOnly": false,
|
||||
"reduceOnly": false,
|
||||
"legs": [
|
||||
{
|
||||
"assetID": "0x030501",
|
||||
"contractSize": 1123123123,
|
||||
"limitPrice": 68900777123479,
|
||||
"isBuyingContract": false
|
||||
}
|
||||
],
|
||||
"nonce": 828700936,
|
||||
"expiration": 1730800479321350000
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 326
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "1254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16",
|
||||
"body": "e85ffec169d4ebd4b8057e5fbd31ed31d4511b2f2299bbce0adab6beb5fe2814"
|
||||
}""",
|
||||
"digest_input": "0x19011254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16e85ffec169d4ebd4b8057e5fbd31ed31d4511b2f2299bbce0adab6beb5fe2814",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "2d0a437f7d64523386974c2a729d69604f8e2a7f0684b7de923845d8b175ab69",
|
||||
"r": 30067953785684815030293507105225786115862149795459199007947867478230986262090,
|
||||
"s": 23299979093064779986156565292425191814752388247725004533191995996670719399433,
|
||||
"v": 28,
|
||||
"signature": "4279dbd734584fb07b016bce7d9f029e7f7f80e378dc65cddc06aa6b0c9e064a33835221a0fbcb700de3068169b45572a27756bff479f2887e8c89139e6f96091c"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "test decimal precision 3, round down",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.1231231234",
|
||||
limit_price="68900.7771234794",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"subAccountID": "8289849667772468",
|
||||
"isMarket": false,
|
||||
"timeInForce": 1,
|
||||
"postOnly": false,
|
||||
"reduceOnly": false,
|
||||
"legs": [
|
||||
{
|
||||
"assetID": "0x030501",
|
||||
"contractSize": 1123123123,
|
||||
"limitPrice": 68900777123479,
|
||||
"isBuyingContract": false
|
||||
}
|
||||
],
|
||||
"nonce": 828700936,
|
||||
"expiration": 1730800479321350000
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 326
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "1254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16",
|
||||
"body": "e85ffec169d4ebd4b8057e5fbd31ed31d4511b2f2299bbce0adab6beb5fe2814"
|
||||
}""",
|
||||
"digest_input": "0x19011254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16e85ffec169d4ebd4b8057e5fbd31ed31d4511b2f2299bbce0adab6beb5fe2814",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "2d0a437f7d64523386974c2a729d69604f8e2a7f0684b7de923845d8b175ab69",
|
||||
"r": 30067953785684815030293507105225786115862149795459199007947867478230986262090,
|
||||
"s": 23299979093064779986156565292425191814752388247725004533191995996670719399433,
|
||||
"v": 28,
|
||||
"signature": "4279dbd734584fb07b016bce7d9f029e7f7f80e378dc65cddc06aa6b0c9e064a33835221a0fbcb700de3068169b45572a27756bff479f2887e8c89139e6f96091c"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "test decimal precision 4, round down",
|
||||
"order": Order(
|
||||
metadata=OrderMetadata(
|
||||
client_order_id="1", create_time="1730800479321350000"
|
||||
),
|
||||
sub_account_id=sub_account_id,
|
||||
time_in_force=TimeInForce.GOOD_TILL_TIME,
|
||||
post_only=False,
|
||||
is_market=False,
|
||||
reduce_only=False,
|
||||
legs=[
|
||||
OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.1231231239",
|
||||
limit_price="68900.7771234799",
|
||||
is_buying_asset=False,
|
||||
)
|
||||
],
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"subAccountID": "8289849667772468",
|
||||
"isMarket": false,
|
||||
"timeInForce": 1,
|
||||
"postOnly": false,
|
||||
"reduceOnly": false,
|
||||
"legs": [
|
||||
{
|
||||
"assetID": "0x030501",
|
||||
"contractSize": 1123123123,
|
||||
"limitPrice": 68900777123479,
|
||||
"isBuyingContract": false
|
||||
}
|
||||
],
|
||||
"nonce": 828700936,
|
||||
"expiration": 1730800479321350000
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 326
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "1254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16",
|
||||
"body": "e85ffec169d4ebd4b8057e5fbd31ed31d4511b2f2299bbce0adab6beb5fe2814"
|
||||
}""",
|
||||
"digest_input": "0x19011254f97f8495f704630a238cbcd898a4b8ab20d77bb93e17049d3445f4f81f16e85ffec169d4ebd4b8057e5fbd31ed31d4511b2f2299bbce0adab6beb5fe2814",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "2d0a437f7d64523386974c2a729d69604f8e2a7f0684b7de923845d8b175ab69",
|
||||
"r": 30067953785684815030293507105225786115862149795459199007947867478230986262090,
|
||||
"s": 23299979093064779986156565292425191814752388247725004533191995996670719399433,
|
||||
"v": 28,
|
||||
"signature": "4279dbd734584fb07b016bce7d9f029e7f7f80e378dc65cddc06aa6b0c9e064a33835221a0fbcb700de3068169b45572a27756bff479f2887e8c89139e6f96091c"
|
||||
}""",
|
||||
},
|
||||
]
|
||||
|
||||
account = Account.from_key(private_key)
|
||||
|
||||
instruments = {
|
||||
"BTC_USDT_Perp": Instrument(
|
||||
instrument="BTC_USDT_Perp",
|
||||
instrument_hash="0x030501",
|
||||
base="BTC",
|
||||
quote="USDT",
|
||||
kind=Kind.PERPETUAL,
|
||||
venues=[],
|
||||
settlement_period=InstrumentSettlementPeriod.DAILY,
|
||||
tick_size="0.00000001",
|
||||
min_size="0.00000001",
|
||||
create_time="123",
|
||||
base_decimals=9,
|
||||
quote_decimals=9,
|
||||
max_position_size="1000000",
|
||||
)
|
||||
}
|
||||
|
||||
for tc in test_cases:
|
||||
config = GrvtApiConfig(
|
||||
env=GrvtEnv.TESTNET,
|
||||
private_key=private_key,
|
||||
trading_account_id=sub_account_id,
|
||||
api_key="not-needed",
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
# Get intermediate values
|
||||
message_data = build_EIP712_order_message_data(tc["order"], instruments)
|
||||
domain_data = get_EIP712_domain_data(config.env, 326)
|
||||
signable_message = encode_typed_data(
|
||||
domain_data, EIP712_ORDER_MESSAGE_TYPE, message_data
|
||||
)
|
||||
signed_message = account.sign_message(signable_message)
|
||||
signed_order = sign_order(tc["order"], config, account, instruments)
|
||||
|
||||
# Convert to comparable strings
|
||||
message_data_json = json.dumps(message_data, indent=2)
|
||||
domain_data_json = json.dumps(domain_data, indent=2)
|
||||
signable_message_json = json.dumps(
|
||||
{
|
||||
"version": signable_message.version.hex(),
|
||||
"header": signable_message.header.hex(),
|
||||
"body": signable_message.body.hex(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
signed_message_json = json.dumps(
|
||||
{
|
||||
"message_hash": signed_message.message_hash.hex(),
|
||||
"r": signed_message.r,
|
||||
"s": signed_message.s,
|
||||
"v": signed_message.v,
|
||||
"signature": signed_message.signature.hex(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
# EIP-712 signable message format: https://eips.ethereum.org/EIPS/eip-712
|
||||
signed_message_hash_preimage = (
|
||||
"0x19"
|
||||
+ signable_message.version.hex()
|
||||
+ signable_message.header.hex()
|
||||
+ signable_message.body.hex()
|
||||
)
|
||||
|
||||
# Strip whitespace for comparison
|
||||
assert (
|
||||
message_data_json.strip() == tc["expected_message_data_json"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: message_data mismatch.
|
||||
Wanted:
|
||||
{tc['expected_message_data_json'].strip()}
|
||||
Got:
|
||||
{message_data_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
domain_data_json.strip() == tc["expected_domain_data_json"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: domain_data mismatch.
|
||||
Wanted:
|
||||
{tc['expected_domain_data_json'].strip()}
|
||||
Got:
|
||||
{domain_data_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
signable_message_json.strip() == tc["expected_signable_message"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: signable_message mismatch.
|
||||
Wanted:
|
||||
{tc['expected_signable_message'].strip()}
|
||||
Got:
|
||||
{signable_message_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
signed_message_hash_preimage.strip() == tc["digest_input"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: digest_input mismatch.
|
||||
Wanted:
|
||||
{tc["digest_input"].strip()
|
||||
}
|
||||
Got:
|
||||
{signed_message_hash_preimage.strip()}
|
||||
"""
|
||||
assert (
|
||||
signed_message_json.strip() == tc["expected_signed_message"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: signed_message mismatch.
|
||||
Wanted:
|
||||
{tc['expected_signed_message'].strip()}
|
||||
Got:
|
||||
{signed_message_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
signed_order.signature.signer == str(account.address) == "0x" + public_key
|
||||
), f"""Test '{tc['name']}' failed: signer mismatch."""
|
||||
|
||||
|
||||
def test_sign_transfer_table():
|
||||
chainId = 1
|
||||
private_key = "f7934647276a6e1fa0af3f4467b4b8ddaf45d25a7368fa1a295eef49a446819d"
|
||||
main_account_id = "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1"
|
||||
sub_account_id = "8289849667772468"
|
||||
expiry = "1730800479321350000"
|
||||
nonce = 828700936
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "Transfer $1 from main account to sub account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id="0",
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id=sub_account_id,
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"fromAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"fromSubAccount": "0",
|
||||
"toAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"toSubAccount": "8289849667772468",
|
||||
"tokenCurrency": 3,
|
||||
"numTokens": 1000000,
|
||||
"nonce": 828700936,
|
||||
"expiration": "1730800479321350000"
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 1
|
||||
}""",
|
||||
# Per EIP-191: https://eips.ethereum.org/EIPS/eip-191
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6",
|
||||
"body": "8dfbfc161ca14b60b318aec118c0f77137ab6d5c0d6f4aa283a75995ec842a9f"
|
||||
}""",
|
||||
# EIP-712 signable message format: https://eips.ethereum.org/EIPS/eip-712
|
||||
# Pre-image of the signed message's message hash
|
||||
# encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19"‖ version ‖ domainSeparator ‖ hashStruct(message)
|
||||
"digest_input": "0x1901950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f68dfbfc161ca14b60b318aec118c0f77137ab6d5c0d6f4aa283a75995ec842a9f",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "f237c6e8ceaf8f75c35537c26a5810f5029fe3fa0179d636cfa75dba0eeaecda",
|
||||
"r": 15279414997690414521303216846501751476684522786442627331685751803272563600625,
|
||||
"s": 49712406548009011982925909577001640710775887931482920893646195243522061953552,
|
||||
"v": 28,
|
||||
"signature": "21c7d7a8e225cb146c80dc79bbe818f915536817f1343e974cfdbe2bfc952cf16de83999555f6236e5a56c86876defe00b4776c428ab5a4d7f997d290baaea101c"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1.5 from main account to sub account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id="0",
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id=sub_account_id,
|
||||
currency="USDT",
|
||||
num_tokens="1.5",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"fromAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"fromSubAccount": "0",
|
||||
"toAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"toSubAccount": "8289849667772468",
|
||||
"tokenCurrency": 3,
|
||||
"numTokens": 1500000,
|
||||
"nonce": 828700936,
|
||||
"expiration": "1730800479321350000"
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 1
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6",
|
||||
"body": "efc25031927cbee99a4b8c408d427168218312cb5fcbc2ef0644a25c411e9cd6"
|
||||
}""",
|
||||
"digest_input": "0x1901950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6efc25031927cbee99a4b8c408d427168218312cb5fcbc2ef0644a25c411e9cd6",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "e9a82979035693538d20cbd16a231379d68b2484f065a64b5ac962a0ef45ed2c",
|
||||
"r": 101618044735866410136029494650850506089543609286068143785089360195209387957707,
|
||||
"s": 2923457745704967739422721965786309607758766142290846761836065357300251710122,
|
||||
"v": 28,
|
||||
"signature": "e0a9c66d8d11c3a9ae3624e150cbbdf85d542722cac5255cad4e50af5ac1ddcb06769e5284352ead5735b8b11f9e9510d024bbb889a828889db4cb04132b52aa1c"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1 from sub account to main account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id=sub_account_id,
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"fromAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"fromSubAccount": "8289849667772468",
|
||||
"toAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"toSubAccount": "0",
|
||||
"tokenCurrency": 3,
|
||||
"numTokens": 1000000,
|
||||
"nonce": 828700936,
|
||||
"expiration": "1730800479321350000"
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 1
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6",
|
||||
"body": "325bbcdd0af37fd856ab19d5a34f04c98b4b9125fd924a41d149290d85cf5d1a"
|
||||
}""",
|
||||
"digest_input": "0x1901950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6325bbcdd0af37fd856ab19d5a34f04c98b4b9125fd924a41d149290d85cf5d1a",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "05a4682aaaffda9347f7f577623dc86be873d75680250d94f30e27fb450b0597",
|
||||
"r": 87355230145152558239319417798390737905701563801639010151017443441731256782876,
|
||||
"s": 20754249269006714296744776682911604802815023385414018875577784419437276970483,
|
||||
"v": 27,
|
||||
"signature": "c1214ee17dbc14f183297b9dd3f93120b16e633691817ee26045451bc629101c2de27d226a3d3188742629ab222d430d7989d6ea3e6a86bc259606e371123df31b"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1.5 from sub account to main account",
|
||||
"transfer": Transfer(
|
||||
from_account_id=main_account_id,
|
||||
from_sub_account_id=sub_account_id,
|
||||
to_account_id=main_account_id,
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1.5",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"fromAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"fromSubAccount": "8289849667772468",
|
||||
"toAccount": "0x0c1f4c8ee7acd9ea19b91bbb343cbaf6efd58ce1",
|
||||
"toSubAccount": "0",
|
||||
"tokenCurrency": 3,
|
||||
"numTokens": 1500000,
|
||||
"nonce": 828700936,
|
||||
"expiration": "1730800479321350000"
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 1
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6",
|
||||
"body": "d20efa4f5ca3d7cd996d0a7be827a788fdea40ad9dc3f7d64f909ec3df2f0f1c"
|
||||
}""",
|
||||
"digest_input": "0x1901950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6d20efa4f5ca3d7cd996d0a7be827a788fdea40ad9dc3f7d64f909ec3df2f0f1c",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "57de810bfd4c46796b923242eba5fba199c69ec6159fd7f0bae13d20e6b2e32f",
|
||||
"r": 84003073399296424218543069615592899281416934630850361306323613156742252728687,
|
||||
"s": 27475502714605040799395938380083883707063662756512201821577577919846841662182,
|
||||
"v": 27,
|
||||
"signature": "b9b80dfd4b0d53e64b6dd1067d7d936c79a8c3966175bcefb2021cc71d08116f3cbe955c9f56e41f70c658e02df07873e77d347aa5c422943b87fdfd94293ae61b"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1 external",
|
||||
"transfer": Transfer(
|
||||
from_account_id="0x922a4874196806460fc63b5bcbff45f94c87f76f",
|
||||
from_sub_account_id="0",
|
||||
to_account_id="0x6a3434fce60ff567f60d80fb98f2f981e9b081fd",
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"fromAccount": "0x922a4874196806460fc63b5bcbff45f94c87f76f",
|
||||
"fromSubAccount": "0",
|
||||
"toAccount": "0x6a3434fce60ff567f60d80fb98f2f981e9b081fd",
|
||||
"toSubAccount": "0",
|
||||
"tokenCurrency": 3,
|
||||
"numTokens": 1000000,
|
||||
"nonce": 828700936,
|
||||
"expiration": "1730800479321350000"
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 1
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6",
|
||||
"body": "c92edbd85a6756bbc1d472694218135800c5c34cecd62dfecf8b79696fb30a5a"
|
||||
}""",
|
||||
"digest_input": "0x1901950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6c92edbd85a6756bbc1d472694218135800c5c34cecd62dfecf8b79696fb30a5a",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "aa540bb37dc69583724239e2ee089f9863760b1f5dd3707f63254f5f9045a36b",
|
||||
"r": 11015968193451536627767691276906473050663313657726362297424610772811255519811,
|
||||
"s": 40117308978565698603770991327546519074883337154968913527997965101318785819773,
|
||||
"v": 28,
|
||||
"signature": "185ad129ca0de3584fcd91f6df0c25d8065411041db117c50dabd057249a1a4358b1979c1f8c65970578bc2756f17a0b5c7352c27007811800dcd8966351647d1c"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "Transfer $1.5 external revert",
|
||||
"transfer": Transfer(
|
||||
from_account_id="0x6a3434fce60ff567f60d80fb98f2f981e9b081fd",
|
||||
from_sub_account_id="0",
|
||||
to_account_id="0x922a4874196806460fc63b5bcbff45f94c87f76f",
|
||||
to_sub_account_id="0",
|
||||
currency="USDT",
|
||||
num_tokens="1.5",
|
||||
signature=Signature(
|
||||
signer="", r="", s="", v=0, expiration=expiry, nonce=nonce
|
||||
),
|
||||
transfer_type=TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
"expected_message_data_json": """
|
||||
{
|
||||
"fromAccount": "0x6a3434fce60ff567f60d80fb98f2f981e9b081fd",
|
||||
"fromSubAccount": "0",
|
||||
"toAccount": "0x922a4874196806460fc63b5bcbff45f94c87f76f",
|
||||
"toSubAccount": "0",
|
||||
"tokenCurrency": 3,
|
||||
"numTokens": 1500000,
|
||||
"nonce": 828700936,
|
||||
"expiration": "1730800479321350000"
|
||||
}""",
|
||||
"expected_domain_data_json": """
|
||||
{
|
||||
"name": "GRVT Exchange",
|
||||
"version": "0",
|
||||
"chainId": 1
|
||||
}""",
|
||||
"expected_signable_message": """
|
||||
{
|
||||
"version": "01",
|
||||
"header": "950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f6",
|
||||
"body": "5660d6d82b43a7bd01c7bee9379478b27e2c7774d9e9aebd69ea163c523f8730"
|
||||
}""",
|
||||
"digest_input": "0x1901950ca409f14e4f89a10e63790c40b5dc9c5fab89944e8c98112dece5b08415f65660d6d82b43a7bd01c7bee9379478b27e2c7774d9e9aebd69ea163c523f8730",
|
||||
"expected_signed_message": """
|
||||
{
|
||||
"message_hash": "2a02bf2a7772d74f176d75f987e984dd253837aba1faed4b0a65ba5a3038ce06",
|
||||
"r": 84973466961705873082056285677608514305288271637256010073726199940890275535890,
|
||||
"s": 4896548729346283309439956649162331116008267310844279523516289648065258100769,
|
||||
"v": 28,
|
||||
"signature": "bbdd4726fef5cddb6eeb5fac07ed95702673293133d66481777a6a3ee82adc120ad3592ea3ebf428b260c56723386383253481bc188d9aeb87d9b21b850698211c"
|
||||
}""",
|
||||
},
|
||||
]
|
||||
|
||||
account = Account.from_key(private_key)
|
||||
config = GrvtApiConfig(
|
||||
env=GrvtEnv.TESTNET,
|
||||
private_key=private_key,
|
||||
trading_account_id=sub_account_id,
|
||||
api_key="not-needed",
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
for tc in test_cases:
|
||||
message_data = build_EIP712_transfer_message_data(
|
||||
tc["transfer"], 3 # assume all test cases are USDT transfers
|
||||
)
|
||||
domain_data = get_EIP712_domain_data(config.env, chainId)
|
||||
signable_message = encode_typed_data(
|
||||
domain_data, EIP712_TRANSFER_MESSAGE_TYPE, message_data
|
||||
)
|
||||
signed_message = account.sign_message(signable_message)
|
||||
|
||||
# Convert to comparable strings
|
||||
message_data_json = json.dumps(message_data, indent=2)
|
||||
domain_data_json = json.dumps(domain_data, indent=2)
|
||||
signable_message_json = json.dumps(
|
||||
{
|
||||
"version": signable_message.version.hex(),
|
||||
"header": signable_message.header.hex(),
|
||||
"body": signable_message.body.hex(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
signed_message_json = json.dumps(
|
||||
{
|
||||
"message_hash": signed_message.message_hash.hex(),
|
||||
"r": signed_message.r,
|
||||
"s": signed_message.s,
|
||||
"v": signed_message.v,
|
||||
"signature": signed_message.signature.hex(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
# EIP-712 signable message format: https://eips.ethereum.org/EIPS/eip-712
|
||||
signed_message_hash_preimage = (
|
||||
"0x19"
|
||||
+ signable_message.version.hex()
|
||||
+ signable_message.header.hex()
|
||||
+ signable_message.body.hex()
|
||||
)
|
||||
|
||||
# Strip whitespace for comparison
|
||||
assert (
|
||||
message_data_json.strip() == tc["expected_message_data_json"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: message_data mismatch.
|
||||
Wanted:
|
||||
{tc['expected_message_data_json'].strip()}
|
||||
Got:
|
||||
{message_data_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
domain_data_json.strip() == tc["expected_domain_data_json"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: domain_data mismatch.
|
||||
Wanted:
|
||||
{tc['expected_domain_data_json'].strip()}
|
||||
Got:
|
||||
{domain_data_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
signable_message_json.strip() == tc["expected_signable_message"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: signable_message mismatch.
|
||||
Wanted:
|
||||
{tc['expected_signable_message'].strip()}
|
||||
Got:
|
||||
{signable_message_json.strip()}
|
||||
"""
|
||||
assert (
|
||||
signed_message_hash_preimage.strip() == tc["digest_input"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: digest_input mismatch.
|
||||
Wanted:
|
||||
{tc["digest_input"].strip()
|
||||
}
|
||||
Got:
|
||||
{signed_message_hash_preimage.strip()}
|
||||
"""
|
||||
assert (
|
||||
signed_message_json.strip() == tc["expected_signed_message"].strip()
|
||||
), f"""
|
||||
Test '{tc['name']}' failed: signed_message mismatch.
|
||||
Wanted:
|
||||
{tc['expected_signed_message'].strip()}
|
||||
Got:
|
||||
{signed_message_json.strip()}
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
functions = [
|
||||
test_sign_transfer_table,
|
||||
]
|
||||
for f in functions:
|
||||
try:
|
||||
f()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {f.__name__}: {e} {traceback.format_exc()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
from pysdk import grvt_raw_types
|
||||
from pysdk.grvt_raw_base import GrvtError
|
||||
from pysdk.grvt_raw_sync import GrvtRawSync
|
||||
|
||||
from .test_raw_utils import (
|
||||
get_config,
|
||||
get_test_order,
|
||||
get_test_tpsl_order,
|
||||
get_test_transfer,
|
||||
get_test_withdrawal,
|
||||
)
|
||||
|
||||
|
||||
def test_get_all_instruments() -> None:
|
||||
api = GrvtRawSync(config=get_config())
|
||||
resp = api.get_all_instruments_v1(
|
||||
grvt_raw_types.ApiGetAllInstrumentsRequest(is_active=True)
|
||||
)
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected results to be non-null")
|
||||
if len(resp.result) == 0:
|
||||
raise ValueError("Expected results to be non-empty")
|
||||
|
||||
|
||||
def test_open_orders() -> None:
|
||||
api = GrvtRawSync(config=get_config())
|
||||
|
||||
# Skip test if trading account id is not set
|
||||
if api.config.trading_account_id is None or api.config.api_key is None:
|
||||
return None # Skip test if configs are not set
|
||||
|
||||
resp = api.open_orders_v1(
|
||||
grvt_raw_types.ApiOpenOrdersRequest(
|
||||
# sub_account_id=233, Uncomment to test error path with invalid sub account id
|
||||
sub_account_id=str(api.config.trading_account_id),
|
||||
kind=[grvt_raw_types.Kind.PERPETUAL],
|
||||
base=["BTC", "ETH"],
|
||||
quote=["USDT"],
|
||||
)
|
||||
)
|
||||
if isinstance(resp, GrvtError):
|
||||
api.logger.error(f"Received error: {resp}")
|
||||
return None
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected orders to be non-null")
|
||||
if len(resp.result) == 0:
|
||||
api.logger.info("Expected orders to be non-empty")
|
||||
|
||||
|
||||
def test_create_order_with_signing() -> None:
|
||||
api = GrvtRawSync(config=get_config())
|
||||
|
||||
inst_resp = api.get_all_instruments_v1(
|
||||
grvt_raw_types.ApiGetAllInstrumentsRequest(is_active=True)
|
||||
)
|
||||
if isinstance(inst_resp, GrvtError):
|
||||
raise ValueError(f"Received error: {inst_resp}")
|
||||
|
||||
order = get_test_order(api, {inst.instrument: inst for inst in inst_resp.result})
|
||||
if order is None:
|
||||
return None # Skip test if configs are not set
|
||||
resp = api.create_order_v1(grvt_raw_types.ApiCreateOrderRequest(order=order))
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected order to be non-null")
|
||||
|
||||
|
||||
def test_create_tpsl_order_with_signing() -> None:
|
||||
api = GrvtRawSync(config=get_config())
|
||||
|
||||
inst_resp = api.get_all_instruments_v1(
|
||||
grvt_raw_types.ApiGetAllInstrumentsRequest(is_active=True)
|
||||
)
|
||||
if isinstance(inst_resp, GrvtError):
|
||||
raise ValueError(f"Received error: {inst_resp}")
|
||||
|
||||
order = get_test_tpsl_order(
|
||||
api, {inst.instrument: inst for inst in inst_resp.result}
|
||||
)
|
||||
if order is None:
|
||||
return None # Skip test if configs are not set
|
||||
resp = api.create_order_v1(grvt_raw_types.ApiCreateOrderRequest(order=order))
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected order to be non-null")
|
||||
|
||||
|
||||
def test_transfer_with_signing() -> None:
|
||||
api = GrvtRawSync(config=get_config())
|
||||
transfer = get_test_transfer(api)
|
||||
|
||||
if transfer is None:
|
||||
return None # Skip test if configs are not set
|
||||
|
||||
resp = api.transfer_v1(
|
||||
grvt_raw_types.ApiTransferRequest(
|
||||
transfer.from_account_id,
|
||||
transfer.from_sub_account_id,
|
||||
transfer.to_account_id,
|
||||
transfer.to_sub_account_id,
|
||||
transfer.currency,
|
||||
transfer.num_tokens,
|
||||
transfer.signature,
|
||||
grvt_raw_types.TransferType.STANDARD,
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected transfer response to be non-null")
|
||||
|
||||
|
||||
def test_withdrawal_with_signing() -> None:
|
||||
api = GrvtRawSync(config=get_config())
|
||||
withdrawal = get_test_withdrawal(api)
|
||||
|
||||
if withdrawal is None:
|
||||
return None # Skip test if configs are not set
|
||||
|
||||
resp = api.withdrawal_v1(
|
||||
grvt_raw_types.ApiWithdrawalRequest(
|
||||
withdrawal.from_account_id,
|
||||
withdrawal.to_eth_address,
|
||||
withdrawal.currency,
|
||||
withdrawal.num_tokens,
|
||||
withdrawal.signature,
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected withdrawal response to be non-null")
|
||||
@@ -0,0 +1,158 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
|
||||
from pysdk import grvt_fixed_types, grvt_raw_types
|
||||
from pysdk.grvt_raw_base import GrvtApiConfig, GrvtError
|
||||
from pysdk.grvt_raw_env import GrvtEnv
|
||||
from pysdk.grvt_raw_signing import sign_order, sign_transfer, sign_withdrawal
|
||||
from pysdk.grvt_raw_sync import GrvtRawSync
|
||||
|
||||
|
||||
def get_config() -> GrvtApiConfig:
|
||||
logging.basicConfig()
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
conf = GrvtApiConfig(
|
||||
env=GrvtEnv(os.getenv("GRVT_ENV", "testnet")),
|
||||
trading_account_id=os.getenv("GRVT_SUB_ACCOUNT_ID"),
|
||||
private_key=os.getenv("GRVT_PRIVATE_KEY"),
|
||||
api_key=os.getenv("GRVT_API_KEY"),
|
||||
logger=logger,
|
||||
)
|
||||
logger.debug(conf)
|
||||
return conf
|
||||
|
||||
|
||||
def get_main_account_id(api: GrvtRawSync) -> str:
|
||||
resp = api.funding_account_summary_v1(grvt_raw_types.EmptyRequest())
|
||||
if isinstance(resp, GrvtError):
|
||||
raise ValueError(f"Received error: {resp}")
|
||||
if resp.result is None:
|
||||
raise ValueError("Expected funding_account_summary_v1 response to be non-null")
|
||||
return resp.result.main_account_id
|
||||
|
||||
|
||||
def get_test_order(
|
||||
api: GrvtRawSync, instruments: dict[str, grvt_raw_types.Instrument]
|
||||
) -> grvt_raw_types.Order | None:
|
||||
# Skip test if configs are not set
|
||||
if (
|
||||
api.config.trading_account_id is None
|
||||
or api.config.private_key is None
|
||||
or api.config.api_key is None
|
||||
):
|
||||
return None
|
||||
|
||||
order = grvt_raw_types.Order(
|
||||
sub_account_id=str(api.config.trading_account_id),
|
||||
time_in_force=grvt_raw_types.TimeInForce.GOOD_TILL_TIME,
|
||||
legs=[
|
||||
grvt_raw_types.OrderLeg(
|
||||
instrument="BTC_USDT_Perp",
|
||||
size="1.2", # 1.2 BTC
|
||||
limit_price="64170.7", # 80,000 USDT
|
||||
is_buying_asset=True,
|
||||
)
|
||||
],
|
||||
signature=grvt_raw_types.Signature(
|
||||
signer="", # Populated by sign_order
|
||||
r="", # Populated by sign_order
|
||||
s="", # Populated by sign_order
|
||||
v=0, # Populated by sign_order
|
||||
expiration=str(
|
||||
time.time_ns() + 20 * 24 * 60 * 60 * 1_000_000_000
|
||||
), # 20 days
|
||||
nonce=random.randint(0, 2**32 - 1),
|
||||
),
|
||||
metadata=grvt_raw_types.OrderMetadata(
|
||||
client_order_id=str(random.randint(0, 2**32 - 1)),
|
||||
),
|
||||
)
|
||||
return sign_order(order, api.config, api.account, instruments)
|
||||
|
||||
|
||||
def get_test_tpsl_order(
|
||||
api: GrvtRawSync, instruments: dict[str, grvt_raw_types.Instrument]
|
||||
) -> grvt_raw_types.Order | None:
|
||||
order = get_test_order(api, instruments)
|
||||
if order:
|
||||
order.metadata.trigger = grvt_raw_types.TriggerOrderMetadata(
|
||||
trigger_type=grvt_raw_types.TriggerType.TAKE_PROFIT,
|
||||
tpsl=grvt_raw_types.TPSLOrderMetadata(
|
||||
trigger_by=grvt_raw_types.TriggerBy.LAST,
|
||||
trigger_price="64000",
|
||||
),
|
||||
)
|
||||
return order
|
||||
|
||||
|
||||
def get_test_transfer(api: GrvtRawSync) -> grvt_fixed_types.Transfer | None:
|
||||
# Skip test if configs are not set
|
||||
if (
|
||||
api.config.trading_account_id is None
|
||||
or api.config.private_key is None
|
||||
or api.config.api_key is None
|
||||
):
|
||||
return None
|
||||
|
||||
funding_account_address = get_main_account_id(api)
|
||||
|
||||
return sign_transfer(
|
||||
grvt_fixed_types.Transfer(
|
||||
from_account_id=funding_account_address,
|
||||
from_sub_account_id="0",
|
||||
to_account_id=funding_account_address,
|
||||
to_sub_account_id=str(api.config.trading_account_id),
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=grvt_raw_types.Signature(
|
||||
signer="",
|
||||
r="",
|
||||
s="",
|
||||
v=0,
|
||||
expiration=str(
|
||||
time.time_ns() + 20 * 24 * 60 * 60 * 1_000_000_000
|
||||
), # 20 days
|
||||
nonce=random.randint(0, 2**32 - 1),
|
||||
),
|
||||
transfer_type=grvt_fixed_types.TransferType.STANDARD,
|
||||
transfer_metadata="",
|
||||
),
|
||||
api.config,
|
||||
api.account,
|
||||
)
|
||||
|
||||
|
||||
def get_test_withdrawal(api: GrvtRawSync) -> grvt_raw_types.Withdrawal | None:
|
||||
# Skip test if configs are not set
|
||||
if (
|
||||
api.config.trading_account_id is None
|
||||
or api.config.private_key is None
|
||||
or api.config.api_key is None
|
||||
):
|
||||
return None
|
||||
|
||||
funding_account_address = get_main_account_id(api)
|
||||
|
||||
return sign_withdrawal(
|
||||
grvt_raw_types.Withdrawal(
|
||||
from_account_id=funding_account_address,
|
||||
to_eth_address="0xed3FF6F4E84a64556e8F7d149dC3533f0c7D9c49", # Just a test address
|
||||
currency="USDT",
|
||||
num_tokens="1",
|
||||
signature=grvt_raw_types.Signature(
|
||||
signer="",
|
||||
r="",
|
||||
s="",
|
||||
v=0,
|
||||
expiration=str(
|
||||
time.time_ns() + 20 * 24 * 60 * 60 * 1_000_000_000
|
||||
), # 20 days
|
||||
nonce=random.randint(0, 2**32 - 1),
|
||||
),
|
||||
),
|
||||
api.config,
|
||||
api.account,
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .test_grvt_raw_sync import test_transfer_with_signing
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_transfer_with_signing()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .test_grvt_raw_sync import test_withdrawal_with_signing
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_withdrawal_with_signing()
|
||||
Generated
+1961
File diff suppressed because it is too large
Load Diff
+39
-208
@@ -1,231 +1,62 @@
|
||||
# GRVT TypeScript SDK
|
||||
# GRVT
|
||||
|
||||
This SDK provides a TypeScript interface to interact with the GRVT API. It supports both REST API and WebSocket connections.
|
||||
Node.js & JavaScript client for GRVT REST APIs & WebSockets
|
||||
|
||||
## Installation
|
||||
## Installing
|
||||
|
||||
Using npm:
|
||||
|
||||
```bash
|
||||
npm install @grvt/sdk
|
||||
npm install @grvt/client
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### REST API Client
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ECurrency,
|
||||
ETransferType,
|
||||
ITransferMetadata,
|
||||
ETransferProvider,
|
||||
ETransferDirection,
|
||||
EGrvtEnvironment,
|
||||
EChain,
|
||||
ISigningOption
|
||||
} from '@grvt/sdk';
|
||||
|
||||
// Initialize the client
|
||||
const client = new GrvtClient({
|
||||
apiKey: 'your-api-key',
|
||||
apiSecret: 'your-api-secret',
|
||||
env: EGrvtEnvironment.DEV,
|
||||
});
|
||||
|
||||
// Get funding account summary
|
||||
const accountSummary = await client.getFundingAccountSummary();
|
||||
|
||||
// Get sub account summary
|
||||
const subAccountSummary = await client.getSubAccountSummary({
|
||||
sub_account_id: 'your-sub-account-id',
|
||||
});
|
||||
|
||||
// Transfer examples
|
||||
// Note: the signature field is optional. If not provided, the SDK will automatically compute it using the apiSecret and provided signing options
|
||||
|
||||
// Standard transfer
|
||||
const transfer1 = await client.transfer({
|
||||
from_account_id: 'from-account-id',
|
||||
from_sub_account_id: 'from-sub-account-id',
|
||||
to_account_id: 'to-account-id',
|
||||
to_sub_account_id: 'to-sub-account-id',
|
||||
currency: ECurrency.USDT,
|
||||
num_tokens: '100',
|
||||
transfer_type: ETransferType.STANDARD,
|
||||
});
|
||||
|
||||
|
||||
// Metadata for transfer, you can pass it as the second argument for the transfer API
|
||||
const metadata: ITransferMetadata = {
|
||||
provider: ETransferProvider.RHINO;
|
||||
direction: ETransferDirection.DEPOSIT; // Use ETransferDirection.WITHDRAWAL for withdraw flow
|
||||
chainid: Echain.TRON,
|
||||
endpoint,
|
||||
provider_tx_id: tx_hash,
|
||||
provider_ref_id: commit_id,
|
||||
};
|
||||
|
||||
// Signing options for generating the signature as the third argument for the transfer API
|
||||
// Note: nonce must be non-negative and expiration must be within 30 days
|
||||
const signingOptions: ISigningOption = {
|
||||
nonce: 12345,
|
||||
expiration: '1746093221289693252'
|
||||
};
|
||||
|
||||
const transfer2 = await client.transfer(
|
||||
{
|
||||
from_account_id: 'from-account-id',
|
||||
to_account_id: 'to-account-id',
|
||||
currency: ECurrency.USDT,
|
||||
num_tokens: '100',
|
||||
transfer_type: ETransferType.NON_NATIVE_BRIDGE_DEPOSIT, // Use NON_NATIVE_BRIDGE_WITHDRAW for withdraw flow
|
||||
},
|
||||
metadata,
|
||||
signingOptions
|
||||
);
|
||||
|
||||
// Request deposit approval
|
||||
// This API is used to get signature for a deposit before executing it
|
||||
const depositApproval = await client.requestDepositApproval({
|
||||
l1Sender: 'your-l1-address', // L1 address of the sending wallet
|
||||
l2Receiver: 'your-l2-address', // Your L2 address to receive the funds
|
||||
l1Token: 'token-contract-address', // L1 token contract address
|
||||
amount: '100' // Amount to deposit
|
||||
});
|
||||
|
||||
|
||||
// Withdraw funds from your account
|
||||
// Note: the signature field is optional. If not provided, the SDK will automatically compute it using the apiSecret and provided signing options
|
||||
const withdrawResult = await client.withdraw({
|
||||
from_account_id: 'your-account-id',
|
||||
to_eth_address: 'destination-eth-address',
|
||||
currency: ECurrency.USDT,
|
||||
num_tokens: '100'
|
||||
});
|
||||
|
||||
// Query transfer history
|
||||
const transferHistory = await client.getTransferHistory({
|
||||
start_time: '1745600642000785050' // timestamp in nanosecond, use this to filter transfers with event time >= start_time
|
||||
end_time: '17588917787741000000' // timestamp in nanoseconds, use this to filter transfers with event time <= end_time
|
||||
});
|
||||
// You can filter more & do pagination with this query if needed, please take a look at the request interface to get more details
|
||||
|
||||
// Query deposit history
|
||||
const depositHistory = await client.getDepositHistory({
|
||||
start_time: '1745600642000785050' // timestamp in nanosecond, use this to filter deposits with event time >= start_time
|
||||
end_time: '17588917787741000000' // timestamp in nanoseconds, use this to filter deposits with event time <= end_time
|
||||
});
|
||||
// You can filter more & do pagination with this query if needed, please take a look at the request interface to get more details
|
||||
|
||||
|
||||
// Get current server time, in milliseconds since epoch
|
||||
const currentTime = await client.getCurrentTime()
|
||||
// Example result: 1747397398409
|
||||
|
||||
// Convert Rhino chain to Gravity Echain
|
||||
// Result will depend on the environment, specifically
|
||||
// - DEV, STAGING - Rhino DEV
|
||||
// - TESTNET - Rhino STG
|
||||
// - PRODUCTIOn - Rhino PROD
|
||||
// This will return null if the chain ID is not found or not supported
|
||||
import { SupportedChains } from "@rhino.fi/sdk"
|
||||
|
||||
const chainID = await client.getGravityChainIDFromRhinoChain(SupportedChains.BNB_SMART_CHAIN)
|
||||
// Result:
|
||||
// - On DEV/STAGING/TESTNET: 97
|
||||
// - On PRODUCTION: 56
|
||||
|
||||
```
|
||||
|
||||
### WebSocket Client
|
||||
|
||||
The WebSocket client supports real-time data streaming and follows the same authentication mehanism as the REST API client.
|
||||
|
||||
```typescript
|
||||
import { GrvtWsClient, EGrvtEnvironment } from '@grvt/sdk';
|
||||
|
||||
// Initialize the WebSocket client
|
||||
const client = new GrvtWsClient({
|
||||
apiKey: 'your-api-key',
|
||||
env: EGrvtEnvironment.DEV,
|
||||
});
|
||||
|
||||
// Connect to WebSocket
|
||||
await client.connect();
|
||||
|
||||
// Subscribe to transfer history
|
||||
client.subscribeTransferHistory(
|
||||
'main-account-id',
|
||||
(data) => {
|
||||
console.log('Received transfer:', data);
|
||||
},
|
||||
'sub-account-id' // optional
|
||||
);
|
||||
|
||||
// Disconnect when done
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
#### WebSocket Features
|
||||
|
||||
1. **Authentication**:
|
||||
|
||||
- Uses the same cookie-based authentication as the REST API
|
||||
- Automatically refreshes cookies when needed
|
||||
|
||||
2. **Connection Management**:
|
||||
|
||||
- Automatic reconnection with exponential backoff
|
||||
- Connection monitoring with 5-second timeout
|
||||
- Reconnects if no messages are received within the timeout period
|
||||
|
||||
3. **Subscription Handling**:
|
||||
|
||||
- Unique subscription IDs for each subscription
|
||||
- Subscriptions are not automatically restored after reconnection
|
||||
- Users need to manually resubscribe after reconnection
|
||||
|
||||
4. **Error Handling**:
|
||||
- Automatic error logging
|
||||
- Graceful disconnection handling
|
||||
- Reconnection attempts with configurable maximum retries
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
Using yarn:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
yarn add @grvt/client
|
||||
```
|
||||
|
||||
### Formatting
|
||||
Using pnpm:
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
pnpm add @grvt/client
|
||||
```
|
||||
|
||||
### Testing
|
||||
Once the package is installed, you can import the library using `import` or `require` approach:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run SDK tests
|
||||
npm run test:sdk
|
||||
|
||||
# Run WebSocket tests
|
||||
npm run test:ws
|
||||
```js
|
||||
import GRVT from '@grvt/client'
|
||||
```
|
||||
|
||||
### Linting and Formating
|
||||
You can also use the default export, since the named export is just a re-export from the GRVT factory:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```js
|
||||
import GRVT from '@grvt/client'
|
||||
console.log(
|
||||
new GRVT.MDG({
|
||||
host: 'https://market-data.dev.gravitymarkets.io',
|
||||
version: 'v1'
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
If you use `require` for importing:
|
||||
|
||||
```js
|
||||
const GRVT = require('@grvt/client')
|
||||
console.log(
|
||||
new GRVT.MDG({
|
||||
host: 'https://market-data.dev.gravitymarkets.io',
|
||||
version: 'v1'
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
## License
|
||||
## To use WebSocket (available only in browsers/platforms that support WebSocket)
|
||||
|
||||
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
||||
[Browsers supported](https://caniuse.com/websockets)
|
||||
|
||||
```js
|
||||
import { EStreamEndpoints, WS } from '@grvt/client/ws'
|
||||
console.log(new WS('wss://market-data.dev.gravitymarkets.io/ws'))
|
||||
```
|
||||
Reference in New Issue
Block a user