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,808 @@
1
+ """
2
+ K 线 + 因子获取(单点 / 以某点为 end 向前取 n 点)
3
+
4
+ 参考:model-training/trading_report_signal_ranking_v1/get_klines_data_with_factor_dire.py
5
+ 公用方法 get_klines_with_factors(klines_data):输入原始 klines_data,输出带因子的 klines 列表。
6
+ 对外接口:
7
+ - get_kline_with_factor_at_time:输出当前时间点的单条带因子数据
8
+ - get_kline_with_factor_n_points:输出当前所有时间点的带因子数据
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from datetime import datetime
15
+ from typing import Any, Callable, Dict, List, Optional, Union
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s - %(levelname)s - %(message)s",
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # 时间转换(与 get_futures_data 一致)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _convert_to_timestamp_ms(time_input: Union[datetime, str, int, None]) -> Optional[int]:
30
+ if time_input is None:
31
+ return None
32
+ if isinstance(time_input, datetime):
33
+ return int(time_input.timestamp() * 1000)
34
+ if isinstance(time_input, str):
35
+ 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"]:
36
+ try:
37
+ dt = datetime.strptime(time_input, fmt)
38
+ return int(dt.timestamp() * 1000)
39
+ except ValueError:
40
+ continue
41
+ raise ValueError(f"无法解析时间字符串: {time_input}")
42
+ if isinstance(time_input, int):
43
+ return time_input if time_input > 1e10 else time_input * 1000
44
+ raise TypeError(f"不支持的时间类型: {type(time_input)}")
45
+
46
+
47
+ def _get_interval_duration_ms(interval: str) -> int:
48
+ mapping = {
49
+ "1m": 60 * 1000,
50
+ "3m": 3 * 60 * 1000,
51
+ "5m": 5 * 60 * 1000,
52
+ "15m": 15 * 60 * 1000,
53
+ "30m": 30 * 60 * 1000,
54
+ "1h": 60 * 60 * 1000,
55
+ "2h": 2 * 60 * 60 * 1000,
56
+ "4h": 4 * 60 * 60 * 1000,
57
+ "6h": 6 * 60 * 60 * 1000,
58
+ "8h": 8 * 60 * 60 * 1000,
59
+ "12h": 12 * 60 * 60 * 1000,
60
+ "1d": 24 * 60 * 60 * 1000,
61
+ "3d": 3 * 24 * 60 * 60 * 1000,
62
+ "1w": 7 * 24 * 60 * 60 * 1000,
63
+ "1M": 30 * 24 * 60 * 60 * 1000,
64
+ }
65
+ return mapping.get(interval, 60 * 1000)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # 因子计算(与 get_klines_data_with_factor_dire 一致)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def calculate_normalized_alpha_factor(
73
+ data_slice: pd.DataFrame,
74
+ factor_func: Callable,
75
+ factor_name: str,
76
+ min_required: int = 30,
77
+ lookback_periods: int = 30,
78
+ factor_cache: Optional[Dict[tuple, float]] = None,
79
+ current_point_idx: Optional[int] = None,
80
+ **factor_kwargs: Any,
81
+ ) -> Optional[Dict[str, Any]]:
82
+ try:
83
+ if len(data_slice) < min_required + 2:
84
+ return None
85
+ available_periods = len(data_slice) - min_required - 1
86
+ if available_periods < 2:
87
+ return None
88
+ actual_lookback = min(lookback_periods, max(2, available_periods))
89
+ factor_values: List[float] = []
90
+ cache_key_base = factor_name if current_point_idx is not None else None
91
+
92
+ for i in range(actual_lookback + 1):
93
+ end_idx = len(data_slice) - i
94
+ point_idx = current_point_idx - i if current_point_idx is not None else None
95
+ if factor_cache is not None and point_idx is not None and cache_key_base:
96
+ cache_key = (point_idx, cache_key_base)
97
+ if cache_key in factor_cache:
98
+ factor_values.append(factor_cache[cache_key])
99
+ continue
100
+ start_idx = max(0, end_idx - min_required - 1)
101
+ if end_idx <= start_idx:
102
+ factor_values.append(0.0)
103
+ continue
104
+ period_slice = data_slice.iloc[start_idx:end_idx]
105
+ try:
106
+ factor_value = factor_func(period_slice, **factor_kwargs)
107
+ if factor_value is not None:
108
+ value = float(factor_value)
109
+ factor_values.append(value)
110
+ if factor_cache is not None and point_idx is not None and cache_key_base:
111
+ factor_cache[(point_idx, cache_key_base)] = value
112
+ else:
113
+ factor_values.append(0.0)
114
+ except Exception:
115
+ factor_values.append(0.0)
116
+
117
+ if len(factor_values) < 2:
118
+ return None
119
+ factor_array = np.array(factor_values)
120
+ factor_mean = factor_array.mean()
121
+ factor_std = factor_array.std()
122
+ if factor_std == 0 or np.isnan(factor_std):
123
+ normalized_factors = np.zeros_like(factor_array)
124
+ else:
125
+ normalized_factors = (factor_array - factor_mean) / factor_std
126
+ current_normalized = float(normalized_factors[0])
127
+ prev_normalized = float(normalized_factors[1]) if len(normalized_factors) > 1 else 0.0
128
+ signal = "看多" if current_normalized > 0 else ("看空" if current_normalized < 0 else "中性")
129
+ return {
130
+ "value": current_normalized,
131
+ "signal": signal,
132
+ "raw_value": float(factor_values[0]) if factor_values else 0.0,
133
+ "prev_normalized": prev_normalized,
134
+ }
135
+ except Exception as e:
136
+ logging.warning("Error calculating normalized %s factor: %s", factor_name, e)
137
+ return None
138
+
139
+
140
+ def _get_factor_configs() -> List[tuple]:
141
+ from cyqnt_trd.trading_signal.factor.ma_factor import ma_factor
142
+ from cyqnt_trd.trading_signal.factor.rsi_factor import rsi_factor
143
+ from cyqnt_trd.trading_signal.selected_alpha import (
144
+ alpha1_factor,
145
+ alpha3_factor,
146
+ alpha7_factor,
147
+ alpha9_factor,
148
+ alpha11_factor,
149
+ alpha15_factor,
150
+ alpha17_factor,
151
+ alpha21_factor,
152
+ alpha23_factor,
153
+ alpha25_factor,
154
+ alpha29_factor,
155
+ alpha33_factor,
156
+ alpha34_factor,
157
+ )
158
+ return [
159
+ ("ma_factor_5", ma_factor, 6, {"period": 5}, False),
160
+ ("rsi_factor_14", rsi_factor, 16, {"period": 14}, False),
161
+ ("alpha1", alpha1_factor, 30, {"lookback_days": 5, "stddev_period": 20, "power": 2.0}, True),
162
+ ("alpha3", alpha3_factor, 30, {}, True),
163
+ ("alpha7", alpha7_factor, 30, {}, True),
164
+ ("alpha9", alpha9_factor, 30, {}, True),
165
+ ("alpha11", alpha11_factor, 30, {}, True),
166
+ ("alpha15", alpha15_factor, 30, {}, True),
167
+ ("alpha17", alpha17_factor, 30, {}, True),
168
+ ("alpha21", alpha21_factor, 30, {}, True),
169
+ ("alpha23", alpha23_factor, 30, {}, True),
170
+ ("alpha25", alpha25_factor, 30, {}, True),
171
+ ("alpha29", alpha29_factor, 30, {}, True),
172
+ ("alpha33", alpha33_factor, 30, {}, True),
173
+ ("alpha34", alpha34_factor, 30, {}, True),
174
+ ]
175
+
176
+
177
+ HISTORY_WINDOW = 100
178
+ # 接口单次请求 K 线数量上限,超过则分多次请求
179
+ MAX_KLINES_PER_REQUEST = 1500
180
+
181
+
182
+ def _compute_point_factors(
183
+ data_df: pd.DataFrame,
184
+ idx: int,
185
+ factor_configs: List[tuple],
186
+ factor_cache: Dict[tuple, float],
187
+ lookback_periods: int = 30,
188
+ ) -> Optional[Dict[str, Any]]:
189
+ time_point_data = data_df.iloc[: idx + 1]
190
+ kline_point = data_df.iloc[idx]
191
+ open_time = int(kline_point["open_time"])
192
+ close_time = int(kline_point["close_time"])
193
+ open_time_str = kline_point.get(
194
+ "open_time_str", pd.to_datetime(open_time, unit="ms").strftime("%Y-%m-%d %H:%M:%S")
195
+ )
196
+ close_time_str = kline_point.get(
197
+ "close_time_str", pd.to_datetime(close_time, unit="ms").strftime("%Y-%m-%d %H:%M:%S")
198
+ )
199
+ point_factors: Dict[str, Any] = {}
200
+
201
+ for factor_name, factor_func, min_req, kwargs, is_alpha in factor_configs:
202
+ if len(time_point_data) < min_req:
203
+ continue
204
+ try:
205
+ if is_alpha:
206
+ result = calculate_normalized_alpha_factor(
207
+ time_point_data,
208
+ factor_func,
209
+ factor_name,
210
+ min_required=min_req,
211
+ lookback_periods=lookback_periods,
212
+ factor_cache=factor_cache,
213
+ current_point_idx=idx,
214
+ **kwargs,
215
+ )
216
+ if result:
217
+ point_factors[factor_name] = {
218
+ "value": result.get("value", 0.0),
219
+ "raw_value": result.get("raw_value", 0.0),
220
+ "signal": result.get("signal", "中性"),
221
+ "prev_normalized": result.get("prev_normalized", 0.0),
222
+ }
223
+ else:
224
+ cache_key = (idx, factor_name)
225
+ if cache_key in factor_cache:
226
+ factor_value = factor_cache[cache_key]
227
+ else:
228
+ factor_value = factor_func(time_point_data, **kwargs)
229
+ if factor_value is not None:
230
+ factor_cache[cache_key] = float(factor_value)
231
+ if factor_value is not None:
232
+ signal = "看多" if factor_value > 0 else ("看空" if factor_value < 0 else "中性")
233
+ point_factors[factor_name] = {
234
+ "value": float(factor_value),
235
+ "raw_value": float(factor_value),
236
+ "signal": signal,
237
+ }
238
+ except Exception as e:
239
+ logging.debug("Failed %s at idx %s: %s", factor_name, idx, e)
240
+
241
+ # 仅在有足够历史时输出,不进行任何 0 填充
242
+ if idx < HISTORY_WINDOW:
243
+ return None
244
+ start_history_idx = idx - HISTORY_WINDOW
245
+ end_history_idx = idx
246
+ hist = data_df.iloc[start_history_idx:end_history_idx]
247
+ open_price_list = hist["open_price"].astype(float).tolist()
248
+ high_price_list = hist["high_price"].astype(float).tolist()
249
+ low_price_list = hist["low_price"].astype(float).tolist()
250
+ close_price_list = hist["close_price"].astype(float).tolist()
251
+ volume_list = hist["volume"].astype(float).tolist()
252
+
253
+ return {
254
+ "time_index": idx,
255
+ "open_time": open_time,
256
+ "open_time_str": open_time_str,
257
+ "close_time": close_time,
258
+ "close_time_str": close_time_str,
259
+ "open_price": float(kline_point["open_price"]),
260
+ "high_price": float(kline_point["high_price"]),
261
+ "low_price": float(kline_point["low_price"]),
262
+ "close_price": float(kline_point["close_price"]),
263
+ "volume": float(kline_point["volume"]),
264
+ "factors": point_factors,
265
+ "open_price_list": open_price_list,
266
+ "high_price_list": high_price_list,
267
+ "low_price_list": low_price_list,
268
+ "close_price_list": close_price_list,
269
+ "volume_list": volume_list,
270
+ }
271
+
272
+
273
+ def _klines_to_df(klines_data: List[Any]) -> pd.DataFrame:
274
+ formatted: List[Dict[str, Any]] = []
275
+ for kline in klines_data:
276
+ if isinstance(kline, list):
277
+ open_time = int(kline[0]) if isinstance(kline[0], str) else kline[0]
278
+ close_time = int(kline[6]) if isinstance(kline[6], str) else kline[6]
279
+ formatted.append({
280
+ "datetime": pd.to_datetime(open_time, unit="ms"),
281
+ "open_time": open_time,
282
+ "open_time_str": pd.to_datetime(open_time, unit="ms").strftime("%Y-%m-%d %H:%M:%S"),
283
+ "open_price": float(kline[1]),
284
+ "high_price": float(kline[2]),
285
+ "low_price": float(kline[3]),
286
+ "close_price": float(kline[4]),
287
+ "volume": float(kline[5]),
288
+ "close_time": close_time,
289
+ "close_time_str": pd.to_datetime(close_time, unit="ms").strftime("%Y-%m-%d %H:%M:%S"),
290
+ })
291
+ elif isinstance(kline, dict):
292
+ row = dict(kline)
293
+ if "datetime" not in row and "open_time" in row:
294
+ row["datetime"] = pd.to_datetime(row["open_time"], unit="ms")
295
+ formatted.append(row)
296
+ df = pd.DataFrame(formatted)
297
+ return df.sort_values("datetime").reset_index(drop=True)
298
+
299
+
300
+ def _get_fetch_fn(type: str):
301
+ """按 type 返回拉取 K 线的函数(futures / spot)。"""
302
+ if type == "futures":
303
+ from cyqnt_trd.get_data.get_futures_data import get_and_save_futures_klines
304
+ return get_and_save_futures_klines
305
+ from cyqnt_trd.get_data.get_trending_data import get_and_save_klines_direct
306
+ return get_and_save_klines_direct
307
+
308
+
309
+ def _fetch_klines_df(
310
+ token: str,
311
+ type: str,
312
+ interval: str,
313
+ start_time: Optional[Union[datetime, str, int]] = None,
314
+ end_time: Optional[Union[datetime, str, int]] = None,
315
+ limit: Optional[int] = None,
316
+ ) -> Optional[pd.DataFrame]:
317
+ get_fn = _get_fetch_fn(type)
318
+ kwargs: Dict[str, Any] = {"symbol": token, "interval": interval, "save_csv": False, "save_json": False}
319
+ if limit is not None:
320
+ kwargs["limit"] = limit
321
+ if start_time is not None:
322
+ kwargs["start_time"] = start_time
323
+ if end_time is not None:
324
+ kwargs["end_time"] = end_time
325
+ raw = get_fn(**kwargs)
326
+ if not raw:
327
+ return None
328
+ return _klines_to_df(raw)
329
+
330
+
331
+ def _fetch_klines_by_range_paginated(
332
+ token: str,
333
+ type: str,
334
+ interval: str,
335
+ start_ms: int,
336
+ end_ms: int,
337
+ max_per_request: int = MAX_KLINES_PER_REQUEST,
338
+ ) -> Optional[pd.DataFrame]:
339
+ """
340
+ 按时间范围拉取 K 线;若条数超过 max_per_request,则按时间分段多次请求后合并。
341
+ 返回合并去重并按 open_time 排序的 DataFrame。
342
+ """
343
+ interval_ms = _get_interval_duration_ms(interval)
344
+ num_bars_approx = max(0, (end_ms - start_ms) // interval_ms)
345
+ if num_bars_approx == 0:
346
+ return _fetch_klines_df(token, type, interval, start_time=start_ms, end_time=end_ms)
347
+
348
+ get_fn = _get_fetch_fn(type)
349
+ if num_bars_approx <= max_per_request:
350
+ # 小范围时仅用 end_time + limit 单次请求,避免底层 start+end 触发的多轮分页导致卡住
351
+ request_limit = min(num_bars_approx + 20, max_per_request)
352
+ raw = get_fn(
353
+ symbol=token,
354
+ interval=interval,
355
+ end_time=end_ms,
356
+ limit=request_limit,
357
+ save_csv=False,
358
+ save_json=False,
359
+ )
360
+ if not raw:
361
+ return None
362
+ df = _klines_to_df(raw)
363
+ # 只保留 [start_ms, end_ms] 内的 K 线
364
+ if "open_time" in df.columns:
365
+ df = df[df["open_time"] >= start_ms].sort_values("open_time").reset_index(drop=True)
366
+ return df
367
+
368
+ all_raw: List[Any] = []
369
+ current_start = start_ms
370
+ request_count = 0
371
+ max_chunk_requests = 1000 # 防止异常时无限循环
372
+ while current_start < end_ms and request_count < max_chunk_requests:
373
+ request_count += 1
374
+ chunk_end_ms = min(current_start + max_per_request * interval_ms, end_ms)
375
+ batch = get_fn(
376
+ symbol=token,
377
+ interval=interval,
378
+ start_time=current_start,
379
+ end_time=chunk_end_ms,
380
+ save_csv=False,
381
+ save_json=False,
382
+ )
383
+ if not batch:
384
+ break
385
+ all_raw.extend(batch)
386
+ logging.info(
387
+ "K线分页第 %s 次请求: %s ~ %s, 本批 %s 条, 累计 %s 条",
388
+ request_count,
389
+ pd.to_datetime(current_start, unit="ms").strftime("%Y-%m-%d %H:%M"),
390
+ pd.to_datetime(chunk_end_ms, unit="ms").strftime("%Y-%m-%d %H:%M"),
391
+ len(batch),
392
+ len(all_raw),
393
+ )
394
+ if len(batch) < max_per_request:
395
+ break
396
+ current_start = chunk_end_ms
397
+ if not all_raw:
398
+ return None
399
+ df = _klines_to_df(all_raw)
400
+ # 分页边界可能重复,按 open_time 去重
401
+ if "open_time" in df.columns:
402
+ df = df.drop_duplicates(subset=["open_time"], keep="first").sort_values("open_time").reset_index(drop=True)
403
+ return df
404
+
405
+
406
+ def _compute_kline_data_with_factors(
407
+ data_df: pd.DataFrame,
408
+ factor_configs: List[tuple],
409
+ lookback_periods: int = 30,
410
+ ) -> List[Dict[str, Any]]:
411
+ min_required = max(c[2] for c in factor_configs)
412
+ # 仅对“前面至少有 HISTORY_WINDOW 根真实 K 线”的点算因子,不填充 0
413
+ start_idx = max(min_required + 2, HISTORY_WINDOW)
414
+ factor_cache: Dict[tuple, float] = {}
415
+ out: List[Dict[str, Any]] = []
416
+ for idx in range(start_idx, len(data_df)):
417
+ try:
418
+ point = _compute_point_factors(
419
+ data_df, idx, factor_configs, factor_cache, lookback_periods
420
+ )
421
+ if point is not None:
422
+ out.append(point)
423
+ except Exception as e:
424
+ logging.debug("Skip point idx %s (no padding): %s", idx, e)
425
+ return out
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # 公用方法:输入 klines_data,输出带因子的 klines 列表
430
+ # ---------------------------------------------------------------------------
431
+
432
+ def get_klines_with_factors(
433
+ klines_data: List[Any],
434
+ factor_configs: Optional[List[tuple]] = None,
435
+ lookback_periods: int = 30,
436
+ ) -> List[Dict[str, Any]]:
437
+ """
438
+ 公用方法:输入原始 klines_data(list of kline dict/list),输出带因子的 klines 列表。
439
+
440
+ Args:
441
+ klines_data: 原始 K 线数据,与 get_and_save_klines_direct / get_and_save_futures_klines 返回格式一致
442
+ factor_configs: 因子配置,默认 None 使用内置配置
443
+ lookback_periods: 因子回看周期
444
+
445
+ Returns:
446
+ 带因子的 K 线列表,每项为 dict(含 open_time, factors, open_price_list 等)
447
+ """
448
+ data_df = _klines_to_df(klines_data)
449
+ if data_df.empty:
450
+ return []
451
+ configs = factor_configs or _get_factor_configs()
452
+ return _compute_kline_data_with_factors(data_df, configs, lookback_periods)
453
+
454
+
455
+ def get_klines_with_factors_at_time(
456
+ klines_data: List[Any],
457
+ factor_configs: Optional[List[tuple]] = None,
458
+ lookback_periods: int = 30,
459
+ ) -> List[Dict[str, Any]]:
460
+ """
461
+ 输入 klines_data,输出带因子的 klines 数据点,仅包含最新一根(最后一个时间点)。
462
+
463
+ Args:
464
+ klines_data: 原始 K 线数据
465
+ factor_configs: 因子配置,默认 None 使用内置配置
466
+ lookback_periods: 因子回看周期
467
+
468
+ Returns:
469
+ 长度为 0 或 1 的列表;有足够历史时为 [最新一根带因子的 K 线 dict],否则为 []。
470
+ """
471
+ all_points = get_klines_with_factors(klines_data, factor_configs, lookback_periods)
472
+ if not all_points:
473
+ return []
474
+ return [all_points[-1]]
475
+
476
+
477
+ def get_klines_with_factors_n_points(
478
+ klines_data: List[Any],
479
+ factor_configs: Optional[List[tuple]] = None,
480
+ lookback_periods: int = 30,
481
+ ) -> List[Dict[str, Any]]:
482
+ """
483
+ 输入 klines_data,输出带因子的 klines 数据,包含所有可计算因子的时间点及对应数据点。
484
+
485
+ Args:
486
+ klines_data: 原始 K 线数据
487
+ factor_configs: 因子配置,默认 None 使用内置配置
488
+ lookback_periods: 因子回看周期
489
+
490
+ Returns:
491
+ 带因子的 K 线列表,每项为 dict(含 open_time, factors, open_price_list 等),与 klines_data 中可算因子的点一一对应。
492
+ """
493
+ return get_klines_with_factors(klines_data, factor_configs, lookback_periods)
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # 对外接口
498
+ # ---------------------------------------------------------------------------
499
+
500
+ def _success_at_time(
501
+ token: str,
502
+ type: str,
503
+ interval: str,
504
+ at_ms: int,
505
+ kline_data: List[Dict[str, Any]],
506
+ data_rows: Optional[int] = None,
507
+ reason: Optional[str] = None,
508
+ ) -> Dict[str, Any]:
509
+ metadata = {"token": token, "type": type, "interval": interval, "at_time": at_ms}
510
+ if data_rows is not None:
511
+ metadata["data_rows"] = data_rows
512
+ if reason is not None:
513
+ metadata["reason"] = reason
514
+ elif data_rows is not None:
515
+ metadata["at_time_str"] = pd.to_datetime(at_ms, unit="ms").strftime("%Y-%m-%d %H:%M:%S")
516
+ return {"status": "success", "kline_data": kline_data, "metadata": metadata}
517
+
518
+
519
+ def get_kline_with_factor_at_time(
520
+ token: str,
521
+ type: str = "futures",
522
+ interval: str = "30m",
523
+ at_time: Union[datetime, str, int] = None,
524
+ limit: int = 200,
525
+ ) -> Dict[str, Any]:
526
+ """
527
+ 针对某个时间点:先拉取 klines_data,再经 get_klines_with_factors 计算因子,
528
+ 返回该时间点的单根 K 线及其因子。
529
+
530
+ Args:
531
+ token: 交易对,如 BTCUSDT
532
+ type: futures | spot
533
+ interval: K 线周期,如 1m, 30m, 1h
534
+ at_time: 目标时间点(datetime/字符串/毫秒或秒时间戳)
535
+ limit: 拉取 K 线数量,默认 200
536
+
537
+ Returns:
538
+ {"status": "success"|"error", "kline_data": [单点], "metadata": {...}}
539
+ """
540
+ at_ms = _convert_to_timestamp_ms(at_time) if at_time is not None else None
541
+ if at_ms is None:
542
+ return {"status": "error", "error": "at_time is required", "kline_data": [], "metadata": {}}
543
+
544
+ get_fn = _get_fetch_fn(type)
545
+ klines_data = get_fn(
546
+ symbol=token,
547
+ interval=interval,
548
+ limit=min(limit, MAX_KLINES_PER_REQUEST),
549
+ end_time=at_ms + _get_interval_duration_ms(interval),
550
+ save_csv=False,
551
+ save_json=False,
552
+ )
553
+ if not klines_data:
554
+ return {
555
+ "status": "error",
556
+ "error": f"No kline data for token {token}",
557
+ "kline_data": [],
558
+ "metadata": {"token": token, "type": type, "interval": interval, "at_time": at_ms},
559
+ }
560
+
561
+ data_df = _klines_to_df(klines_data).sort_values("datetime").reset_index(drop=True)
562
+ mask = data_df["open_time"] <= at_ms
563
+ if not mask.any():
564
+ return _success_at_time(token, type, interval, at_ms, [], data_rows=len(data_df))
565
+ row_idx = int(data_df.index[mask][-1])
566
+ min_idx = max(max(c[2] for c in _get_factor_configs()) + 2, HISTORY_WINDOW)
567
+ if row_idx < min_idx:
568
+ return _success_at_time(
569
+ token, type, interval, at_ms, [],
570
+ reason="Insufficient history (need at least %s bars before point)" % HISTORY_WINDOW,
571
+ )
572
+
573
+ kline_data_with_factors = get_klines_with_factors(klines_data)
574
+ target_open_time = int(data_df.iloc[row_idx]["open_time"])
575
+ point_data = next((p for p in kline_data_with_factors if int(p["open_time"]) == target_open_time), None)
576
+ return _success_at_time(token, type, interval, at_ms, [point_data] if point_data else [], data_rows=len(data_df))
577
+
578
+
579
+ def get_kline_with_factor_n_points(
580
+ token: str,
581
+ type: str = "futures",
582
+ interval: str = "30m",
583
+ n: int = 500,
584
+ end_time: Optional[Union[datetime, str, int]] = None,
585
+ ) -> Dict[str, Any]:
586
+ """
587
+ 以某时间为 end_time 向前取 n 个点:先拉取 klines_data,再经 get_klines_with_factors 计算因子,
588
+ 返回当前所有时间点的带因子数据。
589
+
590
+ Args:
591
+ token: 交易对
592
+ type: futures | spot
593
+ interval: K 线周期
594
+ n: 向前取的 K 线根数
595
+ end_time: 结束时间,默认 None 表示当前时间
596
+
597
+ Returns:
598
+ {"status": "success"|"error", "kline_data": [...], "metadata": {...}}
599
+ """
600
+ end_ms = int(datetime.now().timestamp() * 1000) if end_time is None else _convert_to_timestamp_ms(end_time)
601
+ if end_ms is None:
602
+ return {"status": "error", "error": "Invalid end_time", "kline_data": [], "metadata": {}}
603
+
604
+ interval_ms = _get_interval_duration_ms(interval)
605
+ start_ms = end_ms - (n + HISTORY_WINDOW) * interval_ms
606
+ data_df = _fetch_klines_by_range_paginated(
607
+ token=token, type=type, interval=interval,
608
+ start_ms=start_ms, end_ms=end_ms + interval_ms,
609
+ )
610
+ if data_df is None or data_df.empty:
611
+ return {
612
+ "status": "error",
613
+ "error": f"No kline data for token {token}",
614
+ "kline_data": [],
615
+ "metadata": {"token": token, "type": type, "interval": interval, "n": n},
616
+ }
617
+
618
+ klines_data = data_df.to_dict("records")
619
+ kline_data_with_factors = get_klines_with_factors(klines_data)
620
+ kline_data_with_factors = [p for p in kline_data_with_factors if int(p["open_time"]) <= end_ms][-n:]
621
+ return {
622
+ "status": "success",
623
+ "kline_data": kline_data_with_factors,
624
+ "metadata": {
625
+ "token": token, "type": type, "interval": interval, "n": n,
626
+ "end_time": end_ms,
627
+ "end_time_str": pd.to_datetime(end_ms, unit="ms").strftime("%Y-%m-%d %H:%M:%S"),
628
+ "start_time": start_ms,
629
+ "start_time_str": pd.to_datetime(start_ms, unit="ms").strftime("%Y-%m-%d %H:%M:%S"),
630
+ "total_kline_points": len(kline_data_with_factors),
631
+ "total_data_points": len(data_df),
632
+ },
633
+ }
634
+
635
+
636
+ # ---------------------------------------------------------------------------
637
+ # 测试脚本
638
+ # ---------------------------------------------------------------------------
639
+
640
+
641
+ def _value_equals(va: Any, vb: Any, rtol: float, atol: float) -> bool:
642
+ """递归比较两值是否在数值容差内一致(支持 dict/list/数值/其它)。"""
643
+ if isinstance(va, dict) and isinstance(vb, dict):
644
+ if set(va.keys()) != set(vb.keys()):
645
+ return False
646
+ return all(_value_equals(va[k], vb[k], rtol, atol) for k in va)
647
+ if isinstance(va, (list, tuple)) and isinstance(vb, (list, tuple)):
648
+ if len(va) != len(vb):
649
+ return False
650
+ return all(_value_equals(x, y, rtol, atol) for x, y in zip(va, vb))
651
+ if isinstance(va, (int, float)) and isinstance(vb, (int, float)):
652
+ try:
653
+ return bool(np.isclose(float(va), float(vb), rtol=rtol, atol=atol, equal_nan=True))
654
+ except (TypeError, ValueError):
655
+ return va == vb
656
+ return va == vb
657
+
658
+
659
+ def _point_dict_equals(a: Dict[str, Any], b: Dict[str, Any], rtol: float = 1e-5, atol: float = 1e-8) -> bool:
660
+ """比较两条 K 线+因子记录是否一致(数值字段用容差比较,含嵌套 factors)。排除 time_index(两处 data_df 不同)。"""
661
+ keys_a, keys_b = set(a.keys()), set(b.keys())
662
+ if keys_a != keys_b:
663
+ return False
664
+ skip_keys = {"time_index"} # 两处 data_df 行号不同,不参与比较
665
+ if int(a.get("open_time", 0)) != int(b.get("open_time", 0)):
666
+ return False
667
+ for k in a:
668
+ if k in skip_keys:
669
+ continue
670
+ if not _value_equals(a[k], b[k], rtol, atol):
671
+ return False
672
+ return True
673
+
674
+
675
+ def _test_at_time_equals_n_points_last():
676
+ """
677
+ 准则:同一份 klines_data 下,get_klines_with_factors_at_time 返回的最新一根
678
+ 应与 get_klines_with_factors_n_points 返回的最后一根完全相同。
679
+ """
680
+ print("=" * 60)
681
+ print("测试: get_klines_with_factors_at_time 末条 == get_klines_with_factors_n_points 末条")
682
+ print("=" * 60)
683
+ token, type_, interval = "BTCUSDT", "futures", "1h"
684
+ at_time_str = "2026-01-30 8:00:00"
685
+ at_ms = _convert_to_timestamp_ms(at_time_str)
686
+
687
+ get_fn = _get_fetch_fn(type_)
688
+ klines_data = get_fn(
689
+ symbol=token,
690
+ interval=interval,
691
+ limit=min(200, MAX_KLINES_PER_REQUEST),
692
+ end_time=at_ms + _get_interval_duration_ms(interval),
693
+ save_csv=False,
694
+ save_json=False,
695
+ )
696
+ if not klines_data:
697
+ print("FAIL: 无法获取 klines_data")
698
+ return
699
+
700
+ at_list = get_klines_with_factors_at_time(klines_data)
701
+ n_list = get_klines_with_factors_n_points(klines_data)
702
+ if not at_list:
703
+ print("FAIL: get_klines_with_factors_at_time 返回空(可能历史不足)")
704
+ return
705
+ if not n_list:
706
+ print("FAIL: get_klines_with_factors_n_points 返回空")
707
+ return
708
+
709
+ at_point = at_list[0]
710
+ n_last = n_list[-1]
711
+ if int(at_point["open_time"]) != int(n_last["open_time"]):
712
+ print(
713
+ "FAIL: open_time 不一致 at_time=%s n_points_last=%s"
714
+ % (at_point["open_time"], n_last["open_time"])
715
+ )
716
+ return
717
+ if not _point_dict_equals(at_point, n_last):
718
+ print("FAIL: 两条记录内容不一致(数值容差内)")
719
+ print("at_time 首条 open_time:", at_point.get("open_time"))
720
+ print("n_points 末条 open_time:", n_last.get("open_time"))
721
+ return
722
+ print("PASS: get_klines_with_factors_at_time 单条 与 get_klines_with_factors_n_points 末条 完全相同")
723
+ print()
724
+
725
+
726
+ def _test_get_kline_with_factor_at_time():
727
+ """测试 get_klines_with_factors_at_time:先获取 klines_data,再调用得到仅最新一根带因子数据."""
728
+ print("=" * 60)
729
+ print("测试: get_klines_with_factors_at_time(输入 klines_data,输出仅最新一根带因子)")
730
+ print("=" * 60)
731
+ token, type_, interval = "BTCUSDT", "futures", "1h"
732
+ at_time = "2026-01-30 8:00:00"
733
+ at_ms = _convert_to_timestamp_ms(at_time)
734
+
735
+ get_fn = _get_fetch_fn(type_)
736
+ klines_data = get_fn(
737
+ symbol=token,
738
+ interval=interval,
739
+ limit=min(200, MAX_KLINES_PER_REQUEST),
740
+ end_time=at_ms + _get_interval_duration_ms(interval),
741
+ save_csv=False,
742
+ save_json=False,
743
+ )
744
+ if not klines_data:
745
+ print("error: No kline data")
746
+ return
747
+
748
+ kline_data = get_klines_with_factors_at_time(klines_data)
749
+ print("at_time:", at_time)
750
+ print("status: success")
751
+ print("kline_data 条数:", len(kline_data), "(预期 0 或 1)")
752
+ if kline_data:
753
+ print("首条 keys:", list(kline_data[0].keys())[:10], "...")
754
+ print("metadata:", {"token": token, "type": type_, "interval": interval, "at_time": at_ms, "data_rows": len(klines_data)})
755
+ print()
756
+
757
+
758
+ def _test_get_kline_with_factor_n_points():
759
+ """测试 get_klines_with_factors_n_points:先获取 klines_data,再调用得到所有带因子数据点."""
760
+ import os
761
+
762
+ print("=" * 60)
763
+ print("测试: get_klines_with_factors_n_points(输入 klines_data,输出所有带因子数据点)")
764
+ print("=" * 60)
765
+ token, type_, interval = "BTCUSDT", "futures", "1h"
766
+ n, end_time = 3000, "2026-01-30 8:00:00"
767
+ end_ms = _convert_to_timestamp_ms(end_time)
768
+ interval_ms = _get_interval_duration_ms(interval)
769
+ start_ms = end_ms - (n + HISTORY_WINDOW) * interval_ms
770
+
771
+ data_df = _fetch_klines_by_range_paginated(
772
+ token=token, type=type_, interval=interval,
773
+ start_ms=start_ms, end_ms=end_ms + interval_ms,
774
+ )
775
+ if data_df is None or data_df.empty:
776
+ print("error: No kline data")
777
+ return
778
+
779
+ klines_data = data_df.to_dict("records")
780
+ kline_data_with_factors = get_klines_with_factors_n_points(klines_data)
781
+ # 可选:只保留 open_time <= end_ms 的最后 n 条,便于与 n_points 语义对比
782
+ displayed = [p for p in kline_data_with_factors if int(p["open_time"]) <= end_ms][-n:]
783
+
784
+ print("n:", n, "end_time:", end_time)
785
+ print("status: success")
786
+ print("kline_data_with_factors 总条数:", len(kline_data_with_factors))
787
+ print("displayed (open_time<=end_ms 最后 n 条):", len(displayed))
788
+ if displayed:
789
+ first, last = displayed[0], displayed[-1]
790
+ print("首条 open_time:", first.get("open_time"), "末条 open_time:", last.get("open_time"))
791
+ print("metadata:", {"token": token, "type": type_, "interval": interval, "n": n, "end_time": end_ms})
792
+
793
+ save_dir = "/Users/user/Desktop/repo/crypto_trading/cyqnt_trd/tmp"
794
+ os.makedirs(save_dir, exist_ok=True)
795
+ save_path = os.path.join(save_dir, "get_kline_with_factor_n_points_result.parquet")
796
+ try:
797
+ pd.DataFrame(displayed).to_parquet(save_path, engine="pyarrow", index=False)
798
+ print(f"结果已保存: {save_path}")
799
+ except Exception as e:
800
+ print(f"保存 parquet 时出错: {e}")
801
+ print()
802
+
803
+
804
+ if __name__ == "__main__":
805
+ _test_get_kline_with_factor_at_time()
806
+ _test_get_kline_with_factor_n_points()
807
+ _test_at_time_equals_n_points_last()
808
+ print("全部测试脚本执行完毕.")