cyqnt-trd 0.1.2__py3-none-any.whl → 0.1.7__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.
- cyqnt_trd/CHANGELOG_0.1.7.md +111 -0
- cyqnt_trd/__init__.py +1 -1
- cyqnt_trd/backtesting/factor_test.py +3 -2
- cyqnt_trd/get_data/__init__.py +16 -1
- cyqnt_trd/get_data/get_futures_data.py +3 -3
- cyqnt_trd/get_data/get_kline_with_factor.py +808 -0
- cyqnt_trd/get_data/get_web3_trending_data.py +389 -0
- cyqnt_trd/online_trading/__init__.py +1 -0
- cyqnt_trd/online_trading/realtime_price_tracker.py +142 -2
- cyqnt_trd/trading_signal/example_usage.py +123 -7
- cyqnt_trd/trading_signal/factor/__init__.py +23 -0
- cyqnt_trd/trading_signal/factor/adx_factor.py +116 -0
- cyqnt_trd/trading_signal/factor/ao_factor.py +66 -0
- cyqnt_trd/trading_signal/factor/bbp_factor.py +68 -0
- cyqnt_trd/trading_signal/factor/cci_factor.py +65 -0
- cyqnt_trd/trading_signal/factor/ema_factor.py +102 -0
- cyqnt_trd/trading_signal/factor/macd_factor.py +97 -0
- cyqnt_trd/trading_signal/factor/momentum_factor.py +44 -0
- cyqnt_trd/trading_signal/factor/stochastic_factor.py +76 -0
- cyqnt_trd/trading_signal/factor/stochastic_tsi_factor.py +129 -0
- cyqnt_trd/trading_signal/factor/uo_factor.py +92 -0
- cyqnt_trd/trading_signal/factor/williams_r_factor.py +60 -0
- cyqnt_trd/trading_signal/factor_details.json +107 -0
- cyqnt_trd/trading_signal/selected_alpha/alpha1.py +4 -2
- cyqnt_trd/trading_signal/selected_alpha/alpha15.py +4 -2
- cyqnt_trd/trading_signal/selected_alpha/generate_alphas.py +1 -0
- {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/METADATA +16 -12
- {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/RECORD +34 -23
- {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/WHEEL +1 -1
- test/real_time_trade.py +467 -10
- test/test_now_factor.py +1082 -0
- test/track_k_line_continue.py +372 -0
- cyqnt_trd/test_script/get_symbols_by_volume.py +0 -227
- cyqnt_trd/test_script/test_alpha.py +0 -261
- cyqnt_trd/test_script/test_kline_data.py +0 -479
- test/test_example_usage.py +0 -381
- test/test_get_data.py +0 -310
- test/test_realtime_price_tracker.py +0 -546
- {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web3 / 链上 K 线数据获取(dquery.sintral.io u-kline API)
|
|
3
|
+
|
|
4
|
+
通过 dquery.sintral.io 的 u-kline 接口获取指定合约地址的 K 线数据,
|
|
5
|
+
支持 BSC 等链、按 USD 计价、多周期。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import requests
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Optional, Union, List, Any
|
|
16
|
+
|
|
17
|
+
# Configure logging
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 默认 API 基础 URL(与 curl 示例一致)
|
|
24
|
+
DEFAULT_BASE_URL = "https://dquery.sintral.io/u-kline/v1/k-line/candles"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _convert_to_timestamp_ms(time_input: Union[datetime, str, int, None]) -> Optional[int]:
|
|
28
|
+
"""
|
|
29
|
+
将各种时间格式转换为毫秒时间戳
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
time_input: 时间输入,可以是:
|
|
33
|
+
- datetime 对象
|
|
34
|
+
- 字符串格式的时间,例如 '2023-01-01 00:00:00' 或 '2023-01-01'
|
|
35
|
+
- 整数时间戳(秒或毫秒,自动判断)
|
|
36
|
+
- None
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
毫秒时间戳,如果输入为 None 则返回 None
|
|
40
|
+
"""
|
|
41
|
+
if time_input is None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
if isinstance(time_input, datetime):
|
|
45
|
+
return int(time_input.timestamp() * 1000)
|
|
46
|
+
|
|
47
|
+
if isinstance(time_input, str):
|
|
48
|
+
try:
|
|
49
|
+
for fmt in ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d', '%Y/%m/%d %H:%M:%S', '%Y/%m/%d']:
|
|
50
|
+
try:
|
|
51
|
+
dt = datetime.strptime(time_input, fmt)
|
|
52
|
+
return int(dt.timestamp() * 1000)
|
|
53
|
+
except ValueError:
|
|
54
|
+
continue
|
|
55
|
+
raise ValueError(f"无法解析时间字符串: {time_input}")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logging.error(f"时间字符串解析失败: {e}")
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
if isinstance(time_input, int):
|
|
61
|
+
if time_input > 1e10:
|
|
62
|
+
return time_input
|
|
63
|
+
return time_input * 1000
|
|
64
|
+
|
|
65
|
+
raise TypeError(f"不支持的时间类型: {type(time_input)}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_interval_duration_ms(interval: str) -> int:
|
|
69
|
+
"""
|
|
70
|
+
将 u-kline 的 interval 字符串(如 1min, 1h)转为单根 K 线对应的毫秒数。
|
|
71
|
+
"""
|
|
72
|
+
interval = (interval or "").strip().lower()
|
|
73
|
+
# 支持 1min, 5min, 15min, 1h, 4h, 1d 等
|
|
74
|
+
mapping = {
|
|
75
|
+
"1min": 60 * 1000,
|
|
76
|
+
"3min": 3 * 60 * 1000,
|
|
77
|
+
"5min": 5 * 60 * 1000,
|
|
78
|
+
"15min": 15 * 60 * 1000,
|
|
79
|
+
"30min": 30 * 60 * 1000,
|
|
80
|
+
"1h": 60 * 60 * 1000,
|
|
81
|
+
"2h": 2 * 60 * 60 * 1000,
|
|
82
|
+
"4h": 4 * 60 * 60 * 1000,
|
|
83
|
+
"6h": 6 * 60 * 60 * 1000,
|
|
84
|
+
"12h": 12 * 60 * 60 * 1000,
|
|
85
|
+
"1d": 24 * 60 * 60 * 1000,
|
|
86
|
+
"3d": 3 * 24 * 60 * 60 * 1000,
|
|
87
|
+
"1w": 7 * 24 * 60 * 60 * 1000,
|
|
88
|
+
}
|
|
89
|
+
if interval in mapping:
|
|
90
|
+
return mapping[interval]
|
|
91
|
+
# 兼容 1m 等形式
|
|
92
|
+
if interval == "1m":
|
|
93
|
+
return 60 * 1000
|
|
94
|
+
if interval.endswith("m") and interval[:-1].isdigit():
|
|
95
|
+
return int(interval[:-1]) * 60 * 1000
|
|
96
|
+
if interval.endswith("h") and interval[:-1].isdigit():
|
|
97
|
+
return int(interval[:-1]) * 60 * 60 * 1000
|
|
98
|
+
if interval.endswith("d") and interval[:-1].isdigit():
|
|
99
|
+
return int(interval[:-1]) * 24 * 60 * 60 * 1000
|
|
100
|
+
return 60 * 1000 # 默认 1 分钟
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _address_to_slug(address: str, max_len: int = 12) -> str:
|
|
104
|
+
"""将合约地址转为短标识,用于文件名。"""
|
|
105
|
+
addr = (address or "").strip().lower()
|
|
106
|
+
if not addr:
|
|
107
|
+
return "unknown"
|
|
108
|
+
if addr.startswith("0x"):
|
|
109
|
+
return addr[2:2 + max_len] if len(addr) > 2 else addr[2:]
|
|
110
|
+
return addr[:max_len]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _timestamp_ms_to_str(ts_ms: int) -> str:
|
|
114
|
+
"""将毫秒时间戳转为可读字符串;若 ts_ms 像秒(<1e11),则按秒解释。"""
|
|
115
|
+
if ts_ms < 1e11:
|
|
116
|
+
ts_ms = int(ts_ms) * 1000
|
|
117
|
+
return datetime.fromtimestamp(int(ts_ms) / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_candle_row(row: Any, index: int, interval_ms: Optional[int] = None) -> Optional[dict]:
|
|
121
|
+
"""
|
|
122
|
+
将 u-kline API 返回的一行 K 线解析为统一结构。
|
|
123
|
+
|
|
124
|
+
u-kline 数组布局与 Binance 不同,为: [?, open, high, low, close, open_time_ms, ?, ...]
|
|
125
|
+
即 index 5 为开盘时间(毫秒),close_time 由 open_time + interval_ms - 1 得到。
|
|
126
|
+
也兼容返回为字典的情况。
|
|
127
|
+
"""
|
|
128
|
+
if row is None:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(row, dict):
|
|
131
|
+
open_time = row.get("open_time") or row.get("openTime") or row.get(0)
|
|
132
|
+
open_p = row.get("open") or row.get("open_price") or row.get(1)
|
|
133
|
+
high = row.get("high") or row.get(2)
|
|
134
|
+
low = row.get("low") or row.get(3)
|
|
135
|
+
close = row.get("close") or row.get("close_price") or row.get(4)
|
|
136
|
+
volume = row.get("volume") or row.get(5)
|
|
137
|
+
close_time = row.get("close_time") or row.get("closeTime") or row.get(6)
|
|
138
|
+
quote_volume = row.get("quote_volume", row.get("quoteVolume", row.get(7, 0)))
|
|
139
|
+
trades = row.get("trades", row.get(8, 0))
|
|
140
|
+
taker_buy_base = row.get("taker_buy_base_volume", row.get(9, 0))
|
|
141
|
+
taker_buy_quote = row.get("taker_buy_quote_volume", row.get(10, 0))
|
|
142
|
+
ignore = row.get("ignore", row.get(11, 0))
|
|
143
|
+
elif isinstance(row, (list, tuple)):
|
|
144
|
+
if len(row) < 6:
|
|
145
|
+
return None
|
|
146
|
+
# u-kline 布局: [?, open, high, low, close, open_time_ms, ...]
|
|
147
|
+
open_p = row[1]
|
|
148
|
+
high = row[2]
|
|
149
|
+
low = row[3]
|
|
150
|
+
close = row[4]
|
|
151
|
+
open_time_raw = row[5]
|
|
152
|
+
if interval_ms is not None:
|
|
153
|
+
# u-kline 布局: index 5 = open_time(ms),close_time = open_time + interval - 1
|
|
154
|
+
open_time_ms = int(float(open_time_raw))
|
|
155
|
+
if open_time_ms < 1e11:
|
|
156
|
+
open_time_ms = open_time_ms * 1000
|
|
157
|
+
close_time_ms = open_time_ms + interval_ms - 1
|
|
158
|
+
open_time = open_time_ms
|
|
159
|
+
close_time = close_time_ms
|
|
160
|
+
volume = 0.0 # u-kline 数组未提供 volume 字段
|
|
161
|
+
quote_volume = 0.0
|
|
162
|
+
trades = 0
|
|
163
|
+
taker_buy_base = 0.0
|
|
164
|
+
taker_buy_quote = 0.0
|
|
165
|
+
ignore = 0
|
|
166
|
+
else:
|
|
167
|
+
open_time = row[0]
|
|
168
|
+
close_time = row[6] if len(row) > 6 else 0
|
|
169
|
+
volume = row[5]
|
|
170
|
+
quote_volume = row[7] if len(row) > 7 else 0
|
|
171
|
+
trades = row[8] if len(row) > 8 else 0
|
|
172
|
+
taker_buy_base = row[9] if len(row) > 9 else 0
|
|
173
|
+
taker_buy_quote = row[10] if len(row) > 10 else 0
|
|
174
|
+
ignore = row[11] if len(row) > 11 else 0
|
|
175
|
+
else:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
open_time = int(open_time)
|
|
180
|
+
close_time = int(close_time)
|
|
181
|
+
if open_time < 1e11:
|
|
182
|
+
open_time = open_time * 1000
|
|
183
|
+
if close_time < 1e11:
|
|
184
|
+
close_time = close_time * 1000
|
|
185
|
+
return {
|
|
186
|
+
"open_time": open_time,
|
|
187
|
+
"open_time_str": _timestamp_ms_to_str(open_time),
|
|
188
|
+
"open_price": float(open_p),
|
|
189
|
+
"high_price": float(high),
|
|
190
|
+
"low_price": float(low),
|
|
191
|
+
"close_price": float(close),
|
|
192
|
+
"volume": float(volume),
|
|
193
|
+
"close_time": close_time,
|
|
194
|
+
"close_time_str": _timestamp_ms_to_str(close_time),
|
|
195
|
+
"quote_volume": float(quote_volume),
|
|
196
|
+
"trades": int(trades),
|
|
197
|
+
"taker_buy_base_volume": float(taker_buy_base),
|
|
198
|
+
"taker_buy_quote_volume": float(taker_buy_quote),
|
|
199
|
+
"ignore": ignore,
|
|
200
|
+
}
|
|
201
|
+
except (TypeError, ValueError) as e:
|
|
202
|
+
logging.debug(f"跳过第 {index} 行解析: {e}")
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_and_save_web3_klines(
|
|
207
|
+
address: str,
|
|
208
|
+
platform: str = "BSC",
|
|
209
|
+
unit: str = "usd",
|
|
210
|
+
interval: str = "1min",
|
|
211
|
+
start_time: Optional[Union[datetime, str, int]] = None,
|
|
212
|
+
end_time: Optional[Union[datetime, str, int]] = None,
|
|
213
|
+
n: int = 1500,
|
|
214
|
+
output_dir: str = "data",
|
|
215
|
+
save_csv: bool = False,
|
|
216
|
+
save_json: bool = True,
|
|
217
|
+
base_url: Optional[str] = None,
|
|
218
|
+
timeout: tuple = (30, 60),
|
|
219
|
+
) -> Optional[List[dict]]:
|
|
220
|
+
"""
|
|
221
|
+
请求 dquery.sintral.io u-kline 接口,获取指定合约的 K 线并保存。
|
|
222
|
+
|
|
223
|
+
参数与 curl 示例对应:
|
|
224
|
+
platform: 链,如 BSC
|
|
225
|
+
unit: 计价单位,如 usd
|
|
226
|
+
interval: 周期,如 1min
|
|
227
|
+
address: 合约地址
|
|
228
|
+
start_time / end_time: 时间范围;若两者都缺失,则 end_time 默认为当前时间,并取 n 个点
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
address: 合约地址,例如 '0xe6df05ce8c8301223373cf5b969afcb1498c5528'
|
|
232
|
+
platform: 链标识,默认 'BSC'
|
|
233
|
+
unit: 计价单位,默认 'usd'
|
|
234
|
+
interval: K 线周期,默认 '1min'
|
|
235
|
+
start_time: 开始时间;与 end_time 同时缺失时可不填
|
|
236
|
+
end_time: 结束时间;与 start_time 同时缺失时默认为当前时间
|
|
237
|
+
n: 当 start_time 与 end_time 都未提供时,按当前时间为 end_time,向前取 n 根 K 线(默认 1500)
|
|
238
|
+
output_dir: 输出目录,默认 'data'
|
|
239
|
+
save_csv: 是否保存 CSV,默认 False
|
|
240
|
+
save_json: 是否保存 JSON,默认 True
|
|
241
|
+
base_url: API 地址,默认使用 DEFAULT_BASE_URL
|
|
242
|
+
timeout: (connect_timeout, read_timeout),默认 (30, 60)
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
解析后的 K 线列表(每项为 dict),失败返回 None
|
|
246
|
+
"""
|
|
247
|
+
base_url = base_url or os.getenv("U_KLINE_BASE_URL", DEFAULT_BASE_URL)
|
|
248
|
+
start_time_ms = _convert_to_timestamp_ms(start_time) if start_time is not None else None
|
|
249
|
+
end_time_ms = _convert_to_timestamp_ms(end_time) if end_time is not None else None
|
|
250
|
+
|
|
251
|
+
if start_time_ms is None and end_time_ms is None:
|
|
252
|
+
end_time_ms = int(datetime.now().timestamp() * 1000)
|
|
253
|
+
interval_ms = _get_interval_duration_ms(interval)
|
|
254
|
+
start_time_ms = end_time_ms - n * interval_ms
|
|
255
|
+
logging.info(f"未指定时间范围:使用 end_time=当前时间,取 n={n} 根 K 线,start_time 由 interval 反推")
|
|
256
|
+
elif start_time_ms is None or end_time_ms is None:
|
|
257
|
+
logging.error("Web3 K 线接口需同时提供 start_time 和 end_time,或两者都不提供以使用默认(当前时间 + n 点)")
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
if start_time_ms >= end_time_ms:
|
|
261
|
+
logging.error("start_time 必须小于 end_time")
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
params = {
|
|
265
|
+
"platform": platform,
|
|
266
|
+
"unit": unit,
|
|
267
|
+
"interval": interval,
|
|
268
|
+
"address": address.strip(),
|
|
269
|
+
"from": start_time_ms,
|
|
270
|
+
"to": end_time_ms,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
time_info = (
|
|
274
|
+
f"from {datetime.fromtimestamp(start_time_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')} "
|
|
275
|
+
f"to {datetime.fromtimestamp(end_time_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')}"
|
|
276
|
+
)
|
|
277
|
+
logging.info(f"请求 Web3 K 线: {platform} {address[:10]}... 间隔={interval}, {time_info}")
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
response = requests.get(base_url, params=params, timeout=timeout)
|
|
281
|
+
response.raise_for_status()
|
|
282
|
+
raw = response.json()
|
|
283
|
+
except requests.exceptions.RequestException as e:
|
|
284
|
+
logging.error(f"请求 u-kline 失败: {e}")
|
|
285
|
+
return None
|
|
286
|
+
except json.JSONDecodeError as e:
|
|
287
|
+
logging.error(f"解析响应 JSON 失败: {e}")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
# 兼容:直接数组 / 包装在 data 或 candles 等字段中
|
|
291
|
+
if isinstance(raw, list):
|
|
292
|
+
rows = raw
|
|
293
|
+
elif isinstance(raw, dict):
|
|
294
|
+
rows = raw.get("data", raw.get("candles", raw.get("result", [])))
|
|
295
|
+
if not isinstance(rows, list):
|
|
296
|
+
rows = [raw] if raw else []
|
|
297
|
+
else:
|
|
298
|
+
rows = []
|
|
299
|
+
|
|
300
|
+
interval_ms = _get_interval_duration_ms(interval)
|
|
301
|
+
formatted = []
|
|
302
|
+
for i, row in enumerate(rows):
|
|
303
|
+
parsed = _parse_candle_row(row, i, interval_ms=interval_ms)
|
|
304
|
+
if parsed:
|
|
305
|
+
formatted.append(parsed)
|
|
306
|
+
|
|
307
|
+
if not formatted:
|
|
308
|
+
logging.warning("未解析到任何 K 线数据")
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
logging.info(f"成功获取 {len(formatted)} 条 Web3 K 线")
|
|
312
|
+
|
|
313
|
+
# 输出目录与文件名
|
|
314
|
+
if not os.path.exists(output_dir):
|
|
315
|
+
os.makedirs(output_dir)
|
|
316
|
+
logging.info(f"创建目录: {output_dir}")
|
|
317
|
+
|
|
318
|
+
slug = _address_to_slug(address)
|
|
319
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
320
|
+
start_str = datetime.fromtimestamp(start_time_ms / 1000).strftime("%Y%m%d_%H%M%S")
|
|
321
|
+
end_str = datetime.fromtimestamp(end_time_ms / 1000).strftime("%Y%m%d_%H%M%S")
|
|
322
|
+
base_filename = f"web3_{platform}_{slug}_{interval}_{len(formatted)}_{start_str}_{end_str}_{timestamp}"
|
|
323
|
+
|
|
324
|
+
if save_csv:
|
|
325
|
+
csv_path = os.path.join(output_dir, f"{base_filename}.csv")
|
|
326
|
+
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
|
327
|
+
writer = csv.writer(f)
|
|
328
|
+
writer.writerow([
|
|
329
|
+
"开盘时间", "开盘价", "最高价", "最低价", "收盘价",
|
|
330
|
+
"成交量", "收盘时间", "成交额", "成交笔数",
|
|
331
|
+
"主动买入成交量", "主动买入成交额", "忽略",
|
|
332
|
+
])
|
|
333
|
+
for c in formatted:
|
|
334
|
+
writer.writerow([
|
|
335
|
+
c["open_time_str"], c["open_price"], c["high_price"], c["low_price"], c["close_price"],
|
|
336
|
+
c["volume"], c["close_time_str"], c["quote_volume"], c["trades"],
|
|
337
|
+
c["taker_buy_base_volume"], c["taker_buy_quote_volume"], c.get("ignore", 0),
|
|
338
|
+
])
|
|
339
|
+
logging.info(f"已保存 CSV: {csv_path}")
|
|
340
|
+
|
|
341
|
+
if save_json:
|
|
342
|
+
json_path = os.path.join(output_dir, f"{base_filename}.json")
|
|
343
|
+
metadata = {
|
|
344
|
+
"source": "u-kline",
|
|
345
|
+
"base_url": base_url,
|
|
346
|
+
"address": address,
|
|
347
|
+
"platform": platform,
|
|
348
|
+
"unit": unit,
|
|
349
|
+
"interval": interval,
|
|
350
|
+
"start_time": start_time_ms,
|
|
351
|
+
"start_time_str": datetime.fromtimestamp(start_time_ms / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
|
352
|
+
"end_time": end_time_ms,
|
|
353
|
+
"end_time_str": datetime.fromtimestamp(end_time_ms / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
|
354
|
+
"data_count": len(formatted),
|
|
355
|
+
"timestamp": timestamp,
|
|
356
|
+
"data": formatted,
|
|
357
|
+
}
|
|
358
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
359
|
+
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
|
360
|
+
logging.info(f"已保存 JSON: {json_path}")
|
|
361
|
+
|
|
362
|
+
return formatted
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
# 示例1:不传 start_time/end_time,默认 end_time=当前时间,取 n=1500 根 K 线
|
|
367
|
+
get_and_save_web3_klines(
|
|
368
|
+
address="0xe6df05ce8c8301223373cf5b969afcb1498c5528",
|
|
369
|
+
platform="BSC",
|
|
370
|
+
unit="usd",
|
|
371
|
+
interval="1min",
|
|
372
|
+
n=1500,
|
|
373
|
+
output_dir="/Users/user/Desktop/repo/crypto_trading/cyqnt_trd/tmp/data/web3_klines",
|
|
374
|
+
save_csv=False,
|
|
375
|
+
save_json=True,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# 示例2:指定时间范围(与 curl 等价)
|
|
379
|
+
# get_and_save_web3_klines(
|
|
380
|
+
# address="0xe6df05ce8c8301223373cf5b969afcb1498c5528",
|
|
381
|
+
# platform="BSC",
|
|
382
|
+
# unit="usd",
|
|
383
|
+
# interval="1min",
|
|
384
|
+
# start_time=1765764600000,
|
|
385
|
+
# end_time=1765768139999,
|
|
386
|
+
# output_dir="/Users/user/Desktop/repo/crypto_trading/cyqnt_trd/tmp/data/web3_klines",
|
|
387
|
+
# save_csv=False,
|
|
388
|
+
# save_json=True,
|
|
389
|
+
# )
|
|
@@ -96,6 +96,9 @@ class RealtimePriceTracker:
|
|
|
96
96
|
self.stream = None
|
|
97
97
|
self.is_running = False
|
|
98
98
|
|
|
99
|
+
# 对于 10m 间隔,需要缓存 1m 数据用于合并(需要10个1m周期)
|
|
100
|
+
self._pending_1m_klines: list = []
|
|
101
|
+
|
|
99
102
|
# 回调函数
|
|
100
103
|
self.on_new_kline_callbacks: list = []
|
|
101
104
|
self.on_data_updated_callbacks: list = []
|
|
@@ -136,6 +139,7 @@ class RealtimePriceTracker:
|
|
|
136
139
|
"1m": KlineCandlestickDataIntervalEnum.INTERVAL_1m,
|
|
137
140
|
"3m": KlineCandlestickDataIntervalEnum.INTERVAL_3m,
|
|
138
141
|
"5m": KlineCandlestickDataIntervalEnum.INTERVAL_5m,
|
|
142
|
+
"10m": "10m", # Binance API 可能不支持,但尝试使用字符串格式
|
|
139
143
|
"15m": KlineCandlestickDataIntervalEnum.INTERVAL_15m,
|
|
140
144
|
"30m": KlineCandlestickDataIntervalEnum.INTERVAL_30m,
|
|
141
145
|
"1h": KlineCandlestickDataIntervalEnum.INTERVAL_1h,
|
|
@@ -223,11 +227,19 @@ class RealtimePriceTracker:
|
|
|
223
227
|
|
|
224
228
|
interval_enum = self.interval_map[self.interval]
|
|
225
229
|
|
|
230
|
+
# 对于 10m 间隔,使用 1m 获取数据然后合并
|
|
231
|
+
if self.interval == "10m":
|
|
232
|
+
# 获取 10 倍的数据量(因为要合并成 10m)
|
|
233
|
+
base_limit = self.lookback_periods * 10
|
|
234
|
+
interval_enum = KlineCandlestickDataIntervalEnum.INTERVAL_1m
|
|
235
|
+
else:
|
|
236
|
+
base_limit = self.lookback_periods
|
|
237
|
+
|
|
226
238
|
# 查询历史 K 线数据
|
|
227
239
|
response = self.rest_client.rest_api.kline_candlestick_data(
|
|
228
240
|
symbol=self.symbol,
|
|
229
241
|
interval=interval_enum,
|
|
230
|
-
limit=
|
|
242
|
+
limit=base_limit
|
|
231
243
|
)
|
|
232
244
|
|
|
233
245
|
klines_data = response.data()
|
|
@@ -236,6 +248,10 @@ class RealtimePriceTracker:
|
|
|
236
248
|
logging.warning("未获取到历史数据")
|
|
237
249
|
return False
|
|
238
250
|
|
|
251
|
+
# 对于 10m 间隔,合并 1m 数据
|
|
252
|
+
if self.interval == "10m":
|
|
253
|
+
klines_data = self._merge_1m_to_10m(klines_data)
|
|
254
|
+
|
|
239
255
|
# 转换为 DataFrame
|
|
240
256
|
data_list = []
|
|
241
257
|
for kline in klines_data:
|
|
@@ -256,6 +272,73 @@ class RealtimePriceTracker:
|
|
|
256
272
|
logging.error(traceback.format_exc())
|
|
257
273
|
return False
|
|
258
274
|
|
|
275
|
+
def _merge_1m_to_10m(self, klines_data: list) -> list:
|
|
276
|
+
"""
|
|
277
|
+
将 1m K线数据合并为 10m K线数据
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
klines_data: 1m K线数据列表
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
合并后的 10m K线数据列表
|
|
284
|
+
"""
|
|
285
|
+
if not klines_data or len(klines_data) < 10:
|
|
286
|
+
return klines_data
|
|
287
|
+
|
|
288
|
+
merged = []
|
|
289
|
+
# 从后往前处理,每10个1m周期合并成1个10m周期
|
|
290
|
+
i = len(klines_data) - 1
|
|
291
|
+
while i >= 9:
|
|
292
|
+
# 获取10个1m周期
|
|
293
|
+
klines_10m = klines_data[i - 9:i + 1] # 包含i-9到i,共10个
|
|
294
|
+
|
|
295
|
+
# 提取第一个(最旧的)和最后一个(最新的)的数据
|
|
296
|
+
kline_first = klines_10m[0]
|
|
297
|
+
kline_last = klines_10m[-1]
|
|
298
|
+
|
|
299
|
+
# 提取所有数据用于计算
|
|
300
|
+
open_time_first = int(kline_first[0]) if isinstance(kline_first[0], str) else kline_first[0]
|
|
301
|
+
open_price_first = float(kline_first[1]) if isinstance(kline_first[1], str) else kline_first[1]
|
|
302
|
+
close_time_last = int(kline_last[6]) if isinstance(kline_last[6], str) else kline_last[6]
|
|
303
|
+
close_price_last = float(kline_last[4]) if isinstance(kline_last[4], str) else kline_last[4]
|
|
304
|
+
|
|
305
|
+
# 计算最高价和最低价
|
|
306
|
+
high_prices = [float(k[2]) if isinstance(k[2], str) else k[2] for k in klines_10m]
|
|
307
|
+
low_prices = [float(k[3]) if isinstance(k[3], str) else k[3] for k in klines_10m]
|
|
308
|
+
max_high = max(high_prices)
|
|
309
|
+
min_low = min(low_prices)
|
|
310
|
+
|
|
311
|
+
# 合并成交量、成交额等
|
|
312
|
+
total_volume = sum(float(k[5]) if isinstance(k[5], str) else k[5] for k in klines_10m)
|
|
313
|
+
total_quote_volume = sum(float(k[7]) if isinstance(k[7], str) else k[7] for k in klines_10m)
|
|
314
|
+
total_trades = sum(int(k[8]) if isinstance(k[8], str) else k[8] for k in klines_10m)
|
|
315
|
+
total_taker_buy_base_volume = sum(float(k[9]) if isinstance(k[9], str) else k[9] for k in klines_10m)
|
|
316
|
+
total_taker_buy_quote_volume = sum(float(k[10]) if isinstance(k[10], str) else k[10] for k in klines_10m)
|
|
317
|
+
|
|
318
|
+
# 合并:开盘价用第一个,收盘价用最后一个,最高价和最低价取10个周期的最大最小值
|
|
319
|
+
merged_kline = [
|
|
320
|
+
open_time_first, # open_time: 使用第一个的开始时间
|
|
321
|
+
str(open_price_first), # open_price: 使用第一个的开盘价
|
|
322
|
+
str(max_high), # high_price: 取10个周期的最高价
|
|
323
|
+
str(min_low), # low_price: 取10个周期的最低价
|
|
324
|
+
str(close_price_last), # close_price: 使用最后一个的收盘价
|
|
325
|
+
str(total_volume), # volume: 合并10个周期的成交量
|
|
326
|
+
close_time_last, # close_time: 使用最后一个的结束时间
|
|
327
|
+
str(total_quote_volume), # quote_volume: 合并10个周期的成交额
|
|
328
|
+
total_trades, # trades: 合并10个周期的成交笔数
|
|
329
|
+
str(total_taker_buy_base_volume), # taker_buy_base_volume
|
|
330
|
+
str(total_taker_buy_quote_volume), # taker_buy_quote_volume
|
|
331
|
+
"0" # ignore
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
merged.insert(0, merged_kline)
|
|
335
|
+
i -= 10
|
|
336
|
+
|
|
337
|
+
# 如果还有剩余的1m周期(少于10个),可以保留或丢弃
|
|
338
|
+
# 这里选择丢弃,因为不完整的10m周期可能不准确
|
|
339
|
+
|
|
340
|
+
return merged
|
|
341
|
+
|
|
259
342
|
def _handle_kline_message(self, data: Any):
|
|
260
343
|
"""
|
|
261
344
|
处理 WebSocket 接收到的 K 线消息
|
|
@@ -306,6 +389,61 @@ class RealtimePriceTracker:
|
|
|
306
389
|
"0" # ignore
|
|
307
390
|
]
|
|
308
391
|
|
|
392
|
+
# 对于 10m 间隔,需要合并 1m 数据
|
|
393
|
+
if self.interval == "10m":
|
|
394
|
+
# 将新的1m周期添加到缓存
|
|
395
|
+
self._pending_1m_klines.append(kline_data)
|
|
396
|
+
|
|
397
|
+
# 如果缓存了10个1m周期,合并成1个10m周期
|
|
398
|
+
if len(self._pending_1m_klines) >= 10:
|
|
399
|
+
klines_10m = self._pending_1m_klines[-10:] # 取最后10个
|
|
400
|
+
|
|
401
|
+
# 提取第一个(最旧的)和最后一个(最新的)的数据
|
|
402
|
+
kline_first = klines_10m[0]
|
|
403
|
+
kline_last = klines_10m[-1]
|
|
404
|
+
|
|
405
|
+
# 提取所有数据用于计算
|
|
406
|
+
open_time_first = int(kline_first[0]) if isinstance(kline_first[0], str) else kline_first[0]
|
|
407
|
+
open_price_first = float(kline_first[1]) if isinstance(kline_first[1], str) else kline_first[1]
|
|
408
|
+
close_time_last = int(kline_last[6]) if isinstance(kline_last[6], str) else kline_last[6]
|
|
409
|
+
close_price_last = float(kline_last[4]) if isinstance(kline_last[4], str) else kline_last[4]
|
|
410
|
+
|
|
411
|
+
# 计算最高价和最低价
|
|
412
|
+
high_prices = [float(k[2]) if isinstance(k[2], str) else k[2] for k in klines_10m]
|
|
413
|
+
low_prices = [float(k[3]) if isinstance(k[3], str) else k[3] for k in klines_10m]
|
|
414
|
+
max_high = max(high_prices)
|
|
415
|
+
min_low = min(low_prices)
|
|
416
|
+
|
|
417
|
+
# 合并成交量、成交额等
|
|
418
|
+
total_volume = sum(float(k[5]) if isinstance(k[5], str) else k[5] for k in klines_10m)
|
|
419
|
+
total_quote_volume = sum(float(k[7]) if isinstance(k[7], str) else k[7] for k in klines_10m)
|
|
420
|
+
total_trades = sum(int(k[8]) if isinstance(k[8], str) else k[8] for k in klines_10m)
|
|
421
|
+
total_taker_buy_base_volume = sum(float(k[9]) if isinstance(k[9], str) else k[9] for k in klines_10m)
|
|
422
|
+
total_taker_buy_quote_volume = sum(float(k[10]) if isinstance(k[10], str) else k[10] for k in klines_10m)
|
|
423
|
+
|
|
424
|
+
# 合并:开盘价用第一个,收盘价用最后一个,最高价和最低价取10个周期的最大最小值
|
|
425
|
+
merged_kline = [
|
|
426
|
+
open_time_first, # open_time: 使用第一个的开始时间
|
|
427
|
+
str(open_price_first), # open_price: 使用第一个的开盘价
|
|
428
|
+
str(max_high), # high_price: 取10个周期的最高价
|
|
429
|
+
str(min_low), # low_price: 取10个周期的最低价
|
|
430
|
+
str(close_price_last), # close_price: 使用最后一个的收盘价
|
|
431
|
+
str(total_volume), # volume: 合并10个周期的成交量
|
|
432
|
+
close_time_last, # close_time: 使用最后一个的结束时间
|
|
433
|
+
str(total_quote_volume), # quote_volume: 合并10个周期的成交额
|
|
434
|
+
total_trades, # trades: 合并10个周期的成交笔数
|
|
435
|
+
str(total_taker_buy_base_volume), # taker_buy_base_volume
|
|
436
|
+
str(total_taker_buy_quote_volume), # taker_buy_quote_volume
|
|
437
|
+
"0" # ignore
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
kline_data = merged_kline
|
|
441
|
+
# 清空缓存(保留最后9个,因为下一个10m周期会用到)
|
|
442
|
+
self._pending_1m_klines = self._pending_1m_klines[-9:]
|
|
443
|
+
else:
|
|
444
|
+
# 还没有10个1m周期,等待更多数据
|
|
445
|
+
return
|
|
446
|
+
|
|
309
447
|
kline_dict = self._kline_to_dict(kline_data)
|
|
310
448
|
self.latest_kline = kline_dict
|
|
311
449
|
|
|
@@ -442,9 +580,11 @@ class RealtimePriceTracker:
|
|
|
442
580
|
return
|
|
443
581
|
|
|
444
582
|
# 订阅 K 线流
|
|
583
|
+
# 对于 10m 间隔,使用 1m 流然后合并
|
|
584
|
+
ws_interval = "1m" if self.interval == "10m" else self.interval
|
|
445
585
|
self.stream = await self.connection.kline_candlestick_streams(
|
|
446
586
|
symbol=self.symbol.lower(),
|
|
447
|
-
interval=
|
|
587
|
+
interval=ws_interval,
|
|
448
588
|
)
|
|
449
589
|
|
|
450
590
|
# 检查流是否成功创建
|