tradepose-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tradepose-client might be problematic. Click here for more details.

@@ -0,0 +1,828 @@
1
+ """Export 任務管理 API"""
2
+
3
+ import json
4
+ import time
5
+ import zipfile
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+ from typing import Optional, Dict, List, Tuple, Union, Any
9
+
10
+ import polars as pl
11
+ import requests
12
+
13
+ from ..models import ExportTaskResponse, TaskStatus
14
+
15
+
16
+ class ExportAPI:
17
+ """Export 任務管理 API Mixin"""
18
+
19
+
20
+ def create_export(
21
+ self,
22
+ strategy_name: str,
23
+ blueprint_name: str,
24
+ export_type: str = "enhanced-ohlcv",
25
+ start_date: Optional[str] = None,
26
+ end_date: Optional[str] = None,
27
+ strategy_ids: Optional[List[str]] = None,
28
+ ) -> str:
29
+ """創建 export 任務
30
+
31
+ Args:
32
+ strategy_name: 策略名稱
33
+ blueprint_name: 藍圖名稱
34
+ export_type: 導出類型 (enhanced-ohlcv, backtest-results, latest-trades)
35
+ start_date: 起始日期 (ISO 8601 格式, 例如 "2020-01-01T00:00:00")
36
+ end_date: 終止日期 (ISO 8601 格式)
37
+ strategy_ids: 策略 ID 列表 (可選)
38
+
39
+ Returns:
40
+ Export task ID
41
+ """
42
+ payload = {"strategy_name": strategy_name, "blueprint_name": blueprint_name}
43
+
44
+ if start_date:
45
+ payload["start_date"] = start_date
46
+ if end_date:
47
+ payload["end_date"] = end_date
48
+ if strategy_ids:
49
+ payload["strategy_ids"] = strategy_ids
50
+
51
+ response = requests.post(
52
+ f"{self.api_url}/api/v1/backtest/export/{export_type}",
53
+ json=payload,
54
+ headers=self._get_headers(),
55
+ )
56
+ response.raise_for_status()
57
+
58
+ result = response.json()
59
+ return result["export_task_id"]
60
+
61
+ def get_export_status(self, task_id: str) -> ExportTaskResponse:
62
+ """查詢 export 任務狀態
63
+
64
+ Args:
65
+ task_id: Export task ID
66
+
67
+ Returns:
68
+ ExportTaskResponse: 任務狀態信息(Pydantic 模型)
69
+
70
+ Example:
71
+ >>> status = client.get_export_status(task_id)
72
+ >>> print(f"狀態: {status.status}")
73
+ >>> if status.status == TaskStatus.COMPLETED:
74
+ ... print(f"完成時間: {status.completed_at}")
75
+ """
76
+ response = requests.get(
77
+ f"{self.api_url}/api/v1/backtest/export/{task_id}/status",
78
+ headers=self._get_headers()
79
+ )
80
+ response.raise_for_status()
81
+ return ExportTaskResponse.model_validate(response.json())
82
+
83
+ def wait_for_export(
84
+ self,
85
+ task_id: str,
86
+ timeout: int = 300,
87
+ poll_interval: int = 2,
88
+ verbose: bool = True,
89
+ ) -> ExportTaskResponse:
90
+ """等待 export 任務完成
91
+
92
+ Args:
93
+ task_id: Export task ID
94
+ timeout: 超時時間(秒)
95
+ poll_interval: 輪詢間隔(秒)
96
+ verbose: 是否顯示進度信息
97
+
98
+ Returns:
99
+ ExportTaskResponse: 完成的任務狀態(Pydantic 模型)
100
+
101
+ Raises:
102
+ TimeoutError: 等待超時
103
+ RuntimeError: 任務失敗
104
+
105
+ Example:
106
+ >>> result = client.wait_for_export(task_id, verbose=True)
107
+ >>> print(f"執行策略: {result.executed_strategies}")
108
+ >>> if result.result_summary:
109
+ ... print(f"總交易數: {result.result_summary.total_trades}")
110
+ """
111
+ start_time = time.time()
112
+
113
+ while True:
114
+ elapsed = time.time() - start_time
115
+ if elapsed > timeout:
116
+ raise TimeoutError(f"等待任務完成超時 ({timeout}秒)")
117
+
118
+ status_info = self.get_export_status(task_id)
119
+ status = status_info.status
120
+
121
+ if verbose:
122
+ print(f"[{elapsed:.1f}s] Status: {status.value}", end="\r")
123
+
124
+ if status == TaskStatus.COMPLETED:
125
+ if verbose:
126
+ print(f"\n✅ 任務完成!耗時 {elapsed:.1f}秒")
127
+ return status_info
128
+
129
+ elif status == TaskStatus.FAILED:
130
+ error = status_info.error or "Unknown error"
131
+ raise RuntimeError(f"任務失敗: {error}")
132
+
133
+ time.sleep(poll_interval)
134
+
135
+ def get_metadata_from_redis(self, task_id: str) -> Dict:
136
+ """從 Redis 讀取任務 metadata
137
+
138
+ 注意: Redis 只存儲 metadata,實際數據需要通過 API 下載
139
+
140
+ Args:
141
+ task_id: Export task ID
142
+
143
+ Returns:
144
+ Metadata 字典
145
+ """
146
+ metadata_key = f"export:{task_id}:metadata"
147
+ metadata_str = self.redis.get(metadata_key)
148
+
149
+ if not metadata_str:
150
+ raise ValueError(f"找不到任務 {task_id} 的元數據")
151
+
152
+ return json.loads(metadata_str)
153
+
154
+ def _extract_zip_to_memory(
155
+ self, zip_data: Union[BytesIO, bytes]
156
+ ) -> Dict[str, BytesIO]:
157
+ """在內存中解壓 ZIP 文件
158
+
159
+ Args:
160
+ zip_data: ZIP 文件數據 (BytesIO 或 bytes)
161
+
162
+ Returns:
163
+ Dict[filename, BytesIO] - 文件名到內容的映射
164
+ """
165
+ if isinstance(zip_data, bytes):
166
+ zip_data = BytesIO(zip_data)
167
+
168
+ files = {}
169
+ with zipfile.ZipFile(zip_data, "r") as zip_ref:
170
+ for filename in zip_ref.namelist():
171
+ files[filename] = BytesIO(zip_ref.read(filename))
172
+
173
+ return files
174
+
175
+ def wait_and_download(
176
+ self,
177
+ task_id: str,
178
+ output_path: Optional[str] = None,
179
+ timeout: int = 300,
180
+ verbose: bool = True,
181
+ ) -> pl.DataFrame:
182
+ """等待任務完成並下載 Parquet 文件(推薦方法)
183
+
184
+ Args:
185
+ task_id: Export task ID
186
+ output_path: 輸出文件路徑(如果為 None,自動生成)
187
+ timeout: 超時時間(秒)
188
+ verbose: 是否顯示進度
189
+
190
+ Returns:
191
+ Polars DataFrame
192
+ """
193
+ # 等待任務完成
194
+ self.wait_for_export(task_id, timeout=timeout, verbose=verbose)
195
+
196
+ # 下載 Parquet 文件
197
+ parquet_path = self.download_parquet(task_id, output_path)
198
+
199
+ # 讀取並應用 schema
200
+ df = self.load_parquet_with_schema(parquet_path)
201
+
202
+ return df
203
+
204
+ def download_parquet(
205
+ self, task_id: str, output_path: Optional[str] = None
206
+ ) -> Union[Path, BytesIO]:
207
+ """直接從 API 下載 Parquet 文件
208
+
209
+ Args:
210
+ task_id: Export task ID
211
+ output_path: 輸出文件路徑(如果為 None,使用內存 BytesIO)
212
+
213
+ Returns:
214
+ 下載的文件路徑或 BytesIO 對象
215
+
216
+ Raises:
217
+ RuntimeError: 下載失敗
218
+ """
219
+ # 發送下載請求
220
+ url = f"{self.api_url}/api/v1/backtest/export/{task_id}/download"
221
+ print(f"📥 開始下載: {url}")
222
+
223
+ response = requests.get(url, stream=True, headers=self._get_headers())
224
+ response.raise_for_status()
225
+
226
+ # 如果未指定路徑,使用 BytesIO
227
+ if output_path is None:
228
+ buffer = BytesIO()
229
+ total_size = 0
230
+
231
+ for chunk in response.iter_content(chunk_size=8192):
232
+ if chunk:
233
+ buffer.write(chunk)
234
+ total_size += len(chunk)
235
+
236
+ buffer.seek(0) # 重置到開頭以便讀取
237
+ file_size_mb = len(buffer.getvalue()) / (1024 * 1024)
238
+ print(f"✅ 下載完成(內存)")
239
+ print(f"📦 數據大小: {file_size_mb:.2f} MB")
240
+
241
+ return buffer
242
+
243
+ # 保存到文件
244
+ output_path = Path(output_path)
245
+ output_path.parent.mkdir(parents=True, exist_ok=True)
246
+
247
+ total_size = 0
248
+ with open(output_path, "wb") as f:
249
+ for chunk in response.iter_content(chunk_size=8192):
250
+ if chunk:
251
+ f.write(chunk)
252
+ total_size += len(chunk)
253
+
254
+ file_size_mb = output_path.stat().st_size / (1024 * 1024)
255
+ print(f"✅ 下載完成: {output_path}")
256
+ print(f"📦 文件大小: {file_size_mb:.2f} MB")
257
+
258
+ return output_path
259
+
260
+ def load_parquet_with_schema(
261
+ self, file_path: Union[str, Path, BytesIO]
262
+ ) -> pl.DataFrame:
263
+ """讀取 Parquet 文件並應用正確的 schema
264
+
265
+ Args:
266
+ file_path: Parquet 文件路徑或 BytesIO 對象
267
+
268
+ Returns:
269
+ Polars DataFrame (已應用 schema)
270
+ """
271
+ if isinstance(file_path, BytesIO):
272
+ print(f"📖 讀取 Parquet(從內存)")
273
+ else:
274
+ print(f"📖 讀取 Parquet: {file_path}")
275
+
276
+ # 讀取文件
277
+ df = pl.read_parquet(file_path)
278
+
279
+ # print(df.head())
280
+
281
+ # df = df.cast(enhanced_ohlcv_schema)
282
+
283
+ print(f"✅ 讀取完成: {len(df):,} 行 × {len(df.columns)} 列")
284
+
285
+ return df
286
+
287
+ def save_to_parquet(
288
+ self, df: pl.DataFrame, output_path: str, compression: str = "zstd"
289
+ ) -> Path:
290
+ """保存 DataFrame 為 Parquet 文件
291
+
292
+ Args:
293
+ df: Polars DataFrame
294
+ output_path: 輸出文件路徑
295
+ compression: 壓縮算法
296
+
297
+ Returns:
298
+ 輸出文件路徑
299
+ """
300
+ output_path = Path(output_path)
301
+ output_path.parent.mkdir(parents=True, exist_ok=True)
302
+
303
+ df.write_parquet(output_path, compression=compression)
304
+
305
+ file_size_mb = output_path.stat().st_size / (1024 * 1024)
306
+ print(f"✅ 已保存: {output_path}")
307
+ print(f"📦 文件大小: {file_size_mb:.2f} MB")
308
+ print(f"📊 行數: {len(df):,} | 列數: {len(df.columns)}")
309
+
310
+ return output_path
311
+
312
+ def download_and_read_backtest_results(
313
+ self,
314
+ task_id: str,
315
+ save_zip_path: Optional[str] = None,
316
+ save_trades_path: Optional[str] = None,
317
+ save_perf_path: Optional[str] = None,
318
+ ) -> Tuple[pl.DataFrame, pl.DataFrame]:
319
+ """下載並讀取 backtest-results(ZIP 格式)
320
+
321
+ 下載 ZIP 文件 → 內存解壓 → 讀取 trades + performance Parquet
322
+
323
+ Args:
324
+ task_id: Export task ID
325
+ save_zip_path: ZIP 保存路徑(可選,不指定則純內存操作)
326
+ save_trades_path: Trades Parquet 保存路徑(可選)
327
+ save_perf_path: Performance Parquet 保存路徑(可選)
328
+
329
+ Returns:
330
+ (trades_df, performance_df): 兩個 Polars DataFrame
331
+
332
+ Example:
333
+ >>> # 純內存模式
334
+ >>> trades_df, perf_df = client.download_and_read_backtest_results(task_id)
335
+ >>>
336
+ >>> # 保存到本地
337
+ >>> trades_df, perf_df = client.download_and_read_backtest_results(
338
+ ... task_id,
339
+ ... save_zip_path="./results.zip",
340
+ ... save_trades_path="./trades.parquet",
341
+ ... save_perf_path="./performance.parquet"
342
+ ... )
343
+ """
344
+ # 下載 ZIP
345
+ print(f"📥 開始下載 backtest-results: {task_id}")
346
+ zip_data = self.download_parquet(task_id, output_path=save_zip_path)
347
+
348
+ # 解壓到內存
349
+ print(f"📦 解壓 ZIP 文件...")
350
+ if isinstance(zip_data, Path):
351
+ with open(zip_data, "rb") as f:
352
+ files = self._extract_zip_to_memory(f.read())
353
+ else:
354
+ files = self._extract_zip_to_memory(zip_data)
355
+
356
+ # 找到 trades 和 performance 文件
357
+ trades_file = None
358
+ perf_file = None
359
+
360
+ for filename, content in files.items():
361
+ if "trades" in filename.lower() and filename.endswith(".parquet"):
362
+ trades_file = content
363
+ elif "performance" in filename.lower() and filename.endswith(".parquet"):
364
+ perf_file = content
365
+
366
+ if not trades_file or not perf_file:
367
+ raise ValueError(
368
+ f"ZIP 文件中找不到 trades 或 performance parquet 文件。文件列表: {list(files.keys())}"
369
+ )
370
+
371
+ # 讀取 trades
372
+ print(f"📖 讀取 trades.parquet...")
373
+ trades_df = pl.read_parquet(trades_file)
374
+ print(f" ✅ {len(trades_df):,} 行 × {len(trades_df.columns)} 列")
375
+
376
+ # 讀取 performance
377
+ print(f"📖 讀取 performance.parquet...")
378
+ perf_df = pl.read_parquet(perf_file)
379
+ print(f" ✅ {len(perf_df):,} 行 × {len(perf_df.columns)} 列")
380
+
381
+ # 可選:保存到本地
382
+ if save_trades_path:
383
+ self.save_to_parquet(trades_df, save_trades_path)
384
+
385
+ if save_perf_path:
386
+ self.save_to_parquet(perf_df, save_perf_path)
387
+
388
+ return trades_df, perf_df
389
+
390
+ def download_and_read_latest_trades(
391
+ self,
392
+ task_id: str,
393
+ save_path: Optional[str] = None,
394
+ ) -> pl.DataFrame:
395
+ """下載並讀取 latest-trades(Parquet 格式)
396
+
397
+ Args:
398
+ task_id: Export task ID
399
+ save_path: 保存路徑(可選,不指定則純內存操作)
400
+
401
+ Returns:
402
+ Polars DataFrame
403
+
404
+ Example:
405
+ >>> # 純內存模式
406
+ >>> df = client.download_and_read_latest_trades(task_id)
407
+ >>>
408
+ >>> # 保存到本地
409
+ >>> df = client.download_and_read_latest_trades(task_id, save_path="./trades.parquet")
410
+ """
411
+ # 下載 Parquet
412
+ print(f"📥 開始下載 latest-trades: {task_id}")
413
+ file_or_buffer = self.download_parquet(task_id, output_path=save_path)
414
+
415
+ # 讀取
416
+ print(f"📖 讀取 latest_trades.parquet...")
417
+ df = pl.read_parquet(file_or_buffer)
418
+
419
+ print(f"✅ 讀取完成: {len(df):,} 行 × {len(df.columns)} 列")
420
+
421
+ return df
422
+
423
+ def download_and_read_enhanced_ohlcv(
424
+ self,
425
+ task_id: str,
426
+ save_path: Optional[str] = None,
427
+ ) -> pl.DataFrame:
428
+ """下載並讀取 enhanced-ohlcv(Parquet 格式)
429
+
430
+ 這是 wait_and_download 的別名,提供統一的接口
431
+
432
+ Args:
433
+ task_id: Export task ID
434
+ save_path: 保存路徑(可選,不指定則純內存操作)
435
+
436
+ Returns:
437
+ Polars DataFrame
438
+ """
439
+ return self.wait_and_download(task_id, output_path=save_path, verbose=True)
440
+
441
+ def quick_export(
442
+ self,
443
+ strategy_name: str,
444
+ blueprint_name: Optional[str] = None,
445
+ start_date: str = "2020-01-01T00:00:00",
446
+ end_date: Optional[str] = None,
447
+ save_path: Optional[str] = None,
448
+ ) -> pl.DataFrame:
449
+ """快速導出(一鍵完成所有步驟)
450
+
451
+ 從 API 下載 Parquet 文件並讀取。不依賴 PostgreSQL。
452
+
453
+ Args:
454
+ strategy_name: 策略名稱
455
+ blueprint_name: 藍圖名稱(如果為 None,自動獲取)
456
+ start_date: 起始日期
457
+ end_date: 終止日期
458
+ save_path: 保存路徑(如果為 None,自動生成臨時文件)
459
+
460
+ Returns:
461
+ Polars DataFrame
462
+
463
+ Example:
464
+ >>> client = TradeposeClient()
465
+ >>> df = client.quick_export(
466
+ ... strategy_name="txf_1h_sma30_50",
467
+ ... save_path="./data/my_data.parquet"
468
+ ... )
469
+ """
470
+ # 獲取 blueprint 名稱
471
+ if blueprint_name is None:
472
+ strategy_detail = self.get_strategy_detail(strategy_name)
473
+ blueprint_name = strategy_detail["base_blueprint"]["name"]
474
+ print(f"📋 使用 blueprint: {blueprint_name}")
475
+
476
+ # 創建 export
477
+ print(f"🚀 創建 export 任務...")
478
+ task_id = self.create_export(
479
+ strategy_name=strategy_name,
480
+ blueprint_name=blueprint_name,
481
+ start_date=start_date,
482
+ end_date=end_date,
483
+ )
484
+ print(f" Task ID: {task_id}")
485
+
486
+ # 等待完成並下載
487
+ print(f"⏳ 等待任務完成...")
488
+ df = self.wait_and_download(task_id, output_path=save_path, verbose=True)
489
+
490
+ return df
491
+
492
+ def quick_backtest_results(
493
+ self,
494
+ strategy_ids: List[str],
495
+ start_date: Optional[str] = None,
496
+ end_date: Optional[str] = None,
497
+ save_zip_path: Optional[str] = None,
498
+ save_trades_path: Optional[str] = None,
499
+ save_perf_path: Optional[str] = None,
500
+ ) -> Tuple[pl.DataFrame, pl.DataFrame]:
501
+ """快速導出回測結果(一鍵完成所有步驟)
502
+
503
+ 創建任務 → 等待完成 → 下載 ZIP → 解壓 → 讀取
504
+
505
+ Args:
506
+ strategy_ids: 策略 ID 列表
507
+ start_date: 起始日期 (ISO 8601)
508
+ end_date: 終止日期 (ISO 8601)
509
+ save_zip_path: ZIP 保存路徑(可選)
510
+ save_trades_path: Trades Parquet 保存路徑(可選)
511
+ save_perf_path: Performance Parquet 保存路徑(可選)
512
+
513
+ Returns:
514
+ (trades_df, performance_df): 兩個 Polars DataFrame
515
+
516
+ Example:
517
+ >>> client = TradeposeClient()
518
+ >>> trades_df, perf_df = client.quick_backtest_results(
519
+ ... strategy_ids=["txf_1h_sma30_50", "txf_1d_ema10_20"],
520
+ ... save_zip_path="./results.zip"
521
+ ... )
522
+ """
523
+ # 創建任務
524
+ print(f"🚀 創建 backtest-results 導出任務...")
525
+ print(f" 策略列表: {strategy_ids}")
526
+
527
+ task_id = self.create_export(
528
+ strategy_name=strategy_ids[0] if strategy_ids else "",
529
+ blueprint_name="", # backtest-results 不需要 blueprint
530
+ export_type="backtest-results",
531
+ start_date=start_date,
532
+ end_date=end_date,
533
+ strategy_ids=strategy_ids,
534
+ )
535
+ print(f" Task ID: {task_id}")
536
+
537
+ # 等待完成
538
+ print(f"⏳ 等待任務完成...")
539
+ self.wait_for_export(task_id, verbose=True)
540
+
541
+ # 下載並讀取
542
+ return self.download_and_read_backtest_results(
543
+ task_id=task_id,
544
+ save_zip_path=save_zip_path,
545
+ save_trades_path=save_trades_path,
546
+ save_perf_path=save_perf_path,
547
+ )
548
+
549
+ def quick_latest_trades(
550
+ self,
551
+ strategy_ids: List[str],
552
+ start_date: Optional[str] = None,
553
+ end_date: Optional[str] = None,
554
+ save_path: Optional[str] = None,
555
+ ) -> pl.DataFrame:
556
+ """快速導出最新交易(一鍵完成所有步驟)
557
+
558
+ 創建任務 → 等待完成 → 下載 Parquet → 讀取
559
+
560
+ Args:
561
+ strategy_ids: 策略 ID 列表
562
+ start_date: 起始日期 (ISO 8601)
563
+ end_date: 終止日期 (ISO 8601)
564
+ save_path: Parquet 保存路徑(可選)
565
+
566
+ Returns:
567
+ Polars DataFrame
568
+
569
+ Example:
570
+ >>> client = TradeposeClient()
571
+ >>> df = client.quick_latest_trades(
572
+ ... strategy_ids=["txf_1h_sma30_50"],
573
+ ... save_path="./latest_trades.parquet"
574
+ ... )
575
+ """
576
+ # 創建任務
577
+ print(f"🚀 創建 latest-trades 導出任務...")
578
+ print(f" 策略列表: {strategy_ids}")
579
+
580
+ task_id = self.create_export(
581
+ strategy_name=strategy_ids[0] if strategy_ids else "",
582
+ blueprint_name="", # latest-trades 不需要 blueprint
583
+ export_type="latest-trades",
584
+ start_date=start_date,
585
+ end_date=end_date,
586
+ strategy_ids=strategy_ids,
587
+ )
588
+ print(f" Task ID: {task_id}")
589
+
590
+ # 等待完成
591
+ print(f"⏳ 等待任務完成...")
592
+ self.wait_for_export(task_id, verbose=True)
593
+
594
+ # 下載並讀取
595
+ return self.download_and_read_latest_trades(
596
+ task_id=task_id, save_path=save_path
597
+ )
598
+
599
+ def quick_enhanced_ohlcv(
600
+ self,
601
+ strategy_name: str,
602
+ blueprint_name: Optional[str] = None,
603
+ start_date: str = "2020-01-01T00:00:00",
604
+ end_date: Optional[str] = None,
605
+ save_path: Optional[str] = None,
606
+ ) -> pl.DataFrame:
607
+ """快速導出 enhanced-ohlcv(一鍵完成所有步驟)
608
+
609
+ 創建任務 → 等待完成 → 下載 Parquet → 讀取
610
+
611
+ Args:
612
+ strategy_name: 策略名稱
613
+ blueprint_name: 藍圖名稱(如果為 None,自動獲取)
614
+ start_date: 起始日期 (ISO 8601)
615
+ end_date: 終止日期 (ISO 8601)
616
+ save_path: Parquet 保存路徑(可選)
617
+
618
+ Returns:
619
+ Polars DataFrame
620
+
621
+ Example:
622
+ >>> client = TradeposeClient()
623
+ >>> df = client.quick_enhanced_ohlcv(
624
+ ... strategy_name="txf_1h_sma30_50",
625
+ ... blueprint_name="txf_base_trend",
626
+ ... save_path="./enhanced_ohlcv.parquet"
627
+ ... )
628
+ """
629
+ # 獲取 blueprint 名稱
630
+ if blueprint_name is None:
631
+ strategy_detail = self.get_strategy_detail(strategy_name)
632
+ blueprint_name = strategy_detail["base_blueprint"]["name"]
633
+ print(f"📋 使用 blueprint: {blueprint_name}")
634
+
635
+ # 創建任務
636
+ print(f"🚀 創建 enhanced-ohlcv 導出任務...")
637
+ print(f" 策略名稱: {strategy_name}")
638
+ print(f" Blueprint: {blueprint_name}")
639
+
640
+ task_id = self.create_export(
641
+ strategy_name=strategy_name,
642
+ blueprint_name=blueprint_name,
643
+ export_type="enhanced-ohlcv",
644
+ start_date=start_date,
645
+ end_date=end_date,
646
+ )
647
+ print(f" Task ID: {task_id}")
648
+
649
+ # 等待完成
650
+ print(f"⏳ 等待任務完成...")
651
+ self.wait_for_export(task_id, verbose=True)
652
+
653
+ # 下載並讀取
654
+ return self.download_and_read_enhanced_ohlcv(
655
+ task_id=task_id, save_path=save_path
656
+ )
657
+
658
+ def create_on_demand_ohlcv_export(
659
+ self,
660
+ base_instrument: str,
661
+ base_freq: str,
662
+ indicator_specs: List[Dict[str, Any]],
663
+ start_date: Optional[str] = None,
664
+ end_date: Optional[str] = None,
665
+ ) -> str:
666
+ """創建 on-demand-ohlcv 導出任務
667
+
668
+ 特點:無需預先註冊策略,直接指定指標規格即可導出
669
+
670
+ Args:
671
+ base_instrument: 基礎商品 ID (例如 "TXFR1", "TXF_M1_SHIOAJI_FUTURE")
672
+ base_freq: 基礎頻率 ("1min", "5min", "1h", "1D" 等)
673
+ indicator_specs: 指標規格列表 (IndicatorSpec JSON 數組)
674
+ start_date: 開始時間 (ISO 8601 格式, 例如 "2025-01-01T00:00:00")
675
+ end_date: 結束時間 (ISO 8601 格式)
676
+
677
+ Returns:
678
+ Export task ID
679
+
680
+ Example:
681
+ >>> # 方式 1: 使用 dict
682
+ >>> specs = [
683
+ ... {
684
+ ... "freq": "1h",
685
+ ... "shift": 1,
686
+ ... "indicator": {"type": "SMA", "period": 20, "column": "close"}
687
+ ... },
688
+ ... {
689
+ ... "freq": "1h",
690
+ ... "shift": 1,
691
+ ... "indicator": {"type": "ATR", "period": 14}
692
+ ... }
693
+ ... ]
694
+ >>> task_id = client.create_on_demand_ohlcv_export(
695
+ ... base_instrument="TXFR1",
696
+ ... base_freq="1min",
697
+ ... indicator_specs=specs
698
+ ... )
699
+
700
+ >>> # 方式 2: 使用 IndicatorSpec.model_dump()
701
+ >>> from tradepose_client.models import create_indicator_spec, Indicator, Freq
702
+ >>>
703
+ >>> sma_spec = create_indicator_spec(
704
+ ... freq=Freq.HOUR_1,
705
+ ... indicator=Indicator.sma(period=20),
706
+ ... shift=1
707
+ ... )
708
+ >>> atr_spec = create_indicator_spec(
709
+ ... freq=Freq.HOUR_1,
710
+ ... indicator=Indicator.atr(period=14),
711
+ ... shift=1
712
+ ... )
713
+ >>>
714
+ >>> task_id = client.create_on_demand_ohlcv_export(
715
+ ... base_instrument="TXFR1",
716
+ ... base_freq="1min",
717
+ ... indicator_specs=[
718
+ ... sma_spec.model_dump(exclude_none=True),
719
+ ... atr_spec.model_dump(exclude_none=True)
720
+ ... ]
721
+ ... )
722
+ """
723
+ payload = {
724
+ "base_instrument": base_instrument,
725
+ "base_freq": base_freq,
726
+ "indicator_specs": indicator_specs,
727
+ }
728
+
729
+ if start_date:
730
+ payload["start_date"] = start_date
731
+ if end_date:
732
+ payload["end_date"] = end_date
733
+
734
+ response = requests.post(
735
+ f"{self.api_url}/api/v1/backtest/export/on-demand-ohlcv",
736
+ json=payload,
737
+ headers=self._get_headers(),
738
+ )
739
+ response.raise_for_status()
740
+
741
+ result = response.json()
742
+ return result["export_task_id"]
743
+
744
+ def quick_on_demand_ohlcv(
745
+ self,
746
+ base_instrument: str,
747
+ base_freq: str,
748
+ indicator_specs: List[Dict[str, Any]],
749
+ start_date: Optional[str] = None,
750
+ end_date: Optional[str] = None,
751
+ save_path: Optional[str] = None,
752
+ ) -> pl.DataFrame:
753
+ """快速導出 on-demand-ohlcv(一鍵完成所有步驟)
754
+
755
+ 創建任務 → 等待完成 → 下載 Parquet → 讀取
756
+
757
+ 特點:無需預先註冊策略,直接指定指標規格即可導出
758
+
759
+ Args:
760
+ base_instrument: 基礎商品 ID
761
+ base_freq: 基礎頻率
762
+ indicator_specs: 指標規格列表
763
+ start_date: 開始時間 (ISO 8601)
764
+ end_date: 結束時間 (ISO 8601)
765
+ save_path: Parquet 保存路徑(可選)
766
+
767
+ Returns:
768
+ Polars DataFrame
769
+
770
+ Example:
771
+ >>> from tradepose_client import TradeposeClient
772
+ >>> from tradepose_client.models import create_indicator_spec, Indicator, Freq
773
+ >>>
774
+ >>> client = TradeposeClient(
775
+ ... api_url="http://localhost:8080",
776
+ ... api_token="your_jwt_token"
777
+ ... )
778
+ >>>
779
+ >>> # 定義指標
780
+ >>> sma_20 = create_indicator_spec(Freq.HOUR_1, Indicator.sma(20), shift=1)
781
+ >>> ema_50 = create_indicator_spec(Freq.HOUR_1, Indicator.ema(50), shift=1)
782
+ >>> atr_14 = create_indicator_spec(Freq.HOUR_1, Indicator.atr(14), shift=1)
783
+ >>>
784
+ >>> # 快速導出(無需註冊策略)
785
+ >>> df = client.quick_on_demand_ohlcv(
786
+ ... base_instrument="TXFR1",
787
+ ... base_freq="1min",
788
+ ... indicator_specs=[
789
+ ... sma_20.model_dump(exclude_none=True),
790
+ ... ema_50.model_dump(exclude_none=True),
791
+ ... atr_14.model_dump(exclude_none=True)
792
+ ... ],
793
+ ... start_date="2025-01-01T00:00:00",
794
+ ... end_date="2025-01-02T23:59:59",
795
+ ... save_path="./on_demand_data.parquet"
796
+ ... )
797
+ >>>
798
+ >>> print(df.columns)
799
+ >>> # ['ts', 'open', 'high', 'low', 'close', 'volume',
800
+ >>> # '1h_SMA|20.close', '1h_EMA|50.close', '1h_ATR|14']
801
+ """
802
+ # 創建任務
803
+ print(f"🚀 創建 on-demand-ohlcv 導出任務...")
804
+ print(f" 商品: {base_instrument}")
805
+ print(f" 頻率: {base_freq}")
806
+ print(f" 指標數: {len(indicator_specs)}")
807
+
808
+ task_id = self.create_on_demand_ohlcv_export(
809
+ base_instrument=base_instrument,
810
+ base_freq=base_freq,
811
+ indicator_specs=indicator_specs,
812
+ start_date=start_date,
813
+ end_date=end_date,
814
+ )
815
+ print(f" Task ID: {task_id}")
816
+
817
+ # 等待完成
818
+ print(f"⏳ 等待任務完成...")
819
+ self.wait_for_export(task_id, verbose=True)
820
+
821
+ # 下載並讀取
822
+ print(f"📥 下載並讀取數據...")
823
+ file_or_buffer = self.download_parquet(task_id, output_path=save_path)
824
+ df = pl.read_parquet(file_or_buffer)
825
+
826
+ print(f"✅ 完成: {len(df):,} 行 × {len(df.columns)} 列")
827
+
828
+ return df