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.
Files changed (40) hide show
  1. cyqnt_trd/CHANGELOG_0.1.7.md +111 -0
  2. cyqnt_trd/__init__.py +1 -1
  3. cyqnt_trd/backtesting/factor_test.py +3 -2
  4. cyqnt_trd/get_data/__init__.py +16 -1
  5. cyqnt_trd/get_data/get_futures_data.py +3 -3
  6. cyqnt_trd/get_data/get_kline_with_factor.py +808 -0
  7. cyqnt_trd/get_data/get_web3_trending_data.py +389 -0
  8. cyqnt_trd/online_trading/__init__.py +1 -0
  9. cyqnt_trd/online_trading/realtime_price_tracker.py +142 -2
  10. cyqnt_trd/trading_signal/example_usage.py +123 -7
  11. cyqnt_trd/trading_signal/factor/__init__.py +23 -0
  12. cyqnt_trd/trading_signal/factor/adx_factor.py +116 -0
  13. cyqnt_trd/trading_signal/factor/ao_factor.py +66 -0
  14. cyqnt_trd/trading_signal/factor/bbp_factor.py +68 -0
  15. cyqnt_trd/trading_signal/factor/cci_factor.py +65 -0
  16. cyqnt_trd/trading_signal/factor/ema_factor.py +102 -0
  17. cyqnt_trd/trading_signal/factor/macd_factor.py +97 -0
  18. cyqnt_trd/trading_signal/factor/momentum_factor.py +44 -0
  19. cyqnt_trd/trading_signal/factor/stochastic_factor.py +76 -0
  20. cyqnt_trd/trading_signal/factor/stochastic_tsi_factor.py +129 -0
  21. cyqnt_trd/trading_signal/factor/uo_factor.py +92 -0
  22. cyqnt_trd/trading_signal/factor/williams_r_factor.py +60 -0
  23. cyqnt_trd/trading_signal/factor_details.json +107 -0
  24. cyqnt_trd/trading_signal/selected_alpha/alpha1.py +4 -2
  25. cyqnt_trd/trading_signal/selected_alpha/alpha15.py +4 -2
  26. cyqnt_trd/trading_signal/selected_alpha/generate_alphas.py +1 -0
  27. {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/METADATA +16 -12
  28. {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/RECORD +34 -23
  29. {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/WHEEL +1 -1
  30. test/real_time_trade.py +467 -10
  31. test/test_now_factor.py +1082 -0
  32. test/track_k_line_continue.py +372 -0
  33. cyqnt_trd/test_script/get_symbols_by_volume.py +0 -227
  34. cyqnt_trd/test_script/test_alpha.py +0 -261
  35. cyqnt_trd/test_script/test_kline_data.py +0 -479
  36. test/test_example_usage.py +0 -381
  37. test/test_get_data.py +0 -310
  38. test/test_realtime_price_tracker.py +0 -546
  39. {cyqnt_trd-0.1.2.dist-info → cyqnt_trd-0.1.7.dist-info}/licenses/LICENSE +0 -0
  40. {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
+ # )
@@ -11,3 +11,4 @@ __all__ = [
11
11
  ]
12
12
 
13
13
 
14
+
@@ -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=self.lookback_periods
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=self.interval,
587
+ interval=ws_interval,
448
588
  )
449
589
 
450
590
  # 检查流是否成功创建