hyperquant 1.48__py3-none-any.whl
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.
Potentially problematic release.
This version of hyperquant might be problematic. Click here for more details.
- hyperquant/__init__.py +8 -0
- hyperquant/broker/auth.py +972 -0
- hyperquant/broker/bitget.py +311 -0
- hyperquant/broker/bitmart.py +720 -0
- hyperquant/broker/coinw.py +487 -0
- hyperquant/broker/deepcoin.py +651 -0
- hyperquant/broker/edgex.py +500 -0
- hyperquant/broker/hyperliquid.py +570 -0
- hyperquant/broker/lbank.py +661 -0
- hyperquant/broker/lib/edgex_sign.py +455 -0
- hyperquant/broker/lib/hpstore.py +252 -0
- hyperquant/broker/lib/hyper_types.py +48 -0
- hyperquant/broker/lib/polymarket/ctfAbi.py +721 -0
- hyperquant/broker/lib/polymarket/safeAbi.py +1138 -0
- hyperquant/broker/lib/util.py +22 -0
- hyperquant/broker/lighter.py +679 -0
- hyperquant/broker/models/apexpro.py +150 -0
- hyperquant/broker/models/bitget.py +359 -0
- hyperquant/broker/models/bitmart.py +635 -0
- hyperquant/broker/models/coinw.py +724 -0
- hyperquant/broker/models/deepcoin.py +809 -0
- hyperquant/broker/models/edgex.py +1053 -0
- hyperquant/broker/models/hyperliquid.py +284 -0
- hyperquant/broker/models/lbank.py +557 -0
- hyperquant/broker/models/lighter.py +868 -0
- hyperquant/broker/models/ourbit.py +1155 -0
- hyperquant/broker/models/polymarket.py +1071 -0
- hyperquant/broker/ourbit.py +550 -0
- hyperquant/broker/polymarket.py +2399 -0
- hyperquant/broker/ws.py +132 -0
- hyperquant/core.py +513 -0
- hyperquant/datavison/_util.py +18 -0
- hyperquant/datavison/binance.py +111 -0
- hyperquant/datavison/coinglass.py +237 -0
- hyperquant/datavison/okx.py +177 -0
- hyperquant/db.py +191 -0
- hyperquant/draw.py +1200 -0
- hyperquant/logkit.py +205 -0
- hyperquant/notikit.py +124 -0
- hyperquant-1.48.dist-info/METADATA +32 -0
- hyperquant-1.48.dist-info/RECORD +42 -0
- hyperquant-1.48.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def fmt_value(price: float, tick: float) -> str:
|
|
5
|
+
tick_dec = Decimal(str(tick))
|
|
6
|
+
price_dec = Decimal(str(price))
|
|
7
|
+
return str(
|
|
8
|
+
(price_dec / tick_dec).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * tick_dec
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def place_to_step(place: int) -> float:
|
|
13
|
+
"""
|
|
14
|
+
把 pricePlace / volumePlace 转换成 tick_size / lot_size
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
place (int): 小数位数,例如 pricePlace=1, volumePlace=2
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
float: 步长 (step),例如 0.1, 0.01
|
|
21
|
+
"""
|
|
22
|
+
return 10 ** (-place)
|
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import json
|
|
6
|
+
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
|
|
7
|
+
from typing import Any, Literal, Sequence
|
|
8
|
+
|
|
9
|
+
import pybotters
|
|
10
|
+
|
|
11
|
+
from lighter.api.account_api import AccountApi
|
|
12
|
+
from lighter.api.order_api import OrderApi
|
|
13
|
+
from lighter.api.candlestick_api import CandlestickApi
|
|
14
|
+
from lighter.api_client import ApiClient
|
|
15
|
+
from lighter.configuration import Configuration
|
|
16
|
+
from lighter.signer_client import SignerClient
|
|
17
|
+
|
|
18
|
+
from .models.lighter import LighterDataStore, _maybe_to_dict
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Lighter:
|
|
24
|
+
"""Lighter exchange client (REST + WebSocket) built on top of the official SDK."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
client: pybotters.Client,
|
|
29
|
+
*,
|
|
30
|
+
configuration: Configuration | None = None,
|
|
31
|
+
l1_address: str | None = None,
|
|
32
|
+
secret: str | None = None,
|
|
33
|
+
api_key_index: int = 3,
|
|
34
|
+
api_client: ApiClient | None = None,
|
|
35
|
+
order_api: OrderApi | None = None,
|
|
36
|
+
candlestick_api: CandlestickApi | None = None,
|
|
37
|
+
account_api: AccountApi | None = None,
|
|
38
|
+
ws_url: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.client = client
|
|
41
|
+
self.store = LighterDataStore()
|
|
42
|
+
self.l1_address = l1_address
|
|
43
|
+
self.account_index: int | None = None
|
|
44
|
+
self.secret:str = secret
|
|
45
|
+
self.api_key_index = api_key_index
|
|
46
|
+
|
|
47
|
+
self.configuration = configuration or Configuration.get_default()
|
|
48
|
+
self._api_client = api_client or ApiClient(configuration=self.configuration)
|
|
49
|
+
self._owns_api_client = api_client is None
|
|
50
|
+
|
|
51
|
+
self.order_api = order_api or OrderApi(self._api_client)
|
|
52
|
+
self.candlestick_api = candlestick_api or CandlestickApi(self._api_client)
|
|
53
|
+
self.account_api = account_api or AccountApi(self._api_client)
|
|
54
|
+
self.signer: SignerClient = None
|
|
55
|
+
|
|
56
|
+
base_host = self.configuration.host.rstrip("/")
|
|
57
|
+
default_ws_url = f"{base_host.replace('https://', 'wss://')}/stream"
|
|
58
|
+
self.ws_url = ws_url or default_ws_url
|
|
59
|
+
self.id_to_symbol: dict[str, str] = {}
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> "Lighter":
|
|
62
|
+
await self.update("detail")
|
|
63
|
+
|
|
64
|
+
# 设置id_to_symbol映射
|
|
65
|
+
for detail in self.store.detail.find():
|
|
66
|
+
market_id = detail.get("market_id")
|
|
67
|
+
symbol = detail.get("symbol")
|
|
68
|
+
if market_id is not None and symbol is not None:
|
|
69
|
+
self.id_to_symbol[str(market_id)] = symbol
|
|
70
|
+
|
|
71
|
+
self.store.set_id_to_symbol(self.id_to_symbol)
|
|
72
|
+
|
|
73
|
+
# 尝试自动设置account_index
|
|
74
|
+
if self.l1_address is not None:
|
|
75
|
+
subact = await self.account_api.accounts_by_l1_address(
|
|
76
|
+
l1_address=self.l1_address
|
|
77
|
+
)
|
|
78
|
+
self.account_index = subact.sub_accounts[0].index
|
|
79
|
+
|
|
80
|
+
if self.secret:
|
|
81
|
+
|
|
82
|
+
self.signer = SignerClient(
|
|
83
|
+
url=self.configuration.host,
|
|
84
|
+
private_key=self.secret,
|
|
85
|
+
account_index=self.account_index if self.account_index is not None else -1,
|
|
86
|
+
api_key_index=self.api_key_index,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
92
|
+
await self.aclose()
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
if self._owns_api_client:
|
|
96
|
+
await self._api_client.close()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def auth(self):
|
|
100
|
+
if not self.signer:
|
|
101
|
+
raise RuntimeError("SignerClient is required for auth token generation")
|
|
102
|
+
auth, err = self.signer.create_auth_token_with_expiry(SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY)
|
|
103
|
+
if err is not None:
|
|
104
|
+
raise Exception(err)
|
|
105
|
+
return auth
|
|
106
|
+
|
|
107
|
+
def get_contract_id(self, symbol: str) -> str | None:
|
|
108
|
+
"""Helper that resolves a symbol to its `market_id`."""
|
|
109
|
+
detail = self.store.detail.get({"symbol": symbol}) or self.store.detail.get({"market_id": symbol})
|
|
110
|
+
if not detail:
|
|
111
|
+
return None
|
|
112
|
+
market_id = detail.get("market_id")
|
|
113
|
+
if market_id is None and detail.get("order_book_index") is not None:
|
|
114
|
+
market_id = detail["order_book_index"]
|
|
115
|
+
return str(market_id) if market_id is not None else None
|
|
116
|
+
|
|
117
|
+
def _get_detail_entry(self, symbol: str | None = None, market_index: int | None = None) -> dict[str, Any] | None:
|
|
118
|
+
if symbol:
|
|
119
|
+
entry = self.store.detail.get({"symbol": symbol})
|
|
120
|
+
if entry:
|
|
121
|
+
return entry
|
|
122
|
+
|
|
123
|
+
if market_index is not None:
|
|
124
|
+
entries = self.store.detail.find({"market_id": market_index})
|
|
125
|
+
if entries:
|
|
126
|
+
return entries[0]
|
|
127
|
+
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
async def update(
|
|
131
|
+
self,
|
|
132
|
+
update_type: Literal[
|
|
133
|
+
"detail",
|
|
134
|
+
"orders",
|
|
135
|
+
"history_order",
|
|
136
|
+
"history_orders",
|
|
137
|
+
"account",
|
|
138
|
+
"positions",
|
|
139
|
+
"all",
|
|
140
|
+
] = "all",
|
|
141
|
+
*,
|
|
142
|
+
symbol: str | None = None,
|
|
143
|
+
limit: int = 50,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Refresh cached data via Lighter REST endpoints."""
|
|
146
|
+
|
|
147
|
+
tasks: list[tuple[str, Any]] = []
|
|
148
|
+
|
|
149
|
+
include_detail = update_type in {"detail", "all"}
|
|
150
|
+
include_orders = update_type in {"orders", "all"}
|
|
151
|
+
include_history = update_type in {"history_order", "history_orders", "all"}
|
|
152
|
+
include_account = update_type in {"account", "positions", "all"}
|
|
153
|
+
account_index = self.account_index
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if include_detail:
|
|
157
|
+
# Use raw HTTP to avoid strict SDK model validation issues (e.g., status 'inactive').
|
|
158
|
+
url = f"{self.configuration.host.rstrip('/')}/api/v1/orderBooks"
|
|
159
|
+
tasks.append(("detail", self.client.get(url)))
|
|
160
|
+
|
|
161
|
+
if include_orders:
|
|
162
|
+
if account_index is None or symbol is None:
|
|
163
|
+
if update_type == "orders":
|
|
164
|
+
raise ValueError("account_index and symbol are required to update orders")
|
|
165
|
+
else:
|
|
166
|
+
cid = self.get_contract_id(symbol)
|
|
167
|
+
tasks.append(
|
|
168
|
+
(
|
|
169
|
+
"orders",
|
|
170
|
+
self.order_api.account_active_orders(
|
|
171
|
+
account_index=account_index,
|
|
172
|
+
market_id=int(cid),
|
|
173
|
+
auth=self.auth
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if include_history:
|
|
179
|
+
if account_index is None:
|
|
180
|
+
raise ValueError("account_index is required to update history orders")
|
|
181
|
+
else:
|
|
182
|
+
tasks.append(
|
|
183
|
+
(
|
|
184
|
+
"history_orders",
|
|
185
|
+
self.order_api.account_inactive_orders(
|
|
186
|
+
account_index=account_index,
|
|
187
|
+
limit=limit,
|
|
188
|
+
auth=self.auth
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if include_account:
|
|
194
|
+
if account_index is None:
|
|
195
|
+
if update_type in {"account", "positions"}:
|
|
196
|
+
raise ValueError("account_index is required to update account data")
|
|
197
|
+
else:
|
|
198
|
+
tasks.append(
|
|
199
|
+
(
|
|
200
|
+
"account",
|
|
201
|
+
self.account_api.account(
|
|
202
|
+
by="index",
|
|
203
|
+
value=str(account_index),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if not tasks:
|
|
209
|
+
logger.debug("No REST requests enqueued for Lighter update_type=%s", update_type)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
results: dict[str, Any] = {}
|
|
213
|
+
for key, coroutine in tasks:
|
|
214
|
+
try:
|
|
215
|
+
resp = await coroutine
|
|
216
|
+
if key == "detail":
|
|
217
|
+
# Parse JSON body for detail endpoint
|
|
218
|
+
results[key] = await resp.json()
|
|
219
|
+
else:
|
|
220
|
+
results[key] = resp
|
|
221
|
+
except Exception:
|
|
222
|
+
logger.exception("Lighter REST request %s failed", key)
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
if "detail" in results:
|
|
226
|
+
self.store.detail._onresponse(results["detail"])
|
|
227
|
+
|
|
228
|
+
if "orders" in results:
|
|
229
|
+
self.store.orders._onresponse(results["orders"])
|
|
230
|
+
|
|
231
|
+
if "history_orders" in results:
|
|
232
|
+
self.store.orders._onresponse(results["history_orders"])
|
|
233
|
+
|
|
234
|
+
if "account" in results:
|
|
235
|
+
account_payload = results["account"]
|
|
236
|
+
self.store.accounts._onresponse(account_payload)
|
|
237
|
+
self.store.positions._onresponse(account_payload)
|
|
238
|
+
|
|
239
|
+
async def sub_orderbook(
|
|
240
|
+
self,
|
|
241
|
+
symbols: Sequence[str] | str,
|
|
242
|
+
*,
|
|
243
|
+
account_ids: Sequence[int] | int | None = None,
|
|
244
|
+
depth_limit: int | None = None,
|
|
245
|
+
) -> pybotters.ws.WebSocketApp:
|
|
246
|
+
"""Subscribe to order book (and optional account) websocket streams by symbol."""
|
|
247
|
+
|
|
248
|
+
if isinstance(symbols, str):
|
|
249
|
+
symbol_list = [symbols]
|
|
250
|
+
else:
|
|
251
|
+
symbol_list = list(symbols)
|
|
252
|
+
|
|
253
|
+
if not symbol_list and not account_ids:
|
|
254
|
+
raise ValueError("At least one symbol or account_id must be provided")
|
|
255
|
+
|
|
256
|
+
needs_detail = any(self.get_contract_id(sym) is None for sym in symbol_list)
|
|
257
|
+
if needs_detail and symbol_list:
|
|
258
|
+
try:
|
|
259
|
+
await self.update("detail")
|
|
260
|
+
except Exception:
|
|
261
|
+
logger.exception("Failed to refresh Lighter market metadata for symbol resolution")
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
order_book_ids: list[str] = []
|
|
265
|
+
for sym in symbol_list:
|
|
266
|
+
market_id = self.get_contract_id(sym)
|
|
267
|
+
if market_id is None:
|
|
268
|
+
if sym.isdigit():
|
|
269
|
+
market_id = sym
|
|
270
|
+
else:
|
|
271
|
+
raise ValueError(f"Unknown symbol: {sym}")
|
|
272
|
+
market_id_str = str(market_id)
|
|
273
|
+
order_book_ids.append(market_id_str)
|
|
274
|
+
self.store.book.id_to_symbol[market_id_str] = sym
|
|
275
|
+
|
|
276
|
+
account_id_list: list[str] = []
|
|
277
|
+
if account_ids is not None:
|
|
278
|
+
if isinstance(account_ids, int):
|
|
279
|
+
account_id_list = [str(account_ids)]
|
|
280
|
+
else:
|
|
281
|
+
account_id_list = [str(aid) for aid in account_ids]
|
|
282
|
+
|
|
283
|
+
if not order_book_ids and not account_id_list:
|
|
284
|
+
raise ValueError("No valid symbols or account_ids resolved for subscription")
|
|
285
|
+
|
|
286
|
+
if depth_limit is not None:
|
|
287
|
+
self.store.book.limit = depth_limit
|
|
288
|
+
|
|
289
|
+
order_book_channels = [f"order_book/{mid}" for mid in order_book_ids]
|
|
290
|
+
account_channels = [f"account_all/{aid}" for aid in account_id_list]
|
|
291
|
+
|
|
292
|
+
send_payload = [
|
|
293
|
+
{"type": "subscribe", "channel": channel} for channel in order_book_channels + account_channels
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
ws_app = self.client.ws_connect(
|
|
297
|
+
self.ws_url,
|
|
298
|
+
send_json=send_payload,
|
|
299
|
+
hdlr_json=self.store.onmessage,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
await ws_app._event.wait()
|
|
303
|
+
return ws_app
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def sub_orders(
|
|
307
|
+
self,
|
|
308
|
+
account_ids: Sequence[int] | int = None,
|
|
309
|
+
) -> pybotters.ws.WebSocketApp:
|
|
310
|
+
"""Subscribe to order updates via Account All Orders stream.
|
|
311
|
+
|
|
312
|
+
Channel per docs: "account_all_orders/{ACCOUNT_ID}" (requires auth).
|
|
313
|
+
Response carries an "orders" mapping of market_id -> [Order].
|
|
314
|
+
"""
|
|
315
|
+
if account_ids:
|
|
316
|
+
if isinstance(account_ids, int):
|
|
317
|
+
account_id_list = [str(account_ids)]
|
|
318
|
+
else:
|
|
319
|
+
account_id_list = [str(aid) for aid in account_ids]
|
|
320
|
+
else:
|
|
321
|
+
account_id_list = [self.account_index]
|
|
322
|
+
|
|
323
|
+
channels = [f"account_all_orders/{aid}" for aid in account_id_list]
|
|
324
|
+
send_payload = [
|
|
325
|
+
{"type": "subscribe", "channel": channel, "auth": self.auth}
|
|
326
|
+
for channel in channels
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
ws_app = self.client.ws_connect(
|
|
330
|
+
self.ws_url,
|
|
331
|
+
send_json=send_payload,
|
|
332
|
+
hdlr_json=self.store.onmessage,
|
|
333
|
+
)
|
|
334
|
+
await ws_app._event.wait()
|
|
335
|
+
return ws_app
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def sub_kline(
|
|
340
|
+
self,
|
|
341
|
+
symbols: Sequence[str] | str,
|
|
342
|
+
*,
|
|
343
|
+
resolutions: Sequence[str] | str,
|
|
344
|
+
) -> pybotters.ws.WebSocketApp:
|
|
345
|
+
"""Subscribe to trade streams and aggregate into klines in the store.
|
|
346
|
+
|
|
347
|
+
- symbols: list of symbols (e.g., ["BTC-USD"]) or a single symbol; may also be numeric market_ids.
|
|
348
|
+
- resolutions: list like ["1m", "5m"] or a single resolution; added to kline store for aggregation.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
# Normalize inputs
|
|
352
|
+
symbol_list = [symbols] if isinstance(symbols, str) else list(symbols)
|
|
353
|
+
res_list = [resolutions] if isinstance(resolutions, str) else list(resolutions)
|
|
354
|
+
|
|
355
|
+
if not symbol_list:
|
|
356
|
+
raise ValueError("At least one symbol must be provided")
|
|
357
|
+
if not res_list:
|
|
358
|
+
raise ValueError("At least one resolution must be provided")
|
|
359
|
+
|
|
360
|
+
# Ensure market metadata for symbol->market_id resolution
|
|
361
|
+
needs_detail = any(self.get_contract_id(sym) is None and not str(sym).isdigit() for sym in symbol_list)
|
|
362
|
+
if needs_detail:
|
|
363
|
+
try:
|
|
364
|
+
await self.update("detail")
|
|
365
|
+
except Exception:
|
|
366
|
+
logger.exception("Failed to refresh Lighter market metadata for kline subscription")
|
|
367
|
+
raise
|
|
368
|
+
|
|
369
|
+
# Resolve market ids and populate id->symbol mapping for klines store
|
|
370
|
+
trade_market_ids: list[str] = []
|
|
371
|
+
for sym in symbol_list:
|
|
372
|
+
market_id = self.get_contract_id(sym)
|
|
373
|
+
if market_id is None:
|
|
374
|
+
if str(sym).isdigit():
|
|
375
|
+
market_id = str(sym)
|
|
376
|
+
symbol_for_map = str(sym)
|
|
377
|
+
else:
|
|
378
|
+
raise ValueError(f"Unknown symbol: {sym}")
|
|
379
|
+
else:
|
|
380
|
+
symbol_for_map = sym
|
|
381
|
+
market_id_str = str(market_id)
|
|
382
|
+
trade_market_ids.append(market_id_str)
|
|
383
|
+
# ensure klines store can resolve symbol from market id
|
|
384
|
+
self.store.klines.id_to_symbol[market_id_str] = symbol_for_map
|
|
385
|
+
|
|
386
|
+
# Register resolutions into kline store aggregation list
|
|
387
|
+
for r in res_list:
|
|
388
|
+
if r not in self.store.klines._res_list:
|
|
389
|
+
self.store.klines._res_list.append(r)
|
|
390
|
+
|
|
391
|
+
# Build subscribe payload for trade channels
|
|
392
|
+
channels = [f"trade/{mid}" for mid in trade_market_ids]
|
|
393
|
+
send_payload = [{"type": "subscribe", "channel": ch} for ch in channels]
|
|
394
|
+
|
|
395
|
+
ws_app = self.client.ws_connect(
|
|
396
|
+
self.ws_url,
|
|
397
|
+
send_json=send_payload,
|
|
398
|
+
hdlr_json=self.store.onmessage,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
await ws_app._event.wait()
|
|
402
|
+
return ws_app
|
|
403
|
+
|
|
404
|
+
async def place_order(
|
|
405
|
+
self,
|
|
406
|
+
symbol: str,
|
|
407
|
+
*,
|
|
408
|
+
base_amount: float,
|
|
409
|
+
price: float,
|
|
410
|
+
is_ask: bool,
|
|
411
|
+
order_type: Literal[
|
|
412
|
+
"limit",
|
|
413
|
+
"market",
|
|
414
|
+
"stop-loss",
|
|
415
|
+
"stop-loss-limit",
|
|
416
|
+
"take-profit",
|
|
417
|
+
"take-profit-limit",
|
|
418
|
+
"twap",
|
|
419
|
+
] = "limit",
|
|
420
|
+
time_in_force: Literal["ioc", "gtc", "post_only"] = "gtc",
|
|
421
|
+
reduce_only: bool = False,
|
|
422
|
+
trigger_price: float | None = None,
|
|
423
|
+
order_expiry: int | None = None,
|
|
424
|
+
nonce: int | None = None,
|
|
425
|
+
api_key_index: int | None = None,
|
|
426
|
+
client_order_index: int = 0,
|
|
427
|
+
) -> dict[str, Any]:
|
|
428
|
+
"""Submit an order through the signer client using human-readable inputs."""
|
|
429
|
+
|
|
430
|
+
if self.signer is None:
|
|
431
|
+
raise RuntimeError("SignerClient is required for placing orders")
|
|
432
|
+
|
|
433
|
+
market_index = self.get_contract_id(symbol)
|
|
434
|
+
if market_index is None:
|
|
435
|
+
raise ValueError(f"Unknown symbol: {symbol}")
|
|
436
|
+
market_index = int(market_index)
|
|
437
|
+
|
|
438
|
+
detail = self._get_detail_entry(symbol=symbol, market_index=market_index)
|
|
439
|
+
if detail is None:
|
|
440
|
+
await self.update("detail")
|
|
441
|
+
detail = self._get_detail_entry(symbol=symbol, market_index=market_index)
|
|
442
|
+
if detail is None:
|
|
443
|
+
raise ValueError(f"Market metadata unavailable for symbol: {symbol}")
|
|
444
|
+
|
|
445
|
+
order_type_map = {
|
|
446
|
+
"limit": self.signer.ORDER_TYPE_LIMIT,
|
|
447
|
+
"market": self.signer.ORDER_TYPE_MARKET,
|
|
448
|
+
"stop-loss": self.signer.ORDER_TYPE_STOP_LOSS,
|
|
449
|
+
"stop-loss-limit": self.signer.ORDER_TYPE_STOP_LOSS_LIMIT,
|
|
450
|
+
"take-profit": self.signer.ORDER_TYPE_TAKE_PROFIT,
|
|
451
|
+
"take-profit-limit": self.signer.ORDER_TYPE_TAKE_PROFIT_LIMIT,
|
|
452
|
+
"twap": self.signer.ORDER_TYPE_TWAP,
|
|
453
|
+
}
|
|
454
|
+
tif_map = {
|
|
455
|
+
"ioc": self.signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL,
|
|
456
|
+
"gtc": self.signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME,
|
|
457
|
+
"post_only": self.signer.ORDER_TIME_IN_FORCE_POST_ONLY,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
order_type_code = order_type_map[order_type]
|
|
462
|
+
except KeyError as exc:
|
|
463
|
+
raise ValueError(f"Unsupported order_type: {order_type}") from exc
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
tif_code = tif_map[time_in_force]
|
|
467
|
+
except KeyError as exc:
|
|
468
|
+
raise ValueError(f"Unsupported time_in_force: {time_in_force}") from exc
|
|
469
|
+
|
|
470
|
+
# Per WS/API docs, OrderExpiry can be 0 with ExpiredAt computed by signer.
|
|
471
|
+
# Use caller-provided value if given; otherwise default to 0 to avoid
|
|
472
|
+
# "OrderExpiry is invalid" errors on some markets.
|
|
473
|
+
expiry = order_expiry if order_expiry is not None else 0
|
|
474
|
+
nonce_value = nonce if nonce is not None else -1
|
|
475
|
+
api_key_idx = api_key_index if api_key_index is not None else self.api_key_index
|
|
476
|
+
|
|
477
|
+
# ----- Precision and min constraints handling -----
|
|
478
|
+
# Prefer explicitly supported decimals. Avoid using quote decimals to infer size.
|
|
479
|
+
price_decimals = (
|
|
480
|
+
detail.get("supported_price_decimals")
|
|
481
|
+
or detail.get("price_decimals")
|
|
482
|
+
or 0
|
|
483
|
+
)
|
|
484
|
+
size_decimals = (
|
|
485
|
+
detail.get("supported_size_decimals")
|
|
486
|
+
or detail.get("size_decimals")
|
|
487
|
+
or 0
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Optional constraints provided by the API
|
|
491
|
+
# Strings like "10.000000" may be returned – normalize via Decimal for accuracy
|
|
492
|
+
def _to_decimal(v, default: str | int = 0):
|
|
493
|
+
try:
|
|
494
|
+
if v is None or v == "":
|
|
495
|
+
return Decimal(str(default))
|
|
496
|
+
return Decimal(str(v))
|
|
497
|
+
except Exception:
|
|
498
|
+
return Decimal(str(default))
|
|
499
|
+
|
|
500
|
+
min_base_amount = _to_decimal(detail.get("min_base_amount"), 0)
|
|
501
|
+
min_quote_amount = _to_decimal(detail.get("min_quote_amount"), 0)
|
|
502
|
+
order_quote_limit = _to_decimal(detail.get("order_quote_limit"), 0)
|
|
503
|
+
|
|
504
|
+
# Use Decimal for precise arithmetic and quantization
|
|
505
|
+
d_price = Decimal(str(price))
|
|
506
|
+
d_size = Decimal(str(base_amount))
|
|
507
|
+
quant_price = Decimal(1) / (Decimal(10) ** int(price_decimals)) if int(price_decimals) > 0 else Decimal(1)
|
|
508
|
+
quant_size = Decimal(1) / (Decimal(10) ** int(size_decimals)) if int(size_decimals) > 0 else Decimal(1)
|
|
509
|
+
|
|
510
|
+
# Round price/size to allowed decimals (half up to the nearest tick)
|
|
511
|
+
d_price = d_price.quantize(quant_price, rounding=ROUND_HALF_UP)
|
|
512
|
+
d_size = d_size.quantize(quant_size, rounding=ROUND_HALF_UP)
|
|
513
|
+
|
|
514
|
+
# Ensure minimum notional and minimum base constraints
|
|
515
|
+
# If violating, adjust size upward to the smallest valid amount respecting size tick
|
|
516
|
+
if min_quote_amount > 0:
|
|
517
|
+
notional = d_price * d_size
|
|
518
|
+
if notional < min_quote_amount:
|
|
519
|
+
# required size to reach min notional
|
|
520
|
+
required = (min_quote_amount / d_price).quantize(quant_size, rounding=ROUND_HALF_UP)
|
|
521
|
+
if required > d_size:
|
|
522
|
+
d_size = required
|
|
523
|
+
if min_base_amount > 0 and d_size < min_base_amount:
|
|
524
|
+
d_size = min_base_amount.quantize(quant_size, rounding=ROUND_HALF_UP)
|
|
525
|
+
|
|
526
|
+
# Respect optional maximum notional limit if provided (>0)
|
|
527
|
+
if order_quote_limit and order_quote_limit > 0:
|
|
528
|
+
notional = d_price * d_size
|
|
529
|
+
if notional > order_quote_limit:
|
|
530
|
+
# Reduce size down to the maximum allowed notional (floor to tick)
|
|
531
|
+
max_size = (order_quote_limit / d_price).quantize(quant_size, rounding=ROUND_DOWN)
|
|
532
|
+
if max_size <= 0:
|
|
533
|
+
raise ValueError("order would exceed order_quote_limit and cannot be reduced to a positive size")
|
|
534
|
+
d_size = max_size
|
|
535
|
+
|
|
536
|
+
# Convert to integer representation expected by signer
|
|
537
|
+
price_scale = 10 ** int(price_decimals)
|
|
538
|
+
size_scale = 10 ** int(size_decimals)
|
|
539
|
+
|
|
540
|
+
price_int = int((d_price * price_scale).to_integral_value(rounding=ROUND_HALF_UP))
|
|
541
|
+
base_amount_int = int((d_size * size_scale).to_integral_value(rounding=ROUND_HALF_UP))
|
|
542
|
+
|
|
543
|
+
trigger_price_int = (
|
|
544
|
+
int((Decimal(str(trigger_price)) * price_scale).to_integral_value(rounding=ROUND_HALF_UP))
|
|
545
|
+
if trigger_price is not None
|
|
546
|
+
else self.signer.NIL_TRIGGER_PRICE
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
created_tx, response, error = await self.signer.create_order(
|
|
550
|
+
market_index=market_index,
|
|
551
|
+
client_order_index=client_order_index,
|
|
552
|
+
base_amount=base_amount_int,
|
|
553
|
+
price=price_int,
|
|
554
|
+
is_ask=is_ask,
|
|
555
|
+
order_type=order_type_code,
|
|
556
|
+
time_in_force=tif_code,
|
|
557
|
+
reduce_only=reduce_only,
|
|
558
|
+
trigger_price=trigger_price_int,
|
|
559
|
+
order_expiry=expiry,
|
|
560
|
+
nonce=nonce_value,
|
|
561
|
+
api_key_index=api_key_idx,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
if error:
|
|
565
|
+
raise RuntimeError(f"Lighter create_order failed: {error}")
|
|
566
|
+
if response is None:
|
|
567
|
+
raise RuntimeError("Lighter create_order returned no response")
|
|
568
|
+
|
|
569
|
+
if hasattr(created_tx, "to_json"):
|
|
570
|
+
request_payload = json.loads(created_tx.to_json())
|
|
571
|
+
else:
|
|
572
|
+
request_payload = str(created_tx)
|
|
573
|
+
response_payload = response.to_dict() if hasattr(response, "to_dict") else _maybe_to_dict(response)
|
|
574
|
+
|
|
575
|
+
# return {
|
|
576
|
+
# "request": request_payload,
|
|
577
|
+
# "response": response_payload,
|
|
578
|
+
# }
|
|
579
|
+
return response_payload
|
|
580
|
+
|
|
581
|
+
async def cancel_order(
|
|
582
|
+
self,
|
|
583
|
+
symbol: str,
|
|
584
|
+
order_index: int,
|
|
585
|
+
*,
|
|
586
|
+
nonce: int | None = None,
|
|
587
|
+
api_key_index: int | None = None,
|
|
588
|
+
) -> dict[str, Any]:
|
|
589
|
+
"""Cancel a single order using the signer client."""
|
|
590
|
+
|
|
591
|
+
market_index = self.get_contract_id(symbol)
|
|
592
|
+
if market_index is None:
|
|
593
|
+
raise ValueError(f"Unknown symbol: {symbol}")
|
|
594
|
+
market_index = int(market_index)
|
|
595
|
+
|
|
596
|
+
if self.signer is None:
|
|
597
|
+
raise RuntimeError("SignerClient is required for cancelling orders")
|
|
598
|
+
|
|
599
|
+
nonce_value = nonce if nonce is not None else -1
|
|
600
|
+
api_key_idx = api_key_index or self.api_key_index
|
|
601
|
+
|
|
602
|
+
cancel_tx, response, error = await self.signer.cancel_order(
|
|
603
|
+
market_index=market_index,
|
|
604
|
+
order_index=order_index,
|
|
605
|
+
nonce=nonce_value,
|
|
606
|
+
api_key_index=api_key_idx,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if error:
|
|
610
|
+
raise RuntimeError(f"Lighter cancel_order failed: {error}")
|
|
611
|
+
if response is None:
|
|
612
|
+
raise RuntimeError("Lighter cancel_order returned no response")
|
|
613
|
+
|
|
614
|
+
if hasattr(cancel_tx, "to_json"):
|
|
615
|
+
request_payload = json.loads(cancel_tx.to_json())
|
|
616
|
+
else:
|
|
617
|
+
request_payload = str(cancel_tx)
|
|
618
|
+
response_payload = response.to_dict() if hasattr(response, "to_dict") else _maybe_to_dict(response)
|
|
619
|
+
return {
|
|
620
|
+
"request": request_payload,
|
|
621
|
+
"response": response_payload,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async def update_kline(
|
|
625
|
+
self,
|
|
626
|
+
symbol: str,
|
|
627
|
+
*,
|
|
628
|
+
resolution: str,
|
|
629
|
+
start_timestamp: int,
|
|
630
|
+
end_timestamp: int,
|
|
631
|
+
count_back: int,
|
|
632
|
+
set_timestamp_to_end: bool | None = None,
|
|
633
|
+
) -> list[dict[str, Any]]:
|
|
634
|
+
"""Fetch candlesticks and update the Kline store.
|
|
635
|
+
|
|
636
|
+
Parameters
|
|
637
|
+
- symbol: market symbol, e.g. "BTC-USD".
|
|
638
|
+
- resolution: e.g. "1m", "5m", "1h".
|
|
639
|
+
- start_timestamp: epoch milliseconds.
|
|
640
|
+
- end_timestamp: epoch milliseconds.
|
|
641
|
+
- count_back: number of bars to fetch.
|
|
642
|
+
- set_timestamp_to_end: if True, API sets last bar timestamp to the end.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
market_id = self.get_contract_id(symbol)
|
|
646
|
+
if market_id is None:
|
|
647
|
+
# try to refresh metadata once
|
|
648
|
+
await self.update("detail")
|
|
649
|
+
market_id = self.get_contract_id(symbol)
|
|
650
|
+
if market_id is None:
|
|
651
|
+
raise ValueError(f"Unknown symbol: {symbol}")
|
|
652
|
+
|
|
653
|
+
resp = await self.candlestick_api.candlesticks(
|
|
654
|
+
market_id=int(market_id),
|
|
655
|
+
resolution=resolution,
|
|
656
|
+
start_timestamp=int(start_timestamp),
|
|
657
|
+
end_timestamp=int(end_timestamp),
|
|
658
|
+
count_back=int(count_back),
|
|
659
|
+
set_timestamp_to_end=bool(set_timestamp_to_end) if set_timestamp_to_end is not None else None,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Update store
|
|
663
|
+
self.store.klines._onresponse(resp, symbol=symbol, resolution=resolution)
|
|
664
|
+
|
|
665
|
+
payload = _maybe_to_dict(resp) or {}
|
|
666
|
+
items = payload.get("candlesticks") or []
|
|
667
|
+
# attach symbol/resolution to return
|
|
668
|
+
out: list[dict[str, Any]] = []
|
|
669
|
+
for it in items:
|
|
670
|
+
if hasattr(it, "to_dict"):
|
|
671
|
+
d = it.to_dict()
|
|
672
|
+
elif hasattr(it, "model_dump"):
|
|
673
|
+
d = it.model_dump()
|
|
674
|
+
else:
|
|
675
|
+
d = dict(it) if isinstance(it, dict) else {"value": it}
|
|
676
|
+
d["symbol"] = symbol
|
|
677
|
+
d["resolution"] = resolution
|
|
678
|
+
out.append(d)
|
|
679
|
+
return out
|