derive-client 0.2.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.3
2
+ Name: derive-client
3
+ Version: 0.2.12
4
+ Summary:
5
+ Author: 8baller
6
+ Author-email: 8baller@station.codes
7
+ Requires-Python: >=3.9,<3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Requires-Dist: derive-action-signing (>=0.0.9,<0.0.10)
13
+ Requires-Dist: eth-account (>=0.13)
14
+ Requires-Dist: pandas (>=1,<=3)
15
+ Requires-Dist: python-dotenv (>=0.14.0,<0.18.0)
16
+ Requires-Dist: requests (>=2,<3)
17
+ Requires-Dist: rich-click (>=1.7.1,<2.0.0)
18
+ Requires-Dist: setuptools (>=68.2.2,<80)
19
+ Requires-Dist: web3 (>=6,<8)
20
+ Requires-Dist: websocket-client (>=0.32.0,<1)
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Derive.xyz Python Client.
24
+
25
+ This repo provides a unified interface for the Derive Exchange.
26
+
27
+ Please checkout the [examples](./examples) directory for usage.
28
+
29
+ Here is a quick demonstration of the cli functionality.
30
+
31
+ ![alt text](derive_demo.gif "Demo of cli tools.")
32
+
33
+
34
+ ## Preparing Keys for the Client
35
+
36
+ To use the client, you will need to generate an API key from the Derive Exchange.
37
+
38
+ The process involves linking your local signer to the account you want to use programmatically.
39
+
40
+ Here are the steps:
41
+
42
+ 0. Generate a local signer using your preferred method. For example, you can use the Open Aea Ledger Ethereum Cli.
43
+ ```bash
44
+ aea generate-key ethereum
45
+ ```
46
+ This will generate a new private key in the `ethereum_private_key.txt` file.
47
+
48
+ 1. Go to the [Derive Exchange](https://derive.xyz) and create an account.
49
+ 2. Go to the API section and create a new [API key](https://.derive.xyz/api-keys/developers).
50
+ 3. Register a new Session key with the Public Address of the account your signer generated in step 0.
51
+
52
+ Once you have the API key, you can use it to interact with the Derive Exchange.
53
+
54
+ You need;
55
+
56
+ `DERIVE_WALLET` - The programtic wallet generated upon account creation. It can be found in the Developer section of the Derive Exchange.
57
+ `SIGNER_PRIVATE_KEY` - The private key generated in step 0.
58
+ `SUBACCOOUNT_ID` - The subaccount id you want to use for the API key.
59
+
60
+ ```python
61
+ derive_client = DeriveClient(
62
+ private_key=TEST_PRIVATE_KEY,
63
+ env=Environment.TEST, # or Environment.PROD
64
+ wallet=TEST_WALLET,
65
+ subaccount_id = 123456
66
+ )
67
+ ```
68
+
69
+
70
+
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install derive-client
76
+ ```
77
+
78
+ ## Dev
79
+
80
+ ### Formatting
81
+
82
+ ```bash
83
+ make fmt
84
+ ```
85
+
86
+ ### Linting
87
+
88
+ ```bash
89
+ make lint
90
+ ```
91
+
92
+ ### Tests
93
+
94
+ ```bash
95
+ make tests
96
+ ```
97
+
98
+ For convience, all commands can be run with:
99
+
100
+ ```
101
+ make all
102
+ ```
103
+
104
+ ### Releasing
105
+
106
+ We can use `tbump` to automatically bump our versions in preparation of a release.
107
+
108
+ ```bash
109
+ export new_version=0.1.5
110
+ tbump $new_version
111
+ ```
112
+
113
+ The release workflow will then detect that a branch with a `v` prefix exists and create a release from it.
114
+
115
+ Additionally, the package will be published to PyPI.
116
+
117
+
@@ -0,0 +1,94 @@
1
+ # Derive.xyz Python Client.
2
+
3
+ This repo provides a unified interface for the Derive Exchange.
4
+
5
+ Please checkout the [examples](./examples) directory for usage.
6
+
7
+ Here is a quick demonstration of the cli functionality.
8
+
9
+ ![alt text](derive_demo.gif "Demo of cli tools.")
10
+
11
+
12
+ ## Preparing Keys for the Client
13
+
14
+ To use the client, you will need to generate an API key from the Derive Exchange.
15
+
16
+ The process involves linking your local signer to the account you want to use programmatically.
17
+
18
+ Here are the steps:
19
+
20
+ 0. Generate a local signer using your preferred method. For example, you can use the Open Aea Ledger Ethereum Cli.
21
+ ```bash
22
+ aea generate-key ethereum
23
+ ```
24
+ This will generate a new private key in the `ethereum_private_key.txt` file.
25
+
26
+ 1. Go to the [Derive Exchange](https://derive.xyz) and create an account.
27
+ 2. Go to the API section and create a new [API key](https://.derive.xyz/api-keys/developers).
28
+ 3. Register a new Session key with the Public Address of the account your signer generated in step 0.
29
+
30
+ Once you have the API key, you can use it to interact with the Derive Exchange.
31
+
32
+ You need;
33
+
34
+ `DERIVE_WALLET` - The programtic wallet generated upon account creation. It can be found in the Developer section of the Derive Exchange.
35
+ `SIGNER_PRIVATE_KEY` - The private key generated in step 0.
36
+ `SUBACCOOUNT_ID` - The subaccount id you want to use for the API key.
37
+
38
+ ```python
39
+ derive_client = DeriveClient(
40
+ private_key=TEST_PRIVATE_KEY,
41
+ env=Environment.TEST, # or Environment.PROD
42
+ wallet=TEST_WALLET,
43
+ subaccount_id = 123456
44
+ )
45
+ ```
46
+
47
+
48
+
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install derive-client
54
+ ```
55
+
56
+ ## Dev
57
+
58
+ ### Formatting
59
+
60
+ ```bash
61
+ make fmt
62
+ ```
63
+
64
+ ### Linting
65
+
66
+ ```bash
67
+ make lint
68
+ ```
69
+
70
+ ### Tests
71
+
72
+ ```bash
73
+ make tests
74
+ ```
75
+
76
+ For convience, all commands can be run with:
77
+
78
+ ```
79
+ make all
80
+ ```
81
+
82
+ ### Releasing
83
+
84
+ We can use `tbump` to automatically bump our versions in preparation of a release.
85
+
86
+ ```bash
87
+ export new_version=0.1.5
88
+ tbump $new_version
89
+ ```
90
+
91
+ The release workflow will then detect that a branch with a `v` prefix exists and create a release from it.
92
+
93
+ Additionally, the package will be published to PyPI.
94
+
@@ -0,0 +1,6 @@
1
+ """
2
+ Init for the derive client
3
+ """
4
+ from .derive import DeriveClient
5
+
6
+ DeriveClient
@@ -0,0 +1,50 @@
1
+ """
2
+ Class based analyser for portfolios.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ import pandas as pd
8
+
9
+ pd.set_option('display.precision', 2)
10
+
11
+
12
+ DELTA_COLUMNS = ['delta', 'gamma', 'vega', 'theta']
13
+
14
+
15
+ class PortfolioAnalyser:
16
+ raw_data: List[dict]
17
+ df: pd.DataFrame
18
+
19
+ def __init__(self, raw_data: List[dict]):
20
+ self.raw_data = raw_data
21
+ self.positions = pd.DataFrame.from_records(raw_data['positions'])
22
+ self.positions["amount"] = pd.to_numeric(self.positions["amount"])
23
+ for col in DELTA_COLUMNS:
24
+ self.positions[col] = pd.to_numeric(self.positions[col])
25
+ adjusted_greek = self.positions[col] * self.positions.amount
26
+ self.positions[col] = adjusted_greek
27
+
28
+ self.positions = self.positions.apply(pd.to_numeric, errors='ignore')
29
+
30
+ def get_positions(self, underlying_currency: str) -> pd.DataFrame:
31
+ df = self.positions
32
+ df = df[df['instrument_name'].str.contains(underlying_currency.upper())]
33
+ return df
34
+
35
+ def get_open_positions(self, underlying_currency: str) -> pd.DataFrame:
36
+ df = self.get_positions(underlying_currency)
37
+ return df[df['amount'] != 0]
38
+
39
+ def get_total_greeks(self, underlying_currency: str) -> pd.DataFrame:
40
+ df = self.get_open_positions(underlying_currency)
41
+ return df[DELTA_COLUMNS].sum()
42
+
43
+ def get_subaccount_value(self) -> float:
44
+ return float(self.raw_data['subaccount_value'])
45
+
46
+ def print_positions(self, underlying_currency: str, columns: Optional[List[str]] = None):
47
+ df = self.get_open_positions(underlying_currency)
48
+ if columns:
49
+ df = df[[c for c in columns if c not in DELTA_COLUMNS] + DELTA_COLUMNS]
50
+ print(df)
@@ -0,0 +1,343 @@
1
+ """
2
+ Async client for Derive
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from datetime import datetime
9
+ from decimal import ROUND_DOWN, Decimal
10
+
11
+ import aiohttp
12
+ from derive_action_signing.utils import sign_ws_login, utc_now_ms
13
+ from web3 import Web3
14
+
15
+ from derive_client.base_client import ApiException
16
+ from derive_client.constants import CONTRACTS, DEFAULT_REFERER, TEST_PRIVATE_KEY
17
+ from derive_client.enums import Environment, InstrumentType, OrderSide, OrderType, TimeInForce, UnderlyingCurrency
18
+ from derive_client.utils import get_logger
19
+ from derive_client.ws_client import WsClient as BaseClient
20
+
21
+
22
+ class DeriveAsyncClient(BaseClient):
23
+ """
24
+ We use the async client to make async requests to the derive API
25
+ We us the ws client to make async requests to the derive ws API
26
+ """
27
+
28
+ current_subscriptions = {}
29
+
30
+ listener = None
31
+ subscribing = False
32
+ _ws = None
33
+
34
+ def __init__(
35
+ self,
36
+ private_key: str = TEST_PRIVATE_KEY,
37
+ env: Environment = Environment.TEST,
38
+ logger=None,
39
+ verbose=False,
40
+ subaccount_id=None,
41
+ wallet=None,
42
+ ):
43
+ self.verbose = verbose
44
+ self.env = env
45
+ self.contracts = CONTRACTS[env]
46
+ self.logger = logger or get_logger()
47
+ self.web3_client = Web3()
48
+ self.signer = self.web3_client.eth.account.from_key(private_key)
49
+ self.wallet = self.signer.address if not wallet else wallet
50
+ print(f"Signing address: {self.signer.address}")
51
+ if wallet:
52
+ print(f"Using wallet: {wallet}")
53
+ self.subaccount_id = subaccount_id
54
+ print(f"Using subaccount id: {self.subaccount_id}")
55
+ self.message_queues = {}
56
+ self.connecting = False
57
+ # we make sure to get the event loop
58
+
59
+ @property
60
+ async def ws(self):
61
+ if self._ws is None:
62
+ self._ws = await self.connect_ws()
63
+ if not self._ws.connected:
64
+ self._ws = await self.connect_ws()
65
+ return self._ws
66
+
67
+ def get_subscription_id(self, instrument_name: str, group: str = "1", depth: str = "100"):
68
+ return f"orderbook.{instrument_name}.{group}.{depth}"
69
+
70
+ async def subscribe(self, instrument_name: str, group: str = "1", depth: str = "100"):
71
+ """
72
+ Subscribe to the order book for a symbol
73
+ """
74
+ # if self.listener is None or self.listener.done():
75
+ asyncio.create_task(self.listen_for_messages())
76
+ channel = self.get_subscription_id(instrument_name, group, depth)
77
+ if channel not in self.message_queues:
78
+ self.message_queues[channel] = asyncio.Queue()
79
+ msg = {"method": "subscribe", "params": {"channels": [channel]}}
80
+ await self._ws.send_json(msg)
81
+ return
82
+
83
+ while instrument_name not in self.current_subscriptions:
84
+ await asyncio.sleep(0.01)
85
+ return self.current_subscriptions[instrument_name]
86
+
87
+ async def connect_ws(self):
88
+ self.connecting = True
89
+ self.session = aiohttp.ClientSession()
90
+ ws = await self.session.ws_connect(self.contracts['WS_ADDRESS'])
91
+ self._ws = ws
92
+ self.connecting = False
93
+ return ws
94
+
95
+ async def listen_for_messages(
96
+ self,
97
+ ):
98
+ while True:
99
+ try:
100
+ msg = await self.ws.receive_json()
101
+ except TypeError:
102
+ continue
103
+ if "error" in msg:
104
+ print(msg)
105
+ raise Exception(msg["error"])
106
+ if "result" in msg:
107
+ result = msg["result"]
108
+ if "status" in result:
109
+ # print(f"Succesfully subscribed to {result['status']}")
110
+ for channel, value in result['status'].items():
111
+ # print(f"Channel {channel} has value {value}")
112
+ if "error" in value:
113
+ raise Exception(f"Subscription error for channel: {channel} error: {value['error']}")
114
+ continue
115
+ # default to putting the message in the queue
116
+ subscription = msg['params']['channel']
117
+ data = msg['params']['data']
118
+ self.handle_message(subscription, data)
119
+
120
+ async def login_client(
121
+ self,
122
+ retries=3,
123
+ ):
124
+ login_request = {
125
+ 'method': 'public/login',
126
+ 'params': sign_ws_login(
127
+ web3_client=self.web3_client,
128
+ smart_contract_wallet=self.wallet,
129
+ session_key_or_wallet_private_key=self.signer._private_key,
130
+ ),
131
+ 'id': str(utc_now_ms()),
132
+ }
133
+ await self._ws.send_json(login_request)
134
+ # we need to wait for the response
135
+ async for msg in self._ws:
136
+ message = json.loads(msg.data)
137
+ if message['id'] == login_request['id']:
138
+ if "result" not in message:
139
+ if self._check_output_for_rate_limit(message):
140
+ return await self.login_client()
141
+ raise ApiException(message['error'])
142
+ break
143
+
144
+ def handle_message(self, subscription, data):
145
+ bids = data['bids']
146
+ asks = data['asks']
147
+
148
+ bids = list(map(lambda x: (float(x[0]), float(x[1])), bids))
149
+ asks = list(map(lambda x: (float(x[0]), float(x[1])), asks))
150
+
151
+ instrument_name = subscription.split(".")[1]
152
+
153
+ if subscription in self.current_subscriptions:
154
+ old_params = self.current_subscriptions[subscription]
155
+ _asks, _bids = old_params["asks"], old_params["bids"]
156
+ if not asks:
157
+ asks = _asks
158
+ if not bids:
159
+ bids = _bids
160
+ timestamp = data['timestamp']
161
+ datetime_str = datetime.fromtimestamp(timestamp / 1000)
162
+ nonce = data['publish_id']
163
+ self.current_subscriptions[instrument_name] = {
164
+ "asks": asks,
165
+ "bids": bids,
166
+ "timestamp": timestamp,
167
+ "datetime": datetime_str.isoformat(),
168
+ "nonce": nonce,
169
+ "symbol": instrument_name,
170
+ }
171
+ return self.current_subscriptions[instrument_name]
172
+
173
+ async def watch_order_book(self, instrument_name: str, group: str = "1", depth: str = "100"):
174
+ """
175
+ Watch the order book for a symbol
176
+ orderbook.{instrument_name}.{group}.{depth}
177
+ """
178
+
179
+ if not self.ws and not self.connecting:
180
+ await self.connect_ws()
181
+ await self.login_client()
182
+
183
+ subscription = self.get_subscription_id(instrument_name, group, depth)
184
+
185
+ if subscription not in self.message_queues:
186
+ while any([self.subscribing, self.ws is None, self.connecting]):
187
+ await asyncio.sleep(1)
188
+ await self.subscribe(instrument_name, group, depth)
189
+
190
+ while instrument_name not in self.current_subscriptions and not self.connecting:
191
+ await asyncio.sleep(0.01)
192
+
193
+ return self.current_subscriptions[instrument_name]
194
+
195
+ async def fetch_instruments(
196
+ self,
197
+ expired=False,
198
+ instrument_type: InstrumentType = InstrumentType.PERP,
199
+ currency: UnderlyingCurrency = UnderlyingCurrency.BTC,
200
+ ):
201
+ return super().fetch_instruments(expired, instrument_type, currency)
202
+
203
+ async def close(self):
204
+ """
205
+ Close the connection
206
+ """
207
+ self.ws.close()
208
+
209
+ async def fetch_tickers(
210
+ self,
211
+ instrument_type: InstrumentType = InstrumentType.OPTION,
212
+ currency: UnderlyingCurrency = UnderlyingCurrency.BTC,
213
+ ):
214
+ if not self._ws:
215
+ await self.connect_ws()
216
+ instruments = await self.fetch_instruments(instrument_type=instrument_type, currency=currency)
217
+ instrument_names = [i['instrument_name'] for i in instruments]
218
+ id_base = str(int(time.time()))
219
+ ids_to_instrument_names = {
220
+ f'{id_base}_{enumerate}': instrument_name for enumerate, instrument_name in enumerate(instrument_names)
221
+ }
222
+ for id, instrument_name in ids_to_instrument_names.items():
223
+ payload = {"instrument_name": instrument_name}
224
+ await self._ws.send_json({'method': 'public/get_ticker', 'params': payload, 'id': id})
225
+ await asyncio.sleep(0.1) # otherwise we get rate limited...
226
+ results = {}
227
+ while ids_to_instrument_names:
228
+ message = await self._ws.receive()
229
+ if message is None:
230
+ continue
231
+ if 'error' in message:
232
+ raise Exception(f"Error fetching ticker {message}")
233
+ if message.type == aiohttp.WSMsgType.CLOSED:
234
+ # we try to reconnect
235
+ print(f"Erorr fetching ticker {message}...")
236
+ self._ws = await self.connect_ws()
237
+ return await self.fetch_tickers(instrument_type, currency)
238
+ message = json.loads(message.data)
239
+ if message['id'] in ids_to_instrument_names:
240
+ try:
241
+ results[message['result']['instrument_name']] = message['result']
242
+ except KeyError:
243
+ print(f"Error fetching ticker {message}")
244
+ del ids_to_instrument_names[message['id']]
245
+ return results
246
+
247
+ async def get_collaterals(self):
248
+ return super().get_collaterals()
249
+
250
+ async def get_positions(self, currency: UnderlyingCurrency = UnderlyingCurrency.BTC):
251
+ return super().get_positions()
252
+
253
+ async def get_open_orders(self, status, currency: UnderlyingCurrency = UnderlyingCurrency.BTC):
254
+ return super().fetch_orders(
255
+ status=status,
256
+ )
257
+
258
+ async def fetch_ticker(self, instrument_name: str):
259
+ """
260
+ Fetch the ticker for a symbol
261
+ """
262
+ return super().fetch_ticker(instrument_name)
263
+
264
+ async def create_order(
265
+ self,
266
+ price,
267
+ amount,
268
+ instrument_name: str,
269
+ reduce_only=False,
270
+ side: OrderSide = OrderSide.BUY,
271
+ order_type: OrderType = OrderType.LIMIT,
272
+ time_in_force: TimeInForce = TimeInForce.GTC,
273
+ instrument_type: InstrumentType = InstrumentType.PERP,
274
+ underlying_currency: UnderlyingCurrency = UnderlyingCurrency.USDC,
275
+ ):
276
+ """
277
+ Create the order.
278
+ """
279
+ if not self._ws:
280
+ await self.connect_ws()
281
+ await self.login_client()
282
+ if side.name.upper() not in OrderSide.__members__:
283
+ raise Exception(f"Invalid side {side}")
284
+ instruments = await self._internal_map_instrument(instrument_type, underlying_currency)
285
+ instrument = instruments[instrument_name]
286
+
287
+ rounded_price = Decimal(price).quantize(Decimal(instrument['tick_size']), rounding=ROUND_DOWN)
288
+ rounded_amount = Decimal(amount).quantize(Decimal(instrument['amount_step']), rounding=ROUND_DOWN)
289
+
290
+ module_data = {
291
+ "asset_address": instrument['base_asset_address'],
292
+ "sub_id": int(instrument['base_asset_sub_id']),
293
+ "limit_price": rounded_price,
294
+ "amount": rounded_amount,
295
+ "max_fee": Decimal(1000),
296
+ "recipient_id": int(self.subaccount_id),
297
+ "is_bid": side == OrderSide.BUY,
298
+ }
299
+
300
+ signed_action = self._generate_signed_action(
301
+ module_address=self.contracts['TRADE_MODULE_ADDRESS'], module_data=module_data
302
+ )
303
+
304
+ order = {
305
+ "instrument_name": instrument_name,
306
+ "direction": side.name.lower(),
307
+ "order_type": order_type.name.lower(),
308
+ "mmp": False,
309
+ "time_in_force": time_in_force.value,
310
+ "referral_code": DEFAULT_REFERER if not self.referral_code else self.referral_code,
311
+ **signed_action.to_json(),
312
+ }
313
+ try:
314
+ response = await self.submit_order(order)
315
+ except aiohttp.ClientConnectionResetError:
316
+ await self.connect_ws()
317
+ await self.login_client()
318
+ response = await self.submit_order(order)
319
+ return response
320
+
321
+ async def _internal_map_instrument(self, instrument_type, currency):
322
+ """
323
+ Map the instrument.
324
+ """
325
+ instruments = await self.fetch_instruments(instrument_type=instrument_type, currency=currency)
326
+ return {i['instrument_name']: i for i in instruments}
327
+
328
+ async def submit_order(self, order):
329
+ id = str(utc_now_ms())
330
+ await self._ws.send_json({'method': 'private/order', 'params': order, 'id': id})
331
+ while True:
332
+ async for msg in self._ws:
333
+ message = json.loads(msg.data)
334
+ if message['id'] == id:
335
+ try:
336
+ if "result" not in message:
337
+ if self._check_output_for_rate_limit(message):
338
+ return await self.submit_order(order)
339
+ raise ApiException(message['error'])
340
+ return message['result']['order']
341
+ except KeyError as error:
342
+ print(message)
343
+ raise Exception(f"Unable to submit order {message}") from error