moomoo-api-mcp 0.1.6__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.
@@ -0,0 +1,691 @@
1
+ """Trade service for managing Moomoo trading context and account operations."""
2
+
3
+ from moomoo import OpenSecTradeContext, OrderStatus, RET_OK, SecurityFirm, TrdMarket
4
+
5
+
6
+ class TradeService:
7
+ """Service to manage Moomoo Trade API connections and account operations."""
8
+
9
+ def __init__(
10
+ self,
11
+ host: str = "127.0.0.1",
12
+ port: int = 11111,
13
+ security_firm: str | None = None,
14
+ ):
15
+ """Initialize TradeService.
16
+
17
+ Args:
18
+ host: Host address of OpenD gateway.
19
+ port: Port number of OpenD gateway.
20
+ security_firm: Securities firm identifier (e.g., 'FUTUSG' for Singapore,
21
+ 'FUTUSECURITIES' for HK). If None, no filter is applied.
22
+ """
23
+ self.host = host
24
+ self.port = port
25
+ self.security_firm = security_firm
26
+ self.trade_ctx: OpenSecTradeContext | None = None
27
+
28
+ def _convert_status_filter(
29
+ self, status_filter_list: list[str] | None
30
+ ) -> list[OrderStatus]:
31
+ """Convert string status values to OrderStatus enum values.
32
+
33
+ The Moomoo SDK expects OrderStatus enum values, not strings.
34
+ This method converts user-provided string values to the proper enum format.
35
+
36
+ Args:
37
+ status_filter_list: List of status strings like ['SUBMITTED', 'FILLED_ALL'].
38
+
39
+ Returns:
40
+ List of OrderStatus enum values. Returns an empty list if input is None.
41
+
42
+ Raises:
43
+ ValueError: If an invalid status string is provided.
44
+ """
45
+ if status_filter_list is None:
46
+ return []
47
+
48
+ converted = []
49
+ for status_str in status_filter_list:
50
+ # OrderStatus has the attribute matching the string (e.g., OrderStatus.SUBMITTED)
51
+ status_enum = getattr(OrderStatus, status_str.upper(), None)
52
+ if status_enum is None:
53
+ valid_statuses = [
54
+ "UNSUBMITTED", "WAITING_SUBMIT", "SUBMITTING", "SUBMIT_FAILED",
55
+ "SUBMITTED", "FILLED_PART", "FILLED_ALL",
56
+ "CANCELLING_PART", "CANCELLING_ALL", "CANCELLED_PART", "CANCELLED_ALL",
57
+ "REJECTED", "DISABLED", "DELETED", "FAILED", "NONE"
58
+ ]
59
+ raise ValueError(
60
+ f"Invalid order status: '{status_str}'. "
61
+ f"Valid options: {valid_statuses}"
62
+ )
63
+ converted.append(status_enum)
64
+ return converted
65
+
66
+ def _get_market_from_code(self, code: str) -> str | None:
67
+ """Extract market from stock code (e.g., 'JP' from 'JP.8058')."""
68
+ if "." in code:
69
+ return code.split(".")[0].upper()
70
+ return None
71
+
72
+ def _find_best_account(self, trd_env: str, market: str) -> int:
73
+ """Find the best account for the given environment and market.
74
+
75
+ Args:
76
+ trd_env: Trading environment ('REAL' or 'SIMULATE').
77
+ market: Target market (e.g., 'JP', 'US', 'HK').
78
+
79
+ Returns:
80
+ Account ID if found, otherwise 0 (default).
81
+
82
+ Raises:
83
+ ValueError: If no suitable account is found.
84
+ """
85
+ try:
86
+ accounts = self.get_accounts()
87
+ except Exception as e:
88
+ # Re-raise as a ValueError to ensure the caller knows account finding failed.
89
+ raise ValueError("Failed to retrieve account list from the API.") from e
90
+
91
+ # Filter by environment
92
+ env_accounts = [acc for acc in accounts if acc.get("trd_env") == trd_env]
93
+
94
+ if not env_accounts:
95
+ # Raise an error if no accounts are found for the environment.
96
+ raise ValueError(f"No accounts found for the '{trd_env}' environment.")
97
+
98
+ # Moomoo market codes mapping to market_auth strings
99
+ # Adjust as needed based on actual API values
100
+ target_market = market.upper()
101
+
102
+ supported_markets = []
103
+
104
+ for acc in env_accounts:
105
+ # Check market_auth which is a list like ['HK', 'US']
106
+ # Note: The field name might be 'trdmarket_auth' based on debug output
107
+ market_auth = acc.get("market_auth") or acc.get("trdmarket_auth") or []
108
+ supported_markets.extend(market_auth)
109
+
110
+ if target_market in market_auth:
111
+ return acc["acc_id"]
112
+
113
+ # If we are here, we found accounts for the env, but none support the market
114
+ unique_supported = sorted(list(set(supported_markets)))
115
+ raise ValueError(
116
+ f"No account found in {trd_env} environment that supports trading in {market}. "
117
+ f"Available accounts support: {unique_supported}"
118
+ )
119
+
120
+ def connect(self) -> None:
121
+ """Initialize connection to OpenD trade context."""
122
+ # Build kwargs for OpenSecTradeContext
123
+ kwargs = {"host": self.host, "port": self.port}
124
+
125
+ # Add security_firm if specified
126
+ if self.security_firm:
127
+ # Convert string to SecurityFirm enum
128
+ firm_enum = getattr(SecurityFirm, self.security_firm, None)
129
+ if firm_enum:
130
+ kwargs["security_firm"] = firm_enum
131
+
132
+ self.trade_ctx = OpenSecTradeContext(**kwargs)
133
+
134
+ def close(self) -> None:
135
+ """Close trade context connection."""
136
+ if self.trade_ctx:
137
+ self.trade_ctx.close()
138
+ self.trade_ctx = None
139
+
140
+ def get_accounts(self) -> list[dict]:
141
+ """Get list of trading accounts.
142
+
143
+ Returns:
144
+ List of account dictionaries with acc_id, trd_env, etc.
145
+ """
146
+ if not self.trade_ctx:
147
+ raise RuntimeError("Trade context not connected")
148
+
149
+ ret, data = self.trade_ctx.get_acc_list()
150
+ if ret != RET_OK:
151
+ raise RuntimeError(f"get_acc_list failed: {data}")
152
+
153
+ return data.to_dict("records")
154
+
155
+ def get_assets(
156
+ self,
157
+ trd_env: str = "SIMULATE",
158
+ acc_id: int = 0,
159
+ refresh_cache: bool = False,
160
+ currency: str | None = None,
161
+ ) -> dict:
162
+ """Get account assets (cash, market value, etc.).
163
+
164
+ Args:
165
+ trd_env: Trading environment, 'REAL' or 'SIMULATE'.
166
+ acc_id: Account ID. Must be obtained from get_accounts().
167
+ refresh_cache: Whether to refresh the cache.
168
+ currency: Filter by currency (e.g., 'HKD', 'USD'). Leave None for default.
169
+
170
+ Returns:
171
+ Dictionary with asset information.
172
+ """
173
+ if not self.trade_ctx:
174
+ raise RuntimeError("Trade context not connected")
175
+
176
+ kwargs = {
177
+ "trd_env": trd_env,
178
+ "acc_id": acc_id,
179
+ "refresh_cache": refresh_cache,
180
+ }
181
+ if currency is not None:
182
+ normalized_currency = currency.strip().upper()
183
+ if normalized_currency:
184
+ kwargs["currency"] = normalized_currency
185
+
186
+ ret, data = self.trade_ctx.accinfo_query(**kwargs)
187
+ if ret != RET_OK:
188
+ raise RuntimeError(f"accinfo_query failed: {data}")
189
+
190
+ records = data.to_dict("records")
191
+ return records[0] if records else {}
192
+
193
+ def get_positions(
194
+ self,
195
+ code: str = "",
196
+ market: str = "",
197
+ pl_ratio_min: float | None = None,
198
+ pl_ratio_max: float | None = None,
199
+ trd_env: str = "SIMULATE",
200
+ acc_id: int = 0,
201
+ refresh_cache: bool = False,
202
+ ) -> list[dict]:
203
+ """Get current positions.
204
+
205
+ Args:
206
+ code: Filter by stock code.
207
+ market: Filter by market (e.g., 'US', 'HK', 'CN', 'SG', 'JP').
208
+ pl_ratio_min: Minimum profit/loss ratio filter.
209
+ pl_ratio_max: Maximum profit/loss ratio filter.
210
+ trd_env: Trading environment.
211
+ acc_id: Account ID. Must be obtained from get_accounts().
212
+ refresh_cache: Whether to refresh cache.
213
+
214
+ Returns:
215
+ List of position dictionaries.
216
+ """
217
+ if not self.trade_ctx:
218
+ raise RuntimeError("Trade context not connected")
219
+
220
+ # Map market string to TrdMarket enum
221
+ # Note: CN is for A-share simulation only; HKCC for Stock Connect (live only)
222
+ market_map = {
223
+ "US": TrdMarket.US,
224
+ "HK": TrdMarket.HK,
225
+ "CN": TrdMarket.CN,
226
+ "HKCC": TrdMarket.HKCC,
227
+ "SG": TrdMarket.SG,
228
+ "JP": TrdMarket.JP,
229
+ }
230
+ position_market = TrdMarket.NONE
231
+ if market:
232
+ try:
233
+ # Try direct map first
234
+ position_market = market_map.get(market.upper())
235
+ if position_market is None:
236
+ # Try to use getattr for other potential values
237
+ position_market = getattr(TrdMarket, market.upper())
238
+ except AttributeError:
239
+ position_market = TrdMarket.NONE
240
+
241
+ ret, data = self.trade_ctx.position_list_query(
242
+ code=code,
243
+ position_market=position_market,
244
+ pl_ratio_min=pl_ratio_min,
245
+ pl_ratio_max=pl_ratio_max,
246
+ trd_env=trd_env,
247
+ acc_id=acc_id,
248
+ refresh_cache=refresh_cache,
249
+ )
250
+ if ret != RET_OK:
251
+ raise RuntimeError(f"position_list_query failed: {data}")
252
+
253
+ return data.to_dict("records")
254
+
255
+ def get_max_tradable(
256
+ self,
257
+ order_type: str,
258
+ code: str,
259
+ price: float,
260
+ order_id: str = "",
261
+ adjust_limit: float = 0,
262
+ trd_env: str = "SIMULATE",
263
+ acc_id: int = 0,
264
+ ) -> dict:
265
+ """Get maximum tradable quantity for a stock.
266
+
267
+ Args:
268
+ order_type: Order type string (e.g., 'NORMAL').
269
+ code: Stock code.
270
+ price: Target price.
271
+ order_id: Optional order ID for modification.
272
+ adjust_limit: Adjust limit percentage.
273
+ trd_env: Trading environment.
274
+ acc_id: Account ID. Must be obtained from get_accounts().
275
+
276
+ Returns:
277
+ Dictionary with max quantities for buy/sell.
278
+ """
279
+ if not self.trade_ctx:
280
+ raise RuntimeError("Trade context not connected")
281
+
282
+ ret, data = self.trade_ctx.acctradinginfo_query(
283
+ order_type=order_type,
284
+ code=code,
285
+ price=price,
286
+ order_id=order_id,
287
+ adjust_limit=adjust_limit,
288
+ trd_env=trd_env,
289
+ acc_id=acc_id,
290
+ )
291
+ if ret != RET_OK:
292
+ raise RuntimeError(f"acctradinginfo_query failed: {data}")
293
+
294
+ records = data.to_dict("records")
295
+ return records[0] if records else {}
296
+
297
+ def get_margin_ratio(self, code_list: list[str]) -> list[dict]:
298
+ """Get margin ratio for stocks.
299
+
300
+ Args:
301
+ code_list: List of stock codes.
302
+
303
+ Returns:
304
+ List of margin ratio dictionaries.
305
+ """
306
+ if not self.trade_ctx:
307
+ raise RuntimeError("Trade context not connected")
308
+
309
+ ret, data = self.trade_ctx.get_margin_ratio(code_list=code_list)
310
+ if ret != RET_OK:
311
+ raise RuntimeError(f"get_margin_ratio failed: {data}")
312
+
313
+ return data.to_dict("records")
314
+
315
+ def get_cash_flow(
316
+ self,
317
+ clearing_date: str = "",
318
+ trd_env: str = "SIMULATE",
319
+ acc_id: int = 0,
320
+ ) -> list[dict]:
321
+ """Get account cash flow history.
322
+
323
+ Args:
324
+ clearing_date: Filter by clearing date (YYYY-MM-DD).
325
+ trd_env: Trading environment.
326
+ acc_id: Account ID. Must be obtained from get_accounts().
327
+
328
+ Returns:
329
+ List of cash flow record dictionaries.
330
+ """
331
+ if not self.trade_ctx:
332
+ raise RuntimeError("Trade context not connected")
333
+
334
+ ret, data = self.trade_ctx.get_acc_cash_flow(
335
+ clearing_date=clearing_date,
336
+ trd_env=trd_env,
337
+ acc_id=acc_id,
338
+ )
339
+ if ret != RET_OK:
340
+ raise RuntimeError(f"get_acc_cash_flow failed: {data}")
341
+
342
+ return data.to_dict("records")
343
+
344
+ def unlock_trade(
345
+ self, password: str | None = None, password_md5: str | None = None
346
+ ) -> None:
347
+ """Unlock trade for trading operations.
348
+
349
+ Args:
350
+ password: Plain text trade password.
351
+ password_md5: MD5 hash of trade password (alternative to password).
352
+
353
+ Raises:
354
+ RuntimeError: If unlock fails.
355
+ """
356
+ if not self.trade_ctx:
357
+ raise RuntimeError("Trade context not connected")
358
+
359
+ ret, data = self.trade_ctx.unlock_trade(
360
+ password=password,
361
+ password_md5=password_md5,
362
+ is_unlock=True,
363
+ )
364
+ if ret != RET_OK:
365
+ raise RuntimeError(f"unlock_trade failed: {data}")
366
+
367
+ def place_order(
368
+ self,
369
+ code: str,
370
+ price: float,
371
+ qty: int,
372
+ trd_side: str,
373
+ order_type: str = "NORMAL",
374
+ time_in_force: str = "DAY",
375
+ adjust_limit: float = 0,
376
+ aux_price: float | None = None,
377
+ trail_type: str | None = None,
378
+ trail_value: float | None = None,
379
+ trail_spread: float | None = None,
380
+ trd_env: str = "SIMULATE",
381
+ acc_id: int = 0,
382
+ remark: str = "",
383
+ ) -> dict:
384
+ """Place a new trading order.
385
+
386
+ Args:
387
+ code: Stock code (e.g., 'US.AAPL').
388
+ price: Order price.
389
+ qty: Order quantity.
390
+ trd_side: Trade side ('BUY' or 'SELL').
391
+ order_type: Order type ('NORMAL', 'MARKET', etc.).
392
+ time_in_force: Time in force ('DAY' or 'GTC'). Defaults to 'DAY'.
393
+ adjust_limit: Adjust limit percentage.
394
+ aux_price: Trigger price for stop/if-touched order types.
395
+ trail_type: Trailing type ('RATIO' or 'AMOUNT') for trailing stop types.
396
+ trail_value: Trailing value (ratio or amount) for trailing stop types.
397
+ trail_spread: Optional trailing spread for trailing stop limit types.
398
+ trd_env: Trading environment ('REAL' or 'SIMULATE').
399
+ acc_id: Account ID. Must be obtained from get_accounts().
400
+ remark: Order remark/note.
401
+
402
+ Returns:
403
+ Dictionary with order details including order_id.
404
+ """
405
+ if not self.trade_ctx:
406
+ raise RuntimeError("Trade context not connected")
407
+
408
+ # Smart account selection if acc_id is default (0)
409
+ if acc_id == 0:
410
+ market = self._get_market_from_code(code)
411
+ if market:
412
+ # Try to find a specific account for this market
413
+ # If valid account found, use it.
414
+ # If none found that support the market, it will raise ValueError
415
+ acc_id = self._find_best_account(trd_env, market)
416
+
417
+ stop_order_types = {
418
+ "STOP",
419
+ "STOP_LIMIT",
420
+ "MARKET_IF_TOUCHED",
421
+ "LIMIT_IF_TOUCHED",
422
+ }
423
+ trailing_order_types = {"TRAILING_STOP", "TRAILING_STOP_LIMIT"}
424
+ if order_type in stop_order_types and aux_price is None:
425
+ raise ValueError("aux_price is required for stop/if-touched order types")
426
+ if order_type in trailing_order_types and (
427
+ trail_type is None or trail_value is None
428
+ ):
429
+ raise ValueError(
430
+ "trail_type and trail_value are required for trailing stop order types"
431
+ )
432
+
433
+ ret, data = self.trade_ctx.place_order(
434
+ price=price,
435
+ qty=qty,
436
+ code=code,
437
+ trd_side=trd_side,
438
+ order_type=order_type,
439
+ time_in_force=time_in_force,
440
+ adjust_limit=adjust_limit,
441
+ aux_price=aux_price,
442
+ trail_type=trail_type,
443
+ trail_value=trail_value,
444
+ trail_spread=trail_spread,
445
+ trd_env=trd_env,
446
+ acc_id=acc_id,
447
+ remark=remark,
448
+ )
449
+ if ret != RET_OK:
450
+ raise RuntimeError(f"place_order failed: {data}")
451
+
452
+ records = data.to_dict("records")
453
+ return records[0] if records else {}
454
+
455
+ def modify_order(
456
+ self,
457
+ order_id: str,
458
+ modify_order_op: str,
459
+ qty: int | None = None,
460
+ price: float | None = None,
461
+ adjust_limit: float = 0,
462
+ trd_env: str = "SIMULATE",
463
+ acc_id: int = 0,
464
+ ) -> dict:
465
+ """Modify an existing order.
466
+
467
+ Args:
468
+ order_id: Order ID to modify.
469
+ modify_order_op: Modification operation ('NORMAL', 'CANCEL', 'DISABLE', 'ENABLE', 'DELETE').
470
+ qty: New quantity (optional).
471
+ price: New price (optional).
472
+ adjust_limit: Adjust limit percentage.
473
+ trd_env: Trading environment.
474
+ acc_id: Account ID.
475
+
476
+ Returns:
477
+ Dictionary with modified order details.
478
+ """
479
+ if not self.trade_ctx:
480
+ raise RuntimeError("Trade context not connected")
481
+
482
+ ret, data = self.trade_ctx.modify_order(
483
+ modify_order_op=modify_order_op,
484
+ order_id=order_id,
485
+ qty=qty,
486
+ price=price,
487
+ adjust_limit=adjust_limit,
488
+ trd_env=trd_env,
489
+ acc_id=acc_id,
490
+ )
491
+ if ret != RET_OK:
492
+ raise RuntimeError(f"modify_order failed: {data}")
493
+
494
+ records = data.to_dict("records")
495
+ return records[0] if records else {}
496
+
497
+ def cancel_order(
498
+ self,
499
+ order_id: str,
500
+ trd_env: str = "SIMULATE",
501
+ acc_id: int = 0,
502
+ ) -> dict:
503
+ """Cancel an existing order.
504
+
505
+ Convenience wrapper around modify_order with CANCEL operation.
506
+
507
+ Args:
508
+ order_id: Order ID to cancel.
509
+ trd_env: Trading environment.
510
+ acc_id: Account ID.
511
+
512
+ Returns:
513
+ Dictionary with cancelled order details.
514
+ """
515
+ if not self.trade_ctx:
516
+ raise RuntimeError("Trade context not connected")
517
+
518
+ ret, data = self.trade_ctx.modify_order(
519
+ modify_order_op="CANCEL",
520
+ order_id=order_id,
521
+ qty=0,
522
+ price=0,
523
+ adjust_limit=0,
524
+ trd_env=trd_env,
525
+ acc_id=acc_id,
526
+ )
527
+ if ret != RET_OK:
528
+ raise RuntimeError(f"cancel_order failed: {data}")
529
+
530
+ records = data.to_dict("records")
531
+ return records[0] if records else {}
532
+
533
+ def get_orders(
534
+ self,
535
+ code: str = "",
536
+ status_filter_list: list[str] | None = None,
537
+ trd_env: str = "SIMULATE",
538
+ acc_id: int = 0,
539
+ refresh_cache: bool = False,
540
+ ) -> list[dict]:
541
+ """Get list of today's orders.
542
+
543
+ Args:
544
+ code: Filter by stock code.
545
+ status_filter_list: Filter by order statuses (as strings).
546
+ Valid options: UNSUBMITTED, WAITING_SUBMIT, SUBMITTING, SUBMIT_FAILED,
547
+ SUBMITTED, FILLED_PART, FILLED_ALL, CANCELLING_PART, CANCELLING_ALL,
548
+ CANCELLED_PART, CANCELLED_ALL, REJECTED, DISABLED, DELETED, FAILED, NONE.
549
+ trd_env: Trading environment.
550
+ acc_id: Account ID.
551
+ refresh_cache: Whether to refresh cache.
552
+
553
+ Returns:
554
+ List of order dictionaries. Returns empty list if no orders found.
555
+ """
556
+ if not self.trade_ctx:
557
+ raise RuntimeError("Trade context not connected")
558
+
559
+ # Convert string status values to OrderStatus enum values
560
+ converted_status_filter = self._convert_status_filter(status_filter_list)
561
+
562
+ ret, data = self.trade_ctx.order_list_query(
563
+ code=code,
564
+ status_filter_list=converted_status_filter,
565
+ trd_env=trd_env,
566
+ acc_id=acc_id,
567
+ refresh_cache=refresh_cache,
568
+ )
569
+ if ret != RET_OK:
570
+ raise RuntimeError(f"order_list_query failed: {data}")
571
+
572
+ # Handle None or empty DataFrame gracefully
573
+ if data is None or data.empty:
574
+ return []
575
+
576
+ return data.to_dict("records")
577
+
578
+ def get_deals(
579
+ self,
580
+ code: str = "",
581
+ trd_env: str = "SIMULATE",
582
+ acc_id: int = 0,
583
+ refresh_cache: bool = False,
584
+ ) -> list[dict]:
585
+ """Get list of today's deals (executed trades).
586
+
587
+ Args:
588
+ code: Filter by stock code.
589
+ trd_env: Trading environment.
590
+ acc_id: Account ID.
591
+ refresh_cache: Whether to refresh cache.
592
+
593
+ Returns:
594
+ List of deal dictionaries.
595
+ """
596
+ if not self.trade_ctx:
597
+ raise RuntimeError("Trade context not connected")
598
+
599
+ ret, data = self.trade_ctx.deal_list_query(
600
+ code=code,
601
+ trd_env=trd_env,
602
+ acc_id=acc_id,
603
+ refresh_cache=refresh_cache,
604
+ )
605
+ if ret != RET_OK:
606
+ raise RuntimeError(f"deal_list_query failed: {data}")
607
+
608
+ return data.to_dict("records")
609
+
610
+ def get_history_orders(
611
+ self,
612
+ code: str = "",
613
+ status_filter_list: list[str] | None = None,
614
+ start: str = "",
615
+ end: str = "",
616
+ trd_env: str = "SIMULATE",
617
+ acc_id: int = 0,
618
+ ) -> list[dict]:
619
+ """Get historical orders.
620
+
621
+ Args:
622
+ code: Filter by stock code.
623
+ status_filter_list: Filter by order statuses (as strings).
624
+ Valid options: UNSUBMITTED, WAITING_SUBMIT, SUBMITTING, SUBMIT_FAILED,
625
+ SUBMITTED, FILLED_PART, FILLED_ALL, CANCELLING_PART, CANCELLING_ALL,
626
+ CANCELLED_PART, CANCELLED_ALL, REJECTED, DISABLED, DELETED, FAILED, NONE.
627
+ start: Start date (YYYY-MM-DD).
628
+ end: End date (YYYY-MM-DD).
629
+ trd_env: Trading environment.
630
+ acc_id: Account ID.
631
+
632
+ Returns:
633
+ List of historical order dictionaries. Returns empty list if no orders found.
634
+ """
635
+ if not self.trade_ctx:
636
+ raise RuntimeError("Trade context not connected")
637
+
638
+ # Convert string status values to OrderStatus enum values
639
+ converted_status_filter = self._convert_status_filter(status_filter_list)
640
+
641
+ ret, data = self.trade_ctx.history_order_list_query(
642
+ code=code,
643
+ status_filter_list=converted_status_filter,
644
+ start=start,
645
+ end=end,
646
+ trd_env=trd_env,
647
+ acc_id=acc_id,
648
+ )
649
+ if ret != RET_OK:
650
+ raise RuntimeError(f"history_order_list_query failed: {data}")
651
+
652
+ # Handle None or empty DataFrame gracefully
653
+ if data is None or data.empty:
654
+ return []
655
+
656
+ return data.to_dict("records")
657
+
658
+ def get_history_deals(
659
+ self,
660
+ code: str = "",
661
+ start: str = "",
662
+ end: str = "",
663
+ trd_env: str = "SIMULATE",
664
+ acc_id: int = 0,
665
+ ) -> list[dict]:
666
+ """Get historical deals (executed trades).
667
+
668
+ Args:
669
+ code: Filter by stock code.
670
+ start: Start date (YYYY-MM-DD).
671
+ end: End date (YYYY-MM-DD).
672
+ trd_env: Trading environment.
673
+ acc_id: Account ID.
674
+
675
+ Returns:
676
+ List of historical deal dictionaries.
677
+ """
678
+ if not self.trade_ctx:
679
+ raise RuntimeError("Trade context not connected")
680
+
681
+ ret, data = self.trade_ctx.history_deal_list_query(
682
+ code=code,
683
+ start=start,
684
+ end=end,
685
+ trd_env=trd_env,
686
+ acc_id=acc_id,
687
+ )
688
+ if ret != RET_OK:
689
+ raise RuntimeError(f"history_deal_list_query failed: {data}")
690
+
691
+ return data.to_dict("records")
File without changes