Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
ethereum_private_key.txt
# macOS
.DS_Store
.AppleDouble
Expand Down Expand Up @@ -117,3 +118,4 @@ site/
*trader

node_modules
/.nix-venv
47 changes: 44 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,54 @@
.PHONY: clean
clean: clean-build clean-pyc clean-test clean-docs

.PHONY: clean-build
clean-build:
rm -fr build/
rm -fr dist/
rm -fr .eggs/
rm -fr deployments/build/
rm -fr deployments/Dockerfiles/open_aea/packages
rm -fr pip-wheel-metadata
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -fr {} +
find . -name '*.svn' -exec rm -fr {} +
find . -name '*.db' -exec rm -fr {} +
rm -fr .idea .history
rm -fr venv

.PHONY: clean-docs
clean-docs:
rm -fr site/

.PHONY: clean-pyc
clean-pyc:
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +

.PHONY: clean-test
clean-test:
rm -fr .tox/
rm -f .coverage
find . -name ".coverage*" -not -name ".coveragerc" -exec rm -fr "{}" \;
rm -fr coverage.xml
rm -fr htmlcov/
rm -fr .hypothesis
rm -fr .pytest_cache
rm -fr .mypy_cache/
find . -name 'log.txt' -exec rm -fr {} +
find . -name 'log.*.txt' -exec rm -fr {} +

.PHONY: tests
tests:
poetry run pytest tests -vv --reruns 10 --reruns-delay 30

fmt:
poetry run black tests lyra examples
poetry run isort tests lyra examples
poetry run black tests derive examples
poetry run isort tests derive examples

lint:
poetry run flake8 tests lyra examples
poetry run flake8 tests derive examples

all: fmt lint tests

Expand Down
48 changes: 44 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,56 @@
# Lyra V2 Python Client.
# Derive.xyz Python Client.

This repo provides a unified interface for the Lyra V2 Exchange.
This repo provides a unified interface for the Derive Exchange.

Please checkout the [examples](./examples) directory for usage.

Here is a quick demonstration of the cli functionality.

![alt text](lyra_demo.gif "Demo of cli tools.")
![alt text](derive_demo.gif "Demo of cli tools.")


## Preparing Keys for the Client

To use the client, you will need to generate an API key from the Derive Exchange.

The process involves linking your local signer to the account you want to use programmatically.

Here are the steps:

0. Generate a local signer using your preferred method. For example, you can use the Open Aea Ledger Ethereum Cli.
```bash
aea generate-key ethereum
```
This will generate a new private key in the `ethereum_private_key.txt` file.

1. Go to the [Derive Exchange](https://derive.xyz) and create an account.
2. Go to the API section and create a new [API key](https://.derive.xyz/api-keys/developers).
3. Register a new Session key with the Public Address of the account your signer generated in step 0.

Once you have the API key, you can use it to interact with the Derive Exchange.

You need;

`DERIVE_WALLET` - The programtic wallet generated upon account creation. It can be found in the Developer section of the Derive Exchange.
`SIGNER_PRIVATE_KEY` - The private key generated in step 0.
`SUBACCOOUNT_ID` - The subaccount id you want to use for the API key.

```python
derive_client = DeriveClient(
private_key=TEST_PRIVATE_KEY,
env=Environment.TEST, # or Environment.PROD
wallet=TEST_WALLET,
subaccount_id = 123456
)
```




## Install

```bash
pip install lyra-v2-client
pip install derive-client
```

## Dev
Expand Down Expand Up @@ -52,3 +91,4 @@ tbump $new_version
The release workflow will then detect that a branch with a `v` prefix exists and create a release from it.

Additionally, the package will be published to PyPI.

6 changes: 6 additions & 0 deletions derive_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Init for the derive client
"""
from .derive import DeriveClient

DeriveClient
File renamed without changes.
161 changes: 122 additions & 39 deletions lyra/async_client.py → derive_client/async_client.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
"""
Async client for Lyra
Async client for Derive
"""

import asyncio
from decimal import Decimal
import json
import time
from datetime import datetime

from derive_client.base_client import ApiException
from lyra_v2_action_signing.utils import (
MAX_INT_32,
decimal_to_big_int,
get_action_nonce,
sign_rest_auth_header,
sign_ws_login,
utc_now_ms,
)
import aiohttp
from web3 import Web3

from lyra.constants import CONTRACTS, TEST_PRIVATE_KEY
from lyra.enums import Environment, InstrumentType, OrderSide, OrderType, TimeInForce, UnderlyingCurrency
from lyra.utils import get_logger
from lyra.ws_client import WsClient as BaseClient
from derive_client.constants import CONTRACTS, DEFAULT_REFERER, TEST_PRIVATE_KEY
from derive_client.enums import Environment, InstrumentType, OrderSide, OrderType, TimeInForce, UnderlyingCurrency
from derive_client.utils import get_logger
from derive_client.ws_client import WsClient as BaseClient


class AsyncClient(BaseClient):
"""
We use the async client to make async requests to the lyra API
We us the ws client to make async requests to the lyra ws API
We use the async client to make async requests to the derive API
We us the ws client to make async requests to the derive ws API
"""

current_subscriptions = {}
Expand All @@ -37,9 +46,6 @@ def __init__(
subaccount_id=None,
wallet=None,
):
"""
Initialize the LyraClient class.
"""
self.verbose = verbose
self.env = env
self.contracts = CONTRACTS[env]
Expand Down Expand Up @@ -97,7 +103,7 @@ async def subscribe(self, instrument_name: str, group: str = "1", depth: str = "
if channel not in self.message_queues:
self.message_queues[channel] = asyncio.Queue()
msg = {"method": "subscribe", "params": {"channels": [channel]}}
await self.ws.send_json(msg)
await self._ws.send_json(msg)
return

while instrument_name not in self.current_subscriptions:
Expand All @@ -106,8 +112,8 @@ async def subscribe(self, instrument_name: str, group: str = "1", depth: str = "

async def connect_ws(self):
self.connecting = True
session = aiohttp.ClientSession()
ws = await session.ws_connect(self.contracts['WS_ADDRESS'])
self.session = aiohttp.ClientSession()
ws = await self.session.ws_connect(self.contracts['WS_ADDRESS'])
self._ws = ws
self.connecting = False
return ws
Expand Down Expand Up @@ -137,21 +143,50 @@ async def listen_for_messages(
data = msg['params']['data']
self.handle_message(subscription, data)

# async def login_client(
# self,
# ):
# login_request = {
# 'method': 'public/login',
# 'params': self.sign_authentication_header(),
# 'id': str(int(time.time())),
# }
# await self._ws.send_json(login_request)
# async for data in self._ws:
# message = json.loads(data.data)
# if message['id'] == login_request['id']:
# if "result" not in message:
# raise Exception(f"Unable to login {message}")
# break

async def login_client(
self,
retries=3,
):
login_request = {
'method': 'public/login',
'params': self.sign_authentication_header(),
'id': str(int(time.time())),
'params': sign_ws_login(
web3_client=self.web3_client,
smart_contract_wallet=self.wallet,
session_key_or_wallet_private_key=self.signer._private_key,
),
'id': str(utc_now_ms()),
}
await self._ws.send_json(login_request)
async for data in self._ws:
message = json.loads(data.data)
# we need to wait for the response
async for msg in self._ws:
message = json.loads(msg.data)
if message['id'] == login_request['id']:
if "result" not in message:
raise Exception(f"Unable to login {message}")
if self._check_output_for_rate_limit(message):
return await self.login_client()
raise ApiException(message['error'])
break
# except (Exception) as error:
# if retries:
# await asyncio.sleep(1)
# return await self.login_client(retries=retries - 1)
# raise error

def handle_message(self, subscription, data):
bids = data['bids']
Expand Down Expand Up @@ -266,7 +301,13 @@ async def get_open_orders(self, status, currency: UnderlyingCurrency = Underlyin
return super().fetch_orders(
status=status,
)

async def fetch_ticker(self, instrument_name: str):
"""
Fetch the ticker for a symbol
"""
return super().fetch_ticker(instrument_name
)

async def create_order(
self,
price,
Expand All @@ -276,30 +317,72 @@ async def create_order(
side: OrderSide = OrderSide.BUY,
order_type: OrderType = OrderType.LIMIT,
time_in_force: TimeInForce = TimeInForce.GTC,
instrument_type: InstrumentType = InstrumentType.PERP,
underlying_currency: UnderlyingCurrency = UnderlyingCurrency.USDC,

):
"""
Create the order.
"""
if not self._ws:
await self.connect_ws()
await self.login_client()
if side.name.upper() not in OrderSide.__members__:
raise Exception(f"Invalid side {side}")
order = self._define_order(
instrument_name=instrument_name,
price=price,
amount=amount,
side=side,
instruments = await self._internal_map_instrument(instrument_type, underlying_currency)
instrument = instruments[instrument_name]
module_data = {
"asset_address": instrument['base_asset_address'],
"sub_id": int(instrument['base_asset_sub_id']),
"limit_price": Decimal(price),
"amount": Decimal(str(amount)),
"max_fee": Decimal(1000),
"recipient_id": int(self.subaccount_id),
"is_bid": side == OrderSide.BUY,
}

signed_action = self._generate_signed_action(
module_address=self.contracts['TRADE_MODULE_ADDRESS'], module_data=module_data
)
_currency = UnderlyingCurrency[instrument_name.split("-")[0]]
if instrument_name.split("-")[1] == "PERP":
instruments = await self.fetch_instruments(instrument_type=InstrumentType.PERP, currency=_currency)
instruments = {i['instrument_name']: i for i in instruments}
base_asset_sub_id = instruments[instrument_name]['base_asset_sub_id']
instrument_type = InstrumentType.PERP
else:
instruments = await self.fetch_instruments(instrument_type=InstrumentType.OPTION, currency=_currency)
instruments = {i['instrument_name']: i for i in instruments}
base_asset_sub_id = instruments[instrument_name]['base_asset_sub_id']
instrument_type = InstrumentType.OPTION

signed_order = self._sign_order(order, base_asset_sub_id, instrument_type, _currency)
response = self.submit_order(signed_order)

order = {
"instrument_name": instrument_name,
"direction": side.name.lower(),
"order_type": order_type.name.lower(),
"mmp": False,
"time_in_force": time_in_force.value,
"referral_code": DEFAULT_REFERER if not self.referral_code else self.referral_code,
**signed_action.to_json(),
}
try:
response = await self.submit_order(order)
except aiohttp.ClientConnectionResetError:
await self.connect_ws()
await self.login_client()
response = await self.submit_order(order)
return response

async def _internal_map_instrument(self, instrument_type, currency):
"""
Map the instrument.
"""
instruments = await self.fetch_instruments(instrument_type=instrument_type, currency=currency)
return {i['instrument_name']: i for i in instruments}

async def submit_order(self, order):
id = str(utc_now_ms())
await self._ws.send_json({'method': 'private/order', 'params': order, 'id': id})
while True:
async for msg in self._ws:
message = json.loads(msg.data)
if message['id'] == id:
try:
if "result" not in message:
if self._check_output_for_rate_limit(message):
return await self.submit_order(order)
raise ApiException(message['error'])
return message['result']['order']
except KeyError as error:
print(message)
raise Exception(f"Unable to submit order {message}") from error

Loading
Loading