hyperquant 0.1.9__py3-none-any.whl → 0.3__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.
- hyperquant/__init__.py +4 -1
- hyperquant/broker/auth.py +51 -0
- hyperquant/broker/hyperliquid.py +572 -0
- hyperquant/broker/lib/hpstore.py +252 -0
- hyperquant/broker/lib/hyper_types.py +48 -0
- hyperquant/broker/models/hyperliquid.py +284 -0
- hyperquant/broker/models/ourbit.py +502 -0
- hyperquant/broker/ourbit.py +234 -0
- hyperquant/broker/ws.py +12 -0
- hyperquant/core.py +77 -26
- hyperquant/datavison/okx.py +177 -0
- hyperquant/db.py +0 -1
- hyperquant/notikit.py +124 -0
- {hyperquant-0.1.9.dist-info → hyperquant-0.3.dist-info}/METADATA +3 -2
- hyperquant-0.3.dist-info/RECORD +21 -0
- hyperquant-0.1.9.dist-info/RECORD +0 -11
- {hyperquant-0.1.9.dist-info → hyperquant-0.3.dist-info}/WHEEL +0 -0
@@ -0,0 +1,234 @@
|
|
1
|
+
from typing import Literal, Optional
|
2
|
+
import pybotters
|
3
|
+
from .models.ourbit import OurbitSwapDataStore
|
4
|
+
|
5
|
+
|
6
|
+
class OurbitSwap:
|
7
|
+
|
8
|
+
def __init__(self, client: pybotters.Client):
|
9
|
+
"""
|
10
|
+
✅ 完成:
|
11
|
+
下单, 撤单, 查询资金, 查询持有订单, 查询历史订单
|
12
|
+
|
13
|
+
"""
|
14
|
+
self.client = client
|
15
|
+
self.store = OurbitSwapDataStore()
|
16
|
+
self.api_url = "https://futures.ourbit.com"
|
17
|
+
self.ws_url = "wss://futures.ourbit.com/edge"
|
18
|
+
|
19
|
+
async def __aenter__(self) -> "OurbitSwap":
|
20
|
+
client = self.client
|
21
|
+
await self.store.initialize(
|
22
|
+
client.get(f"{self.api_url}/api/v1/contract/detailV2?client=web")
|
23
|
+
)
|
24
|
+
return self
|
25
|
+
|
26
|
+
async def update(
|
27
|
+
self, update_type: Literal["position", "orders", "balance", "ticker", "all"] = "all"
|
28
|
+
):
|
29
|
+
"""由于交易所很多不支持ws推送,这里使用Rest"""
|
30
|
+
all_urls = [
|
31
|
+
f"{self.api_url}/api/v1/private/position/open_positions",
|
32
|
+
f"{self.api_url}/api/v1/private/order/list/open_orders?page_size=200",
|
33
|
+
f"{self.api_url}/api/v1/private/account/assets",
|
34
|
+
f"{self.api_url}/api/v1/contract/ticker",
|
35
|
+
]
|
36
|
+
|
37
|
+
url_map = {
|
38
|
+
"position": [all_urls[0]],
|
39
|
+
"orders": [all_urls[1]],
|
40
|
+
"balance": [all_urls[2]],
|
41
|
+
"ticker": [all_urls[3]],
|
42
|
+
"all": all_urls,
|
43
|
+
}
|
44
|
+
|
45
|
+
try:
|
46
|
+
urls = url_map[update_type]
|
47
|
+
except KeyError:
|
48
|
+
raise ValueError(f"update_type err: {update_type}")
|
49
|
+
|
50
|
+
# 直接传协程进去,initialize 会自己 await
|
51
|
+
await self.store.initialize(*(self.client.get(url) for url in urls))
|
52
|
+
|
53
|
+
async def sub_tickers(self):
|
54
|
+
self.client.ws_connect(
|
55
|
+
self.ws_url,
|
56
|
+
send_json={
|
57
|
+
"method": "sub.tickers",
|
58
|
+
"param": {
|
59
|
+
"timezone": "UTC+8"
|
60
|
+
}
|
61
|
+
},
|
62
|
+
hdlr_json=self.store.onmessage
|
63
|
+
)
|
64
|
+
|
65
|
+
async def sub_order_book(self, symbols: str | list[str]):
|
66
|
+
if isinstance(symbols, str):
|
67
|
+
symbols = [symbols]
|
68
|
+
|
69
|
+
send_jsons = []
|
70
|
+
# send_json={"method":"sub.depth.step","param":{"symbol":"BTC_USDT","step":"0.1"}},
|
71
|
+
|
72
|
+
for symbol in symbols:
|
73
|
+
step = self.store.detail.find({"symbol": symbol})[0].get("tick_size")
|
74
|
+
|
75
|
+
send_jsons.append({
|
76
|
+
"method": "sub.depth.step",
|
77
|
+
"param": {
|
78
|
+
"symbol": symbol,
|
79
|
+
"step": str(step)
|
80
|
+
}
|
81
|
+
})
|
82
|
+
|
83
|
+
await self.client.ws_connect(
|
84
|
+
self.ws_url,
|
85
|
+
send_json=send_jsons,
|
86
|
+
hdlr_json=self.store.onmessage
|
87
|
+
)
|
88
|
+
|
89
|
+
def ret_content(self, res: pybotters.FetchResult):
|
90
|
+
match res.data:
|
91
|
+
case {"success": True}:
|
92
|
+
return res.data["data"]
|
93
|
+
case _:
|
94
|
+
raise Exception(f"Failed api {res.response.url}: {res.data}")
|
95
|
+
|
96
|
+
|
97
|
+
async def place_order(
|
98
|
+
self,
|
99
|
+
symbol: str,
|
100
|
+
side: Literal["buy", "sell", "close"],
|
101
|
+
size: float = None,
|
102
|
+
price: float = None,
|
103
|
+
order_type: Literal["market", "limit_GTC", "limit_IOC"] = "market",
|
104
|
+
usdt_amount: Optional[float] = None,
|
105
|
+
leverage: Optional[int] = 20,
|
106
|
+
position_id: Optional[int] = None,
|
107
|
+
):
|
108
|
+
"""size为合约张数"""
|
109
|
+
if (size is None) == (usdt_amount is None):
|
110
|
+
raise ValueError("params err")
|
111
|
+
|
112
|
+
max_lev = self.store.detail.find({"symbol": symbol})[0].get("max_lev")
|
113
|
+
|
114
|
+
if usdt_amount is not None:
|
115
|
+
cs = self.store.detail.find({"symbol": symbol})[0].get("contract_sz")
|
116
|
+
size = max(int(usdt_amount / cs / price), 1)
|
117
|
+
|
118
|
+
|
119
|
+
leverage = max(max_lev, leverage)
|
120
|
+
|
121
|
+
data = {
|
122
|
+
"symbol": symbol,
|
123
|
+
"side": 1 if side == "buy" else 3,
|
124
|
+
"openType": 1,
|
125
|
+
"type": "5",
|
126
|
+
"vol": size,
|
127
|
+
"leverage": leverage,
|
128
|
+
"marketCeiling": False,
|
129
|
+
"priceProtect": "0",
|
130
|
+
}
|
131
|
+
|
132
|
+
if order_type == "limit_IOC":
|
133
|
+
data["type"] = 3
|
134
|
+
data["price"] = str(price)
|
135
|
+
if order_type == "limit_GTC":
|
136
|
+
data["type"] = "1"
|
137
|
+
data["price"] = str(price)
|
138
|
+
|
139
|
+
if side == "close":
|
140
|
+
data["side"] = 4
|
141
|
+
if position_id is None:
|
142
|
+
raise ValueError("position_id is required for closing position")
|
143
|
+
data["positionId"] = position_id
|
144
|
+
|
145
|
+
res = await self.client.fetch(
|
146
|
+
"POST", f"{self.api_url}/api/v1/private/order/create", data=data
|
147
|
+
)
|
148
|
+
return self.ret_content(res)
|
149
|
+
|
150
|
+
async def cancel_orders(self, order_ids: list[str]):
|
151
|
+
res = await self.client.fetch(
|
152
|
+
"POST",
|
153
|
+
f"{self.api_url}/api/v1/private/order/cancel",
|
154
|
+
data=order_ids,
|
155
|
+
)
|
156
|
+
return self.ret_content(res)
|
157
|
+
|
158
|
+
async def query_orders(
|
159
|
+
self,
|
160
|
+
symbol: str,
|
161
|
+
states: list[Literal["filled", "canceled"]], # filled:已成交, canceled:已撤销
|
162
|
+
start_time: Optional[int] = None,
|
163
|
+
end_time: Optional[int] = None,
|
164
|
+
page_size: int = 200,
|
165
|
+
page_num: int = 1,
|
166
|
+
):
|
167
|
+
"""查询历史订单
|
168
|
+
|
169
|
+
Args:
|
170
|
+
symbol: 交易对
|
171
|
+
states: 订单状态列表 ["filled":已成交, "canceled":已撤销]
|
172
|
+
start_time: 开始时间戳(毫秒), 可选
|
173
|
+
end_time: 结束时间戳(毫秒), 可选
|
174
|
+
page_size: 每页数量, 默认200
|
175
|
+
page_num: 页码, 默认1
|
176
|
+
"""
|
177
|
+
state_map = {"filled": 3, "canceled": 4}
|
178
|
+
|
179
|
+
params = {
|
180
|
+
"symbol": symbol,
|
181
|
+
"states": ",".join(str(state_map[state]) for state in states),
|
182
|
+
"page_size": page_size,
|
183
|
+
"page_num": page_num,
|
184
|
+
"category": 1,
|
185
|
+
}
|
186
|
+
|
187
|
+
if start_time:
|
188
|
+
params["start_time"] = start_time
|
189
|
+
if end_time:
|
190
|
+
params["end_time"] = end_time
|
191
|
+
|
192
|
+
res = await self.client.fetch(
|
193
|
+
"GET",
|
194
|
+
f"{self.api_url}/api/v1/private/order/list/history_orders",
|
195
|
+
params=params,
|
196
|
+
)
|
197
|
+
|
198
|
+
return self.ret_content(res)
|
199
|
+
|
200
|
+
async def query_order(self, order_id: str):
|
201
|
+
"""查询单个订单的详细信息
|
202
|
+
|
203
|
+
Args:
|
204
|
+
order_id: 订单ID
|
205
|
+
|
206
|
+
Returns:
|
207
|
+
..code:python
|
208
|
+
|
209
|
+
订单详情数据,例如:
|
210
|
+
[
|
211
|
+
{
|
212
|
+
"id": "38600506", # 成交ID
|
213
|
+
"symbol": "SOL_USDT", # 交易对
|
214
|
+
"side": 4, # 方向(1:买入, 3:卖出, 4:平仓)
|
215
|
+
"vol": 1, # 成交数量
|
216
|
+
"price": 204.11, # 成交价格
|
217
|
+
"fee": 0.00081644, # 手续费
|
218
|
+
"feeCurrency": "USDT", # 手续费币种
|
219
|
+
"profit": -0.0034, # 盈亏
|
220
|
+
"category": 1, # 品类
|
221
|
+
"orderId": "219079365441409152", # 订单ID
|
222
|
+
"timestamp": 1756270991000, # 时间戳
|
223
|
+
"positionMode": 1, # 持仓模式
|
224
|
+
"voucher": false, # 是否使用代金券
|
225
|
+
"taker": true # 是否是taker
|
226
|
+
}
|
227
|
+
]
|
228
|
+
"""
|
229
|
+
res = await self.client.fetch(
|
230
|
+
"GET",
|
231
|
+
f"{self.api_url}/api/v1/private/order/deal_details/{order_id}",
|
232
|
+
)
|
233
|
+
|
234
|
+
return self.ret_content(res)
|
hyperquant/broker/ws.py
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
import asyncio
|
2
|
+
import pybotters
|
3
|
+
|
4
|
+
|
5
|
+
class Heartbeat:
|
6
|
+
@staticmethod
|
7
|
+
async def ourbit(ws: pybotters.ws.ClientWebSocketResponse):
|
8
|
+
while not ws.closed:
|
9
|
+
await ws.send_str('{"method":"ping"}')
|
10
|
+
await asyncio.sleep(10.0)
|
11
|
+
|
12
|
+
pybotters.ws.HeartbeatHosts.items['futures.ourbit.com'] = Heartbeat.ourbit
|
hyperquant/core.py
CHANGED
@@ -350,7 +350,7 @@ class Exchange(ExchangeBase):
|
|
350
350
|
self.record_history(time)
|
351
351
|
|
352
352
|
# 自动更新账户状态
|
353
|
-
self.Update({symbol: price},
|
353
|
+
self.Update({symbol: price}, time=time)
|
354
354
|
|
355
355
|
return trade
|
356
356
|
|
@@ -377,41 +377,92 @@ class Exchange(ExchangeBase):
|
|
377
377
|
trades.append(trade)
|
378
378
|
return trades
|
379
379
|
|
380
|
-
def
|
380
|
+
def _recalc_aggregates(self):
|
381
|
+
"""基于 self.account 中已保存的各 symbol 状态,重算聚合字段。"""
|
382
|
+
usdt = self.account['USDT']
|
383
|
+
usdt['unrealised_profit'] = 0
|
384
|
+
usdt['hold'] = 0
|
385
|
+
usdt['long'] = 0
|
386
|
+
usdt['short'] = 0
|
387
|
+
|
388
|
+
for symbol in self.trade_symbols:
|
389
|
+
if symbol not in self.account:
|
390
|
+
continue
|
391
|
+
sym = self.account[symbol]
|
392
|
+
px = sym.get('price', 0)
|
393
|
+
amt = sym.get('amount', 0)
|
394
|
+
hp = sym.get('hold_price', 0)
|
395
|
+
|
396
|
+
# 仅当价格有效时计入聚合
|
397
|
+
if px is not None and not np.isnan(px) and px != 0:
|
398
|
+
sym['unrealised_profit'] = (px - hp) * amt
|
399
|
+
sym['value'] = amt * px
|
400
|
+
|
401
|
+
if amt > 0:
|
402
|
+
usdt['long'] += sym['value']
|
403
|
+
elif amt < 0:
|
404
|
+
usdt['short'] += sym['value']
|
405
|
+
|
406
|
+
usdt['hold'] += abs(sym['value'])
|
407
|
+
usdt['unrealised_profit'] += sym['unrealised_profit']
|
408
|
+
|
409
|
+
usdt['total'] = round(self.account['USDT']['realised_profit'] + self.initial_balance + usdt['unrealised_profit'], 6)
|
410
|
+
usdt['leverage'] = round(usdt['hold'] / usdt['total'] if usdt['total'] != 0 else 0.0, 3)
|
411
|
+
|
412
|
+
def Update(self, close_price=None, symbols=None, partial=True, **kwargs):
|
413
|
+
"""
|
414
|
+
更新账户状态。
|
415
|
+
- partial=True:只更新给定 symbols 的逐符号状态,然后对所有符号做一次聚合重算(推荐)。
|
416
|
+
- partial=False:与原逻辑兼容;当提供一部分 symbol 时,也会聚合重算,不会清空未提供符号的信息。
|
417
|
+
|
418
|
+
支持三种入参形式:
|
419
|
+
1) close_price 为 dict/Series:symbols 自动取其键/索引
|
420
|
+
2) close_price 为标量 + symbols 为单个字符串
|
421
|
+
3) 显式传 symbols=list[...],close_price 为 dict/Series(从中取价)
|
422
|
+
如果既不传 close_price 也不传 symbols,则只做一次聚合重算(例如你先前已经手动修改了某些 symbol 的 price)。
|
423
|
+
"""
|
381
424
|
if self.recorded and 'time' not in kwargs:
|
382
425
|
raise ValueError("Time parameter is required in recorded mode.")
|
383
426
|
|
384
427
|
time = kwargs.get('time', pd.Timestamp.now())
|
385
|
-
|
386
|
-
|
387
|
-
self.account['USDT']['long'] = 0
|
388
|
-
self.account['USDT']['short'] = 0
|
428
|
+
|
429
|
+
# 解析 symbols & 价格获取器
|
389
430
|
if symbols is None:
|
390
|
-
# symbols = self.trade_symbols
|
391
|
-
# 如果symbols是dict类型, 则取出所有的key, 如果是Series类型, 则取出所有的index
|
392
431
|
if isinstance(close_price, dict):
|
393
432
|
symbols = list(close_price.keys())
|
394
433
|
elif isinstance(close_price, pd.Series):
|
395
|
-
symbols = close_price.index
|
434
|
+
symbols = list(close_price.index)
|
396
435
|
else:
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
436
|
+
symbols = []
|
437
|
+
elif isinstance(symbols, str):
|
438
|
+
symbols = [symbols]
|
439
|
+
|
440
|
+
def get_px(sym):
|
441
|
+
if isinstance(close_price, (int, float, np.floating)) and len(symbols) == 1:
|
442
|
+
return float(close_price)
|
443
|
+
if isinstance(close_price, dict):
|
444
|
+
return close_price.get(sym, np.nan)
|
445
|
+
if isinstance(close_price, pd.Series):
|
446
|
+
return close_price.get(sym, np.nan)
|
447
|
+
return np.nan
|
448
|
+
|
449
|
+
# 仅更新传入的 symbols(部分更新,不动其它符号已保存信息)
|
450
|
+
for sym in symbols:
|
451
|
+
if sym not in self.trade_symbols or sym not in self.account:
|
452
|
+
# 未登记的交易对直接跳过(或可选择自动登记,但此处保持严格)
|
453
|
+
continue
|
454
|
+
px = get_px(sym)
|
455
|
+
if px is None or np.isnan(px):
|
456
|
+
# 价格无效则不覆盖旧价格
|
401
457
|
continue
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
self.account['USDT']['hold'] += abs(self.account[symbol]['value'])
|
411
|
-
self.account['USDT']['unrealised_profit'] += self.account[symbol]['unrealised_profit']
|
412
|
-
|
413
|
-
self.account['USDT']['total'] = round(self.account['USDT']['realised_profit'] + self.initial_balance + self.account['USDT']['unrealised_profit'], 6)
|
414
|
-
self.account['USDT']['leverage'] = round(self.account['USDT']['hold'] / self.account['USDT']['total'], 3)
|
458
|
+
|
459
|
+
self.account[sym]['price'] = float(px)
|
460
|
+
amt = self.account[sym]['amount']
|
461
|
+
self.account[sym]['value'] = amt * float(px)
|
462
|
+
# 不在这里算 unrealised_profit,聚合阶段统一算
|
463
|
+
|
464
|
+
# 无论 partial 与否,最后都用“账户中保存的所有 symbol 当前状态”做一次聚合重算
|
465
|
+
self._recalc_aggregates()
|
415
466
|
|
416
467
|
# 记录账户总资产到 history
|
417
468
|
if self.recorded:
|
@@ -0,0 +1,177 @@
|
|
1
|
+
import aiohttp
|
2
|
+
import asyncio
|
3
|
+
import pandas as pd
|
4
|
+
import time
|
5
|
+
from datetime import datetime, timezone
|
6
|
+
from ._util import _to_milliseconds # 确保时间转换函数可用
|
7
|
+
|
8
|
+
class OKX:
|
9
|
+
def __init__(self) -> None:
|
10
|
+
self.session = aiohttp.ClientSession()
|
11
|
+
self.base_url = "https://www.okx.com/api/v5/market"
|
12
|
+
|
13
|
+
async def get_klines(self, symbol: str, interval: str, start_time, end_time=None, limit: int = 100):
|
14
|
+
"""
|
15
|
+
获取 OKX 永续合约 K 线数据,带时间过滤,从 end_time 向 start_time 方向翻页。
|
16
|
+
|
17
|
+
:param symbol: 交易对, 如 'BTC-USDT'
|
18
|
+
:param interval: K 线间隔, 如 '1m', '15m', '1H', '4H', '1D'
|
19
|
+
:param start_time: 开始时间(datetime 或 毫秒)
|
20
|
+
:param end_time: 结束时间(datetime 或 毫秒), 可选
|
21
|
+
:param limit: 每次请求最大数量(OKX 最大 300)
|
22
|
+
:return: DataFrame 格式的 K 线数据,按时间升序
|
23
|
+
"""
|
24
|
+
if 'h' in interval or 'd' in interval:
|
25
|
+
interval = interval.upper() # 确保间隔是大写格式
|
26
|
+
|
27
|
+
url = f"{self.base_url}/history-candles"
|
28
|
+
all_rows = []
|
29
|
+
# 转换 start_time 和 end_time 到毫秒时间戳
|
30
|
+
if isinstance(start_time, (int, float)):
|
31
|
+
start_ts = int(start_time)
|
32
|
+
else:
|
33
|
+
# 处理 datetime 对象
|
34
|
+
start_ts = int(start_time.timestamp() * 1000)
|
35
|
+
if end_time:
|
36
|
+
if isinstance(end_time, (int, float)):
|
37
|
+
end_ts = int(end_time)
|
38
|
+
else:
|
39
|
+
end_ts = int(end_time.timestamp() * 1000)
|
40
|
+
else:
|
41
|
+
# 如果没有指定结束时间,就用当前时间戳
|
42
|
+
end_ts = int(time.time() * 1000)
|
43
|
+
|
44
|
+
# 每次请求最多返回 limit=300
|
45
|
+
batch_limit = min(limit, 300)
|
46
|
+
# 初始 after 参数为 end_ts,向过去翻页
|
47
|
+
current_after = end_ts
|
48
|
+
|
49
|
+
while True:
|
50
|
+
params = {
|
51
|
+
"instId": symbol,
|
52
|
+
"bar": interval,
|
53
|
+
"limit": str(batch_limit),
|
54
|
+
"after": str(current_after)
|
55
|
+
}
|
56
|
+
# 发送请求
|
57
|
+
async with self.session.get(url, params=params) as resp:
|
58
|
+
data = await resp.json()
|
59
|
+
if not data or data.get("code") != "0" or not data.get("data"):
|
60
|
+
# 返回错误或无数据,结束循环
|
61
|
+
break
|
62
|
+
buf = data["data"] # 每条是 [ts, o, h, l, c, vol, volCcy, volCcyQuote, confirm]
|
63
|
+
|
64
|
+
# 本批数据按时间从新到旧排列, 最后一条是最旧的
|
65
|
+
rows_this_batch = []
|
66
|
+
for item in buf:
|
67
|
+
ts = int(item[0])
|
68
|
+
# 如果已经早于 start_ts,就跳过,并认为后面更旧,也可以结束循环
|
69
|
+
if ts < start_ts:
|
70
|
+
continue
|
71
|
+
# 如果某些条目时间超出 end_ts,也跳过
|
72
|
+
if ts > end_ts:
|
73
|
+
continue
|
74
|
+
# 解析数值字段
|
75
|
+
dt = pd.to_datetime(ts, unit='ms', utc=True)
|
76
|
+
o = float(item[1]); h = float(item[2]); l = float(item[3]); c = float(item[4]); vol = float(item[5])
|
77
|
+
# 按需把每个 K 线封装为字典,后续转换为 DataFrame
|
78
|
+
rows_this_batch.append({
|
79
|
+
"symbol": symbol,
|
80
|
+
"open_time": dt,
|
81
|
+
"open": o,
|
82
|
+
"high": h,
|
83
|
+
"low": l,
|
84
|
+
"close": c,
|
85
|
+
"volume": vol,
|
86
|
+
"interval": interval,
|
87
|
+
"confirm": item[8]
|
88
|
+
})
|
89
|
+
|
90
|
+
if not rows_this_batch:
|
91
|
+
# 本批没有符合时间范围的数据,直接结束
|
92
|
+
break
|
93
|
+
|
94
|
+
# 累积本批符合条件的行
|
95
|
+
all_rows.extend(rows_this_batch)
|
96
|
+
|
97
|
+
# 检查是否到达 start_ts 之前:buf 最后一项是最旧
|
98
|
+
oldest_ts = int(buf[-1][0])
|
99
|
+
if oldest_ts < start_ts:
|
100
|
+
# 已经翻到 start_time 范围之前,结束循环
|
101
|
+
break
|
102
|
+
|
103
|
+
# 否则,更新 after = oldest_ts,继续向过去翻页
|
104
|
+
current_after = oldest_ts
|
105
|
+
# 为了不触发速率限制,稍做休眠(根据需要可以调整或删除)
|
106
|
+
|
107
|
+
# 如果累积到数据,则转换为 DataFrame;否则返回空 DataFrame
|
108
|
+
if all_rows:
|
109
|
+
df = pd.DataFrame(all_rows)
|
110
|
+
# 去重、按时间排序
|
111
|
+
df.drop_duplicates(subset=["open_time"], inplace=True)
|
112
|
+
df.sort_values("open_time", inplace=True)
|
113
|
+
df.reset_index(drop=True, inplace=True)
|
114
|
+
return df
|
115
|
+
else:
|
116
|
+
return pd.DataFrame()
|
117
|
+
|
118
|
+
async def get_index_klines(self, pair: str, interval: str, start_time, end_time=None, limit: int = 100):
|
119
|
+
"""
|
120
|
+
获取OKX指数K线数据(自动分批)
|
121
|
+
|
122
|
+
:param pair: 指数名称, 如 'BTC-USD'
|
123
|
+
:param interval: K线间隔, 如 '1m', '1H', '1D'
|
124
|
+
:param start_time: 开始时间(毫秒时间戳/datetime/date)
|
125
|
+
:param end_time: 结束时间(毫秒时间戳/datetime/date)
|
126
|
+
:param limit: 每次请求最大数量(OKX最大300)
|
127
|
+
:return: DataFrame格式的指数K线
|
128
|
+
"""
|
129
|
+
url = f"{self.base_url}/index-candles"
|
130
|
+
all_klines = []
|
131
|
+
ms_start = _to_milliseconds(start_time)
|
132
|
+
ms_end = _to_milliseconds(end_time) if end_time else None
|
133
|
+
|
134
|
+
params = {
|
135
|
+
"instId": pair,
|
136
|
+
"bar": interval,
|
137
|
+
"limit": min(limit, 300),
|
138
|
+
"after": ms_start
|
139
|
+
}
|
140
|
+
if ms_end:
|
141
|
+
params["before"] = ms_end
|
142
|
+
|
143
|
+
while True:
|
144
|
+
async with self.session.get(url, params=params) as resp:
|
145
|
+
data = await resp.json()
|
146
|
+
if data['code'] != "0":
|
147
|
+
raise Exception(f"OKX API Error: {data['msg']} (Code {data['code']})")
|
148
|
+
|
149
|
+
klines = data['data']
|
150
|
+
if not klines:
|
151
|
+
break
|
152
|
+
|
153
|
+
all_klines.extend(klines)
|
154
|
+
|
155
|
+
if len(klines) < params["limit"]:
|
156
|
+
break
|
157
|
+
|
158
|
+
last_ts = int(klines[-1][0])
|
159
|
+
params["after"] = last_ts
|
160
|
+
|
161
|
+
# 数据转换
|
162
|
+
columns = ["open_time", "open", "high", "low", "close", "confirm"]
|
163
|
+
df = pd.DataFrame(all_klines, columns=columns)
|
164
|
+
|
165
|
+
df["open_time"] = pd.to_datetime(df["open_time"].astype(int), unit="ms")
|
166
|
+
num_cols = ["open", "high", "low", "close"]
|
167
|
+
df[num_cols] = df[num_cols].apply(pd.to_numeric, errors="coerce")
|
168
|
+
|
169
|
+
return df.sort_values("open_time").reset_index(drop=True)
|
170
|
+
|
171
|
+
async def close(self):
|
172
|
+
"""关闭会话"""
|
173
|
+
await self.session.close()
|
174
|
+
|
175
|
+
# 使用示例
|
176
|
+
# async with OKXSwap() as okx:
|
177
|
+
# df = await okx.get_klines("BTC-USDT", "1H", datetime(2023,1,1))
|
hyperquant/db.py
CHANGED
hyperquant/notikit.py
ADDED
@@ -0,0 +1,124 @@
|
|
1
|
+
"""
|
2
|
+
通知类的功能简单封装,非必要别修改 :)
|
3
|
+
只要知道怎么使用以下函数:
|
4
|
+
- send_wecom_msg
|
5
|
+
- send_wecom_img
|
6
|
+
|
7
|
+
Binance期现套利 | 邢不行 | 2024分享会
|
8
|
+
author: 邢不行
|
9
|
+
微信: xbx6660
|
10
|
+
"""
|
11
|
+
import base64
|
12
|
+
import hashlib
|
13
|
+
import os.path
|
14
|
+
import json
|
15
|
+
import traceback
|
16
|
+
import aiohttp
|
17
|
+
import asyncio
|
18
|
+
from datetime import datetime
|
19
|
+
|
20
|
+
from hyperquant.logkit import get_logger
|
21
|
+
logger = get_logger('notikit', './data/logs/notikit.log', show_time=True)
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
proxy = {}
|
26
|
+
|
27
|
+
|
28
|
+
def handle_exception(e: Exception, msg: str = '') -> None:
|
29
|
+
logger.error(f"{msg}:{e}")
|
30
|
+
logger.error(e)
|
31
|
+
logger.error(traceback.format_exc())
|
32
|
+
|
33
|
+
|
34
|
+
# 企业微信通知
|
35
|
+
async def send_wecom_msg(content: str, webhook_url: str) -> None:
|
36
|
+
if not webhook_url:
|
37
|
+
logger.warning('未配置wecom_webhook_url,不发送信息')
|
38
|
+
return
|
39
|
+
if not content:
|
40
|
+
logger.warning('未配置content,不发送信息')
|
41
|
+
return
|
42
|
+
|
43
|
+
try:
|
44
|
+
data = {
|
45
|
+
"msgtype": "text",
|
46
|
+
"text": {
|
47
|
+
"content": content + '\n' + datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
async with aiohttp.ClientSession() as session:
|
52
|
+
async with session.post(
|
53
|
+
webhook_url,
|
54
|
+
json=data,
|
55
|
+
proxy=proxy.get('http') if proxy else None,
|
56
|
+
timeout=aiohttp.ClientTimeout(total=10)
|
57
|
+
) as response:
|
58
|
+
result = await response.text()
|
59
|
+
logger.info(f'调用企业微信接口返回: {result}')
|
60
|
+
logger.ok('成功发送企业微信')
|
61
|
+
except Exception as e:
|
62
|
+
handle_exception(e, '发送企业微信失败')
|
63
|
+
|
64
|
+
|
65
|
+
# 上传图片,解析bytes
|
66
|
+
class MyEncoder(json.JSONEncoder):
|
67
|
+
def default(self, obj):
|
68
|
+
"""
|
69
|
+
只要检查到了是bytes类型的数据就把它转为str类型
|
70
|
+
:param obj:
|
71
|
+
:return:
|
72
|
+
"""
|
73
|
+
if isinstance(obj, bytes):
|
74
|
+
return str(obj, encoding='utf-8')
|
75
|
+
return json.JSONEncoder.default(self, obj)
|
76
|
+
|
77
|
+
|
78
|
+
# 企业微信发送图片
|
79
|
+
async def send_wecom_img(file_path: str, webhook_url: str) -> None:
|
80
|
+
"""
|
81
|
+
企业微信发送图片
|
82
|
+
:param file_path: 图片地址
|
83
|
+
:param webhook_url: 企业微信webhook网址
|
84
|
+
:return:
|
85
|
+
"""
|
86
|
+
if not os.path.exists(file_path):
|
87
|
+
logger.warning('找不到图片')
|
88
|
+
return
|
89
|
+
if not webhook_url:
|
90
|
+
logger.warning('未配置wecom_webhook_url,不发送信息')
|
91
|
+
return
|
92
|
+
|
93
|
+
try:
|
94
|
+
with open(file_path, 'rb') as f:
|
95
|
+
image_content = f.read()
|
96
|
+
|
97
|
+
image_base64 = base64.b64encode(image_content).decode('utf-8')
|
98
|
+
md5 = hashlib.md5()
|
99
|
+
md5.update(image_content)
|
100
|
+
image_md5 = md5.hexdigest()
|
101
|
+
|
102
|
+
data = {
|
103
|
+
'msgtype': 'image',
|
104
|
+
'image': {
|
105
|
+
'base64': image_base64,
|
106
|
+
'md5': image_md5
|
107
|
+
}
|
108
|
+
}
|
109
|
+
|
110
|
+
async with aiohttp.ClientSession() as session:
|
111
|
+
async with session.post(
|
112
|
+
webhook_url,
|
113
|
+
json=data,
|
114
|
+
proxy=proxy.get('http') if proxy else None,
|
115
|
+
timeout=aiohttp.ClientTimeout(total=10)
|
116
|
+
) as response:
|
117
|
+
result = await response.text()
|
118
|
+
logger.info(f'调用企业微信接口返回: {result}')
|
119
|
+
logger.ok('成功发送企业微信图片')
|
120
|
+
except Exception as e:
|
121
|
+
handle_exception(e, '发送企业微信图片失败')
|
122
|
+
finally:
|
123
|
+
if os.path.exists(file_path):
|
124
|
+
os.remove(file_path)
|