futu-stock-mcp-server 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 futu-stock-mcp-server might be problematic. Click here for more details.

@@ -0,0 +1,1475 @@
1
+ from contextlib import asynccontextmanager
2
+ from collections.abc import AsyncIterator
3
+ from typing import Dict, Any, List, Optional
4
+ from futu import OpenQuoteContext, OpenSecTradeContext, TrdMarket, SecurityFirm, RET_OK
5
+ import json
6
+ import asyncio
7
+ from loguru import logger
8
+ import os
9
+ import sys
10
+ from dotenv import load_dotenv
11
+ from mcp.server.fastmcp import FastMCP
12
+ from mcp.types import TextContent, PromptMessage
13
+ from mcp.server import Server
14
+ import atexit
15
+ import signal
16
+ import fcntl
17
+ import psutil
18
+ import time
19
+ from datetime import datetime
20
+
21
+ # Get the project root directory and add it to Python path
22
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+ sys.path.insert(0, project_root)
24
+
25
+ # Load environment variables from project root
26
+ env_path = os.path.join(project_root, '.env')
27
+ load_dotenv(env_path)
28
+
29
+ # Configure logging
30
+ logger.remove() # Remove default handler
31
+
32
+ # Get the project root directory
33
+ log_dir = os.path.join(project_root, "logs")
34
+ os.makedirs(log_dir, exist_ok=True)
35
+
36
+ # Add file handler
37
+ logger.add(
38
+ os.path.join(log_dir, "futu_server.log"),
39
+ rotation="500 MB",
40
+ retention="10 days",
41
+ level="DEBUG",
42
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
43
+ )
44
+
45
+ # Add console handler
46
+ logger.add(
47
+ lambda msg: print(msg),
48
+ level="INFO",
49
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
50
+ )
51
+
52
+ logger.info(f"Starting server with log directory: {log_dir}")
53
+
54
+ # PID file path
55
+ PID_FILE = os.path.join(project_root, '.futu_mcp.pid')
56
+ LOCK_FILE = os.path.join(project_root, '.futu_mcp.lock')
57
+
58
+ # Global variables
59
+ quote_ctx = None
60
+ trade_ctx = None
61
+ lock_fd = None
62
+ _is_shutting_down = False
63
+ _is_trade_initialized = False
64
+
65
+ def is_process_running(pid):
66
+ """Check if a process with given PID is running"""
67
+ try:
68
+ return psutil.pid_exists(pid)
69
+ except:
70
+ return False
71
+
72
+ def cleanup_stale_processes():
73
+ """Clean up any stale Futu processes"""
74
+ global _is_shutting_down
75
+ if _is_shutting_down:
76
+ return
77
+
78
+ try:
79
+ # 只检查 PID 文件中的进程
80
+ if os.path.exists(PID_FILE):
81
+ try:
82
+ with open(PID_FILE, 'r') as f:
83
+ old_pid = int(f.read().strip())
84
+ if old_pid != os.getpid():
85
+ try:
86
+ old_proc = psutil.Process(old_pid)
87
+ if any('futu_stock_mcp_server' in cmd for cmd in old_proc.cmdline()):
88
+ logger.info(f"Found stale process {old_pid}")
89
+ old_proc.terminate()
90
+ try:
91
+ old_proc.wait(timeout=3)
92
+ except psutil.TimeoutExpired:
93
+ old_proc.kill()
94
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
95
+ pass
96
+ except (IOError, ValueError):
97
+ pass
98
+
99
+ # 清理 PID 文件
100
+ try:
101
+ os.unlink(PID_FILE)
102
+ except OSError:
103
+ pass
104
+
105
+ # 清理锁文件
106
+ if os.path.exists(LOCK_FILE):
107
+ try:
108
+ os.unlink(LOCK_FILE)
109
+ except OSError:
110
+ pass
111
+
112
+ except Exception as e:
113
+ logger.error(f"Error cleaning up stale processes: {str(e)}")
114
+
115
+ def cleanup_connections():
116
+ """Clean up Futu connections"""
117
+ global quote_ctx, trade_ctx
118
+ try:
119
+ if quote_ctx:
120
+ try:
121
+ quote_ctx.close()
122
+ logger.info("Successfully closed quote context")
123
+ except Exception as e:
124
+ logger.error(f"Error closing quote context: {str(e)}")
125
+ quote_ctx = None
126
+
127
+ if trade_ctx:
128
+ try:
129
+ trade_ctx.close()
130
+ logger.info("Successfully closed trade context")
131
+ except Exception as e:
132
+ logger.error(f"Error closing trade context: {str(e)}")
133
+ trade_ctx = None
134
+
135
+ # 等待连接完全关闭
136
+ time.sleep(1)
137
+ except Exception as e:
138
+ logger.error(f"Error during connection cleanup: {str(e)}")
139
+
140
+ def release_lock():
141
+ """Release the process lock"""
142
+ global lock_fd
143
+ try:
144
+ if lock_fd is not None:
145
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
146
+ os.close(lock_fd)
147
+ lock_fd = None
148
+ if os.path.exists(LOCK_FILE):
149
+ os.unlink(LOCK_FILE)
150
+ if os.path.exists(PID_FILE):
151
+ os.unlink(PID_FILE)
152
+ except Exception as e:
153
+ logger.error(f"Error releasing lock: {str(e)}")
154
+
155
+ def cleanup_all():
156
+ """Clean up all resources on exit"""
157
+ global _is_shutting_down
158
+ if _is_shutting_down:
159
+ return
160
+ _is_shutting_down = True
161
+
162
+ cleanup_connections()
163
+ release_lock()
164
+ cleanup_stale_processes()
165
+
166
+ def signal_handler(signum, frame):
167
+ """Handle process signals"""
168
+ global _is_shutting_down
169
+ if _is_shutting_down:
170
+ return
171
+
172
+ # 只处理 SIGINT 和 SIGTERM
173
+ if signum not in (signal.SIGINT, signal.SIGTERM):
174
+ return
175
+
176
+ logger.info(f"Received signal {signum}, cleaning up...")
177
+ cleanup_all()
178
+ sys.exit(0)
179
+
180
+ # Register cleanup functions
181
+ atexit.register(cleanup_all)
182
+ signal.signal(signal.SIGINT, signal_handler)
183
+ signal.signal(signal.SIGTERM, signal_handler)
184
+
185
+ def acquire_lock():
186
+ """Try to acquire the process lock"""
187
+ try:
188
+ # 先检查 PID 文件
189
+ if os.path.exists(PID_FILE):
190
+ try:
191
+ with open(PID_FILE, 'r') as f:
192
+ old_pid = int(f.read().strip())
193
+ if old_pid != os.getpid() and psutil.pid_exists(old_pid):
194
+ try:
195
+ old_proc = psutil.Process(old_pid)
196
+ if any('futu_stock_mcp_server' in cmd for cmd in old_proc.cmdline()):
197
+ logger.error(f"Another instance is already running (PID: {old_pid})")
198
+ return None
199
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
200
+ pass
201
+ except (IOError, ValueError):
202
+ pass
203
+
204
+ # 创建锁文件
205
+ lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR)
206
+ try:
207
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
208
+ except IOError:
209
+ os.close(lock_fd)
210
+ return None
211
+
212
+ # 写入 PID 文件
213
+ with open(PID_FILE, 'w') as f:
214
+ f.write(str(os.getpid()))
215
+
216
+ return lock_fd
217
+ except Exception as e:
218
+ logger.error(f"Failed to acquire lock: {str(e)}")
219
+ if 'lock_fd' in locals():
220
+ try:
221
+ os.close(lock_fd)
222
+ except:
223
+ pass
224
+ return None
225
+
226
+ def init_quote_connection():
227
+ """Initialize quote connection only"""
228
+ global quote_ctx
229
+
230
+ try:
231
+ # Check if OpenD is running by attempting to get global state
232
+ try:
233
+ temp_ctx = OpenQuoteContext(
234
+ host=os.getenv('FUTU_HOST', '127.0.0.1'),
235
+ port=int(os.getenv('FUTU_PORT', '11111'))
236
+ )
237
+ ret, _ = temp_ctx.get_global_state()
238
+ temp_ctx.close()
239
+ if ret != RET_OK:
240
+ logger.error("OpenD is not running or not accessible")
241
+ return False
242
+ except Exception as e:
243
+ logger.error(f"Failed to connect to OpenD: {str(e)}")
244
+ return False
245
+
246
+ # Initialize Futu connection
247
+ quote_ctx = OpenQuoteContext(
248
+ host=os.getenv('FUTU_HOST', '127.0.0.1'),
249
+ port=int(os.getenv('FUTU_PORT', '11111'))
250
+ )
251
+ logger.info("Successfully connected to Futu Quote API")
252
+ return True
253
+
254
+ except Exception as e:
255
+ logger.error(f"Failed to initialize quote connection: {str(e)}")
256
+ cleanup_connections()
257
+ return False
258
+
259
+ def init_trade_connection():
260
+ """Initialize trade connection only"""
261
+ global trade_ctx, _is_trade_initialized
262
+
263
+ if _is_trade_initialized and trade_ctx:
264
+ return True
265
+
266
+ try:
267
+ # Initialize trade context with proper market access
268
+ trade_env = os.getenv('FUTU_TRADE_ENV', 'SIMULATE')
269
+ security_firm = getattr(SecurityFirm, os.getenv('FUTU_SECURITY_FIRM', 'FUTUSECURITIES'))
270
+
271
+ # 只支持港股和美股
272
+ market_map = {
273
+ 'HK': 1, # TrdMarket.HK
274
+ 'US': 2 # TrdMarket.US
275
+ }
276
+ trd_market = market_map.get(os.getenv('FUTU_TRD_MARKET', 'HK'), 1)
277
+
278
+ # 创建交易上下文
279
+ trade_ctx = OpenSecTradeContext(
280
+ filter_trdmarket=trd_market,
281
+ host=os.getenv('FUTU_HOST', '127.0.0.1'),
282
+ port=int(os.getenv('FUTU_PORT', '11111')),
283
+ security_firm=security_firm
284
+ )
285
+
286
+ # 等待连接就绪
287
+ time.sleep(1)
288
+
289
+ # 验证连接状态
290
+ if not trade_ctx:
291
+ raise Exception("Failed to create trade context")
292
+
293
+ # Set trade environment
294
+ if hasattr(trade_ctx, 'set_trade_env'):
295
+ ret, data = trade_ctx.set_trade_env(trade_env)
296
+ if ret != RET_OK:
297
+ logger.warning(f"Failed to set trade environment: {data}")
298
+
299
+ # Verify account access and permissions
300
+ ret, data = trade_ctx.get_acc_list()
301
+ if ret != RET_OK:
302
+ logger.warning(f"Failed to get account list: {data}")
303
+ cleanup_connections()
304
+ return False
305
+
306
+ if data is None or len(data) == 0:
307
+ logger.warning("No trading accounts available")
308
+ cleanup_connections()
309
+ return False
310
+
311
+ # Convert DataFrame to records if necessary
312
+ if hasattr(data, 'to_dict'):
313
+ accounts = data.to_dict('records')
314
+ else:
315
+ accounts = data
316
+
317
+ logger.info(f"Found {len(accounts)} trading account(s)")
318
+
319
+ # 检查账户状态
320
+ for acc in accounts:
321
+ if isinstance(acc, dict):
322
+ acc_id = acc.get('acc_id', 'Unknown')
323
+ acc_type = acc.get('acc_type', 'Unknown')
324
+ acc_state = acc.get('acc_state', 'Unknown')
325
+ trd_env = acc.get('trd_env', 'Unknown')
326
+ trd_market = acc.get('trd_market', 'Unknown')
327
+ else:
328
+ acc_id = getattr(acc, 'acc_id', 'Unknown')
329
+ acc_type = getattr(acc, 'acc_type', 'Unknown')
330
+ acc_state = getattr(acc, 'acc_state', 'Unknown')
331
+ trd_env = getattr(acc, 'trd_env', 'Unknown')
332
+ trd_market = getattr(acc, 'trd_market', 'Unknown')
333
+
334
+ logger.info(f"Account: {acc_id}, Type: {acc_type}, State: {acc_state}, Environment: {trd_env}, Market: {trd_market}")
335
+
336
+ _is_trade_initialized = True
337
+ logger.info(f"Successfully initialized trade connection (Trade Environment: {trade_env}, Security Firm: {security_firm}, Market: {trd_market})")
338
+ return True
339
+
340
+ except Exception as e:
341
+ logger.error(f"Failed to initialize trade connection: {str(e)}")
342
+ cleanup_connections()
343
+ _is_trade_initialized = False
344
+ return False
345
+
346
+ def init_futu_connection():
347
+ """Initialize both quote and trade connections"""
348
+ return init_quote_connection()
349
+
350
+ @asynccontextmanager
351
+ async def lifespan(server: Server):
352
+ # Startup - only initialize quote connection
353
+ if not init_quote_connection():
354
+ logger.error("Failed to initialize quote connection")
355
+ raise Exception("Quote connection failed")
356
+ try:
357
+ yield
358
+ finally:
359
+ # Shutdown - ensure connections are closed
360
+ cleanup_all()
361
+
362
+ # Create MCP server instance
363
+ mcp = FastMCP("futu-stock-server", lifespan=lifespan)
364
+
365
+ def handle_return_data(ret: int, data: Any) -> Dict[str, Any]:
366
+ """Helper function to handle return data from Futu API
367
+
368
+ Args:
369
+ ret: Return code from Futu API
370
+ data: Data returned from Futu API
371
+
372
+ Returns:
373
+ Dict containing either the data or error message
374
+ """
375
+ if ret != RET_OK:
376
+ return {'error': str(data)}
377
+
378
+ # If data is already a dict, return it directly
379
+ if isinstance(data, dict):
380
+ return data
381
+
382
+ # If data has to_dict method, call it
383
+ if hasattr(data, 'to_dict'):
384
+ return data.to_dict()
385
+
386
+ # If data is a pandas DataFrame, convert to dict
387
+ if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
388
+ return data.to_dict('records')
389
+
390
+ # For other types, try to convert to dict or return as is
391
+ try:
392
+ return dict(data)
393
+ except (TypeError, ValueError):
394
+ return {'data': data}
395
+
396
+ # Market Data Tools
397
+ @mcp.tool()
398
+ async def get_stock_quote(symbols: List[str]) -> Dict[str, Any]:
399
+ """Get stock quote data for given symbols
400
+
401
+ Args:
402
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL", "SH.600519"]
403
+ Format: {market}.{code}
404
+ - HK: Hong Kong stocks
405
+ - US: US stocks
406
+ - SH: Shanghai stocks
407
+ - SZ: Shenzhen stocks
408
+
409
+ Returns:
410
+ Dict containing quote data including:
411
+ - quote_list: List of quote data entries, each containing:
412
+ - code: Stock code
413
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
414
+ - last_price: Latest price
415
+ - open_price: Opening price
416
+ - high_price: Highest price
417
+ - low_price: Lowest price
418
+ - prev_close_price: Previous closing price
419
+ - volume: Trading volume
420
+ - turnover: Trading amount
421
+ - turnover_rate: Turnover rate
422
+ - amplitude: Price amplitude
423
+ - dark_status: Dark pool status (0: Normal)
424
+ - list_time: Listing date
425
+ - price_spread: Price spread
426
+ - stock_owner: Stock owner
427
+ - lot_size: Lot size
428
+ - sec_status: Security status
429
+
430
+ Raises:
431
+ - INVALID_PARAM: Invalid parameter
432
+ - INVALID_CODE: Invalid stock code format
433
+ - GET_STOCK_QUOTE_FAILED: Failed to get stock quote
434
+
435
+ Note:
436
+ - Stock quote contains latest market data
437
+ - Can request multiple stocks at once
438
+ - Does not include historical data
439
+ - Consider actual needs when selecting stocks
440
+ - Handle exceptions properly
441
+ """
442
+ ret, data = quote_ctx.get_stock_quote(symbols)
443
+ if ret != RET_OK:
444
+ return {'error': str(data)}
445
+
446
+ # Convert DataFrame to dict if necessary
447
+ if hasattr(data, 'to_dict'):
448
+ result = {
449
+ 'quote_list': data.to_dict('records')
450
+ }
451
+ else:
452
+ result = {
453
+ 'quote_list': data
454
+ }
455
+
456
+ return result
457
+
458
+ @mcp.tool()
459
+ async def get_market_snapshot(symbols: List[str]) -> Dict[str, Any]:
460
+ """Get market snapshot for given symbols
461
+
462
+ Args:
463
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL", "SH.600519"]
464
+ Format: {market}.{code}
465
+ - HK: Hong Kong stocks
466
+ - US: US stocks
467
+ - SH: Shanghai stocks
468
+ - SZ: Shenzhen stocks
469
+
470
+ Returns:
471
+ Dict containing snapshot data including:
472
+ - snapshot_list: List of snapshot data entries, each containing:
473
+ - code: Stock code
474
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
475
+ - last_price: Latest price
476
+ - open_price: Opening price
477
+ - high_price: Highest price
478
+ - low_price: Lowest price
479
+ - prev_close_price: Previous closing price
480
+ - volume: Trading volume
481
+ - turnover: Trading amount
482
+ - turnover_rate: Turnover rate
483
+ - amplitude: Price amplitude
484
+ - dark_status: Dark pool status (0: Normal)
485
+ - list_time: Listing date
486
+ - price_spread: Price spread
487
+ - stock_owner: Stock owner
488
+ - lot_size: Lot size
489
+ - sec_status: Security status
490
+ - bid_price: List of bid prices
491
+ - bid_volume: List of bid volumes
492
+ - ask_price: List of ask prices
493
+ - ask_volume: List of ask volumes
494
+
495
+ Raises:
496
+ - INVALID_PARAM: Invalid parameter
497
+ - INVALID_CODE: Invalid stock code format
498
+ - GET_MARKET_SNAPSHOT_FAILED: Failed to get market snapshot
499
+
500
+ Note:
501
+ - Market snapshot contains latest market data
502
+ - Can request multiple stocks at once
503
+ - Does not include historical data
504
+ - Consider actual needs when selecting stocks
505
+ - Handle exceptions properly
506
+ """
507
+ ret, data = quote_ctx.get_market_snapshot(symbols)
508
+ if ret != RET_OK:
509
+ return {'error': str(data)}
510
+
511
+ # Convert DataFrame to dict if necessary
512
+ if hasattr(data, 'to_dict'):
513
+ result = {
514
+ 'snapshot_list': data.to_dict('records')
515
+ }
516
+ else:
517
+ result = {
518
+ 'snapshot_list': data
519
+ }
520
+
521
+ return result
522
+
523
+ @mcp.tool()
524
+ async def get_cur_kline(symbol: str, ktype: str, count: int = 100) -> Dict[str, Any]:
525
+ """Get current K-line data
526
+
527
+ Args:
528
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
529
+ Format: {market}.{code}
530
+ - HK: Hong Kong stocks
531
+ - US: US stocks
532
+ - SH: Shanghai stocks
533
+ - SZ: Shenzhen stocks
534
+ ktype: K-line type, options:
535
+ - "K_1M": 1 minute
536
+ - "K_5M": 5 minutes
537
+ - "K_15M": 15 minutes
538
+ - "K_30M": 30 minutes
539
+ - "K_60M": 60 minutes
540
+ - "K_DAY": Daily
541
+ - "K_WEEK": Weekly
542
+ - "K_MON": Monthly
543
+ - "K_QUARTER": Quarterly
544
+ - "K_YEAR": Yearly
545
+ count: Number of K-lines to return (default: 100)
546
+ Range: 1-1000
547
+
548
+ Returns:
549
+ Dict containing K-line data including:
550
+ - kline_list: List of K-line data entries, each containing:
551
+ - code: Stock code
552
+ - kline_type: K-line type
553
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
554
+ - open_price: Opening price
555
+ - high_price: Highest price
556
+ - low_price: Lowest price
557
+ - close_price: Closing price
558
+ - volume: Trading volume
559
+ - turnover: Trading amount
560
+ - pe_ratio: Price-to-earnings ratio
561
+ - turnover_rate: Turnover rate
562
+ - timestamp: K-line time
563
+ - kline_status: K-line status (0: Normal)
564
+
565
+ Raises:
566
+ - INVALID_PARAM: Invalid parameter
567
+ - INVALID_CODE: Invalid stock code format
568
+ - INVALID_SUBTYPE: Invalid K-line type
569
+ - GET_CUR_KLINE_FAILED: Failed to get K-line data
570
+
571
+ Note:
572
+ - IMPORTANT: Must subscribe to the K-line data first using subscribe() with the corresponding K-line type
573
+ - K-line data contains latest market data
574
+ - Can request multiple stocks at once
575
+ - Different periods have different update frequencies
576
+ - Consider actual needs when selecting stocks and K-line types
577
+ - Handle exceptions properly
578
+ """
579
+ ret, data = quote_ctx.get_cur_kline(
580
+ code=symbol,
581
+ ktype=ktype,
582
+ num=count
583
+ )
584
+ if ret != RET_OK:
585
+ return {'error': str(data)}
586
+
587
+ # Convert DataFrame to dict if necessary
588
+ if hasattr(data, 'to_dict'):
589
+ result = {
590
+ 'kline_list': data.to_dict('records')
591
+ }
592
+ else:
593
+ result = {
594
+ 'kline_list': data
595
+ }
596
+
597
+ return result
598
+
599
+ @mcp.tool()
600
+ async def get_history_kline(symbol: str, ktype: str, start: str, end: str, count: int = 100) -> Dict[str, Any]:
601
+ """Get historical K-line data
602
+
603
+ Args:
604
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
605
+ Format: {market}.{code}
606
+ - HK: Hong Kong stocks
607
+ - US: US stocks
608
+ - SH: Shanghai stocks
609
+ - SZ: Shenzhen stocks
610
+ ktype: K-line type, options:
611
+ - "K_1M": 1 minute
612
+ - "K_3M": 3 minutes
613
+ - "K_5M": 5 minutes
614
+ - "K_15M": 15 minutes
615
+ - "K_30M": 30 minutes
616
+ - "K_60M": 60 minutes
617
+ - "K_DAY": Daily
618
+ - "K_WEEK": Weekly
619
+ - "K_MON": Monthly
620
+ start: Start date in format "YYYY-MM-DD"
621
+ end: End date in format "YYYY-MM-DD"
622
+ count: Number of K-lines to return (default: 100)
623
+ Range: 1-1000
624
+
625
+ Note:
626
+ - Limited to 30 stocks per 30 days
627
+ - Used quota will be automatically released after 30 days
628
+ - Different K-line types have different update frequencies
629
+ - Historical data availability varies by market and stock
630
+
631
+ Returns:
632
+ Dict containing K-line data including:
633
+ - code: Stock code
634
+ - kline_type: K-line type
635
+ - time_key: K-line time (YYYY-MM-DD HH:mm:ss)
636
+ - open: Opening price
637
+ - close: Closing price
638
+ - high: Highest price
639
+ - low: Lowest price
640
+ - volume: Trading volume
641
+ - turnover: Trading amount
642
+ - pe_ratio: Price-to-earnings ratio
643
+ - turnover_rate: Turnover rate
644
+ - change_rate: Price change rate
645
+ - last_close: Last closing price
646
+
647
+ Raises:
648
+ - INVALID_PARAM: Invalid parameter
649
+ - INVALID_CODE: Invalid stock code format
650
+ - INVALID_SUBTYPE: Invalid K-line type
651
+ - GET_HISTORY_KLINE_FAILED: Failed to get historical K-line data
652
+ """
653
+ ret, data, page_req_key = quote_ctx.request_history_kline(
654
+ code=symbol,
655
+ start=start,
656
+ end=end,
657
+ ktype=ktype,
658
+ max_count=count
659
+ )
660
+
661
+ if ret != RET_OK:
662
+ return {'error': data}
663
+
664
+ result = data.to_dict()
665
+
666
+ # If there are more pages, continue fetching
667
+ while page_req_key is not None:
668
+ ret, data, page_req_key = quote_ctx.request_history_kline(
669
+ code=symbol,
670
+ start=start,
671
+ end=end,
672
+ ktype=ktype,
673
+ max_count=count,
674
+ page_req_key=page_req_key
675
+ )
676
+ if ret != RET_OK:
677
+ return {'error': data}
678
+ # Append new data to result
679
+ new_data = data.to_dict()
680
+ for key in result:
681
+ if isinstance(result[key], list):
682
+ result[key].extend(new_data[key])
683
+
684
+ return result
685
+
686
+ @mcp.tool()
687
+ async def get_rt_data(symbol: str) -> Dict[str, Any]:
688
+ """Get real-time data
689
+
690
+ Args:
691
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
692
+ Format: {market}.{code}
693
+ - HK: Hong Kong stocks
694
+ - US: US stocks
695
+ - SH: Shanghai stocks
696
+ - SZ: Shenzhen stocks
697
+
698
+ Returns:
699
+ Dict containing real-time data including:
700
+ - rt_data_list: List of real-time data entries, each containing:
701
+ - code: Stock code
702
+ - time: Time (HH:mm:ss)
703
+ - price: Latest price
704
+ - volume: Trading volume
705
+ - turnover: Trading amount
706
+ - avg_price: Average price
707
+ - timestamp: Update time (YYYY-MM-DD HH:mm:ss)
708
+ - rt_data_status: Real-time data status (0: Normal)
709
+
710
+ Raises:
711
+ - INVALID_PARAM: Invalid parameter
712
+ - INVALID_CODE: Invalid stock code format
713
+ - GET_RT_DATA_FAILED: Failed to get real-time data
714
+
715
+ Note:
716
+ - IMPORTANT: Must subscribe to RT_DATA first using subscribe()
717
+ - Real-time data is updated frequently
718
+ - Contains latest data only, not historical data
719
+ - Update frequency varies by market and stock
720
+ - Consider using callbacks for real-time processing
721
+ """
722
+ ret, data = quote_ctx.get_rt_data(symbol)
723
+ if ret != RET_OK:
724
+ return {'error': str(data)}
725
+
726
+ # Convert DataFrame to dict if necessary
727
+ if hasattr(data, 'to_dict'):
728
+ result = {
729
+ 'rt_data_list': data.to_dict('records')
730
+ }
731
+ else:
732
+ result = {
733
+ 'rt_data_list': data
734
+ }
735
+
736
+ return result
737
+
738
+ @mcp.tool()
739
+ async def get_ticker(symbol: str) -> Dict[str, Any]:
740
+ """Get ticker data
741
+
742
+ Args:
743
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
744
+ Format: {market}.{code}
745
+ - HK: Hong Kong stocks
746
+ - US: US stocks
747
+ - SH: Shanghai stocks
748
+ - SZ: Shenzhen stocks
749
+
750
+ Returns:
751
+ Dict containing ticker data including:
752
+ - code: Stock code
753
+ - sequence: Sequence number
754
+ - price: Deal price
755
+ - volume: Deal volume
756
+ - turnover: Deal amount
757
+ - ticker_direction: Ticker direction
758
+ 1: Bid order
759
+ 2: Ask order
760
+ 3: Neutral order
761
+ - ticker_type: Ticker type
762
+ 1: Regular trade
763
+ 2: Cancel trade
764
+ 3: Trading at closing price
765
+ 4: Off-exchange trade
766
+ 5: After-hours trade
767
+ - timestamp: Deal time (YYYY-MM-DD HH:mm:ss)
768
+ - ticker_status: Ticker status (0: Normal)
769
+
770
+ Raises:
771
+ - INVALID_PARAM: Invalid parameter
772
+ - INVALID_CODE: Invalid stock code format
773
+ - GET_RT_TICKER_FAILED: Failed to get ticker data
774
+
775
+ Note:
776
+ - IMPORTANT: Must subscribe to TICKER first using subscribe()
777
+ - Ticker data is updated in real-time
778
+ - High update frequency, large data volume
779
+ - Update frequency varies by market and stock
780
+ - Consider using callbacks for real-time processing
781
+ """
782
+ ret, data = quote_ctx.get_ticker(symbol)
783
+ return handle_return_data(ret, data)
784
+
785
+ @mcp.tool()
786
+ async def get_order_book(symbol: str) -> Dict[str, Any]:
787
+ """Get order book data
788
+
789
+ Args:
790
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
791
+ Format: {market}.{code}
792
+ - HK: Hong Kong stocks
793
+ - US: US stocks
794
+ - SH: Shanghai stocks
795
+ - SZ: Shenzhen stocks
796
+
797
+ Returns:
798
+ Dict containing order book data including:
799
+ - code: Stock code
800
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
801
+ - bid_price: List of bid prices (up to 10 levels)
802
+ - bid_volume: List of bid volumes (up to 10 levels)
803
+ - ask_price: List of ask prices (up to 10 levels)
804
+ - ask_volume: List of ask volumes (up to 10 levels)
805
+
806
+ Raises:
807
+ - INVALID_PARAM: Invalid parameter
808
+ - INVALID_CODE: Invalid stock code format
809
+ - GET_ORDER_BOOK_FAILED: Failed to get order book data
810
+
811
+ Note:
812
+ - IMPORTANT: Must subscribe to ORDER_BOOK first using subscribe()
813
+ - Order book data is updated in real-time
814
+ - Contains latest bid/ask information only
815
+ - Number of price levels may vary by market
816
+ - Update frequency varies by market and stock
817
+ """
818
+ ret, data = quote_ctx.get_order_book(symbol)
819
+ return handle_return_data(ret, data)
820
+
821
+ @mcp.tool()
822
+ async def get_broker_queue(symbol: str) -> Dict[str, Any]:
823
+ """Get broker queue data
824
+
825
+ Args:
826
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
827
+ Format: {market}.{code}
828
+ - HK: Hong Kong stocks
829
+ - US: US stocks
830
+ - SH: Shanghai stocks
831
+ - SZ: Shenzhen stocks
832
+
833
+ Returns:
834
+ Dict containing broker queue data including:
835
+ - code: Stock code
836
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
837
+ - bid_broker_id: List of bid broker IDs
838
+ - bid_broker_name: List of bid broker names
839
+ - bid_broker_pos: List of bid broker positions
840
+ - ask_broker_id: List of ask broker IDs
841
+ - ask_broker_name: List of ask broker names
842
+ - ask_broker_pos: List of ask broker positions
843
+ - timestamp: Update timestamp
844
+ - broker_status: Broker queue status (0: Normal)
845
+
846
+ Raises:
847
+ - INVALID_PARAM: Invalid parameter
848
+ - INVALID_CODE: Invalid stock code format
849
+ - GET_BROKER_QUEUE_FAILED: Failed to get broker queue data
850
+
851
+ Note:
852
+ - IMPORTANT: Must subscribe to BROKER first using subscribe()
853
+ - Broker queue data is updated in real-time
854
+ - Shows broker information for both bid and ask sides
855
+ - Number of brokers may vary by market
856
+ - Update frequency varies by market and stock
857
+ - Mainly used for displaying broker trading activities
858
+ """
859
+ ret, data = quote_ctx.get_broker_queue(symbol)
860
+ return handle_return_data(ret, data)
861
+
862
+ @mcp.tool()
863
+ async def subscribe(symbols: List[str], sub_types: List[str]) -> Dict[str, Any]:
864
+ """Subscribe to real-time data
865
+
866
+ Args:
867
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL"]
868
+ Format: {market}.{code}
869
+ - HK: Hong Kong stocks
870
+ - US: US stocks
871
+ - SH: Shanghai stocks
872
+ - SZ: Shenzhen stocks
873
+ sub_types: List of subscription types, options:
874
+ - "QUOTE": Basic quote (price, volume, etc.)
875
+ - "ORDER_BOOK": Order book (bid/ask)
876
+ - "TICKER": Ticker (trades)
877
+ - "RT_DATA": Real-time data
878
+ - "BROKER": Broker queue
879
+ - "K_1M": 1-minute K-line
880
+ - "K_3M": 3-minute K-line
881
+ - "K_5M": 5-minute K-line
882
+ - "K_15M": 15-minute K-line
883
+ - "K_30M": 30-minute K-line
884
+ - "K_60M": 60-minute K-line
885
+ - "K_DAY": Daily K-line
886
+ - "K_WEEK": Weekly K-line
887
+ - "K_MON": Monthly K-line
888
+ - "K_QUARTER": Quarterly K-line
889
+ - "K_YEAR": Yearly K-line
890
+
891
+ Note:
892
+ - Maximum 100 symbols per request
893
+ - Maximum 5 subscription types per request
894
+ - Each socket can subscribe up to 500 symbols
895
+ - Data will be pushed through callbacks
896
+ - Consider unsubscribing when data is no longer needed
897
+
898
+ Returns:
899
+ Dict containing subscription result:
900
+ - status: "success" or error message
901
+
902
+ Raises:
903
+ - INVALID_PARAM: Invalid parameter
904
+ - INVALID_CODE: Invalid stock code format
905
+ - INVALID_SUBTYPE: Invalid subscription type
906
+ - SUBSCRIBE_FAILED: Failed to subscribe
907
+ """
908
+ for symbol in symbols:
909
+ for sub_type in sub_types:
910
+ ret, data = quote_ctx.subscribe(symbol, sub_type)
911
+ if ret != RET_OK:
912
+ return {'error': data}
913
+ return {"status": "success"}
914
+
915
+ @mcp.tool()
916
+ async def unsubscribe(symbols: List[str], sub_types: List[str]) -> Dict[str, Any]:
917
+ """Unsubscribe from real-time data
918
+
919
+ Args:
920
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL"]
921
+ Format: {market}.{code}
922
+ - HK: Hong Kong stocks
923
+ - US: US stocks
924
+ - SH: Shanghai stocks
925
+ - SZ: Shenzhen stocks
926
+ sub_types: List of subscription types, options:
927
+ - "QUOTE": Basic quote (price, volume, etc.)
928
+ - "ORDER_BOOK": Order book (bid/ask)
929
+ - "TICKER": Ticker (trades)
930
+ - "RT_DATA": Real-time data
931
+ - "BROKER": Broker queue
932
+ - "K_1M": 1-minute K-line
933
+ - "K_5M": 5-minute K-line
934
+ - "K_15M": 15-minute K-line
935
+ - "K_30M": 30-minute K-line
936
+ - "K_60M": 60-minute K-line
937
+ - "K_DAY": Daily K-line
938
+ - "K_WEEK": Weekly K-line
939
+ - "K_MON": Monthly K-line
940
+
941
+ Returns:
942
+ Dict containing unsubscription result:
943
+ - status: "success" or error message
944
+
945
+ Raises:
946
+ - INVALID_PARAM: Invalid parameter
947
+ - INVALID_CODE: Invalid stock code format
948
+ - INVALID_SUBTYPE: Invalid subscription type
949
+ - UNSUBSCRIBE_FAILED: Failed to unsubscribe
950
+ """
951
+ for symbol in symbols:
952
+ for sub_type in sub_types:
953
+ ret, data = quote_ctx.unsubscribe(symbol, sub_type)
954
+ if ret != RET_OK:
955
+ return {'error': data}
956
+ return {"status": "success"}
957
+
958
+ # Derivatives Tools
959
+ @mcp.tool()
960
+ async def get_option_chain(symbol: str, start: str, end: str) -> Dict[str, Any]:
961
+ """Get option chain data
962
+
963
+ Args:
964
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
965
+ Format: {market}.{code}
966
+ - HK: Hong Kong stocks
967
+ - US: US stocks
968
+ start: Start date in format "YYYY-MM-DD"
969
+ end: End date in format "YYYY-MM-DD"
970
+
971
+ Returns:
972
+ Dict containing option chain data including:
973
+ - stock_code: Underlying stock code
974
+ - stock_name: Underlying stock name
975
+ - option_list: List of option contracts, each containing:
976
+ - option_code: Option code
977
+ - option_name: Option name
978
+ - option_type: Option type (CALL/PUT)
979
+ - strike_price: Strike price
980
+ - expiry_date: Expiry date
981
+ - last_price: Latest price
982
+ - volume: Trading volume
983
+ - open_interest: Open interest
984
+ - implied_volatility: Implied volatility
985
+ - delta: Delta value
986
+ - gamma: Gamma value
987
+ - theta: Theta value
988
+ - vega: Vega value
989
+ - update_time: Update time
990
+
991
+ Raises:
992
+ - INVALID_PARAM: Invalid parameter
993
+ - INVALID_MARKET: Invalid market code
994
+ - INVALID_STOCKCODE: Invalid stock code
995
+ - INVALID_EXPIRYDATE: Invalid expiry date
996
+ - GET_OPTION_CHAIN_FAILED: Failed to get option chain
997
+
998
+ Note:
999
+ - Option chain data is essential for options trading
1000
+ - Contains both call and put options
1001
+ - Includes Greeks for risk management
1002
+ - Data is updated during trading hours
1003
+ - Consider using with option expiration dates API
1004
+ """
1005
+ ret, data = quote_ctx.get_option_chain(symbol, start, end)
1006
+ return data.to_dict() if ret == RET_OK else {'error': data}
1007
+
1008
+ @mcp.tool()
1009
+ async def get_option_expiration_date(symbol: str) -> Dict[str, Any]:
1010
+ """Get option expiration dates
1011
+
1012
+ Args:
1013
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1014
+ Format: {market}.{code}
1015
+ - HK: Hong Kong stocks
1016
+ - US: US stocks
1017
+
1018
+ Returns:
1019
+ Dict containing expiration dates:
1020
+ - strike_time: List of expiration dates in format "YYYY-MM-DD"
1021
+ - option_expiry_info: Additional expiry information
1022
+
1023
+ Raises:
1024
+ - INVALID_PARAM: Invalid parameter
1025
+ - INVALID_MARKET: Invalid market code
1026
+ - INVALID_STOCKCODE: Invalid stock code
1027
+ - GET_OPTION_EXPIRATION_FAILED: Failed to get expiration dates
1028
+
1029
+ Note:
1030
+ - Use this API before querying option chain
1031
+ - Different stocks may have different expiry dates
1032
+ - Expiry dates are typically on monthly/weekly cycles
1033
+ - Not all stocks have listed options
1034
+ """
1035
+ ret, data = quote_ctx.get_option_expiration_date(symbol)
1036
+ return data.to_dict() if ret == RET_OK else {'error': data}
1037
+
1038
+ @mcp.tool()
1039
+ async def get_option_condor(symbol: str, expiry: str, strike_price: float) -> Dict[str, Any]:
1040
+ """Get option condor strategy data
1041
+
1042
+ Args:
1043
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1044
+ Format: {market}.{code}
1045
+ - HK: Hong Kong stocks
1046
+ - US: US stocks
1047
+ expiry: Option expiration date in format "YYYY-MM-DD"
1048
+ strike_price: Strike price of the option
1049
+
1050
+ Returns:
1051
+ Dict containing condor strategy data including:
1052
+ - strategy_name: Strategy name
1053
+ - option_list: List of options in the strategy
1054
+ - risk_metrics: Risk metrics for the strategy
1055
+ - profit_loss: Profit/loss analysis
1056
+
1057
+ Raises:
1058
+ - INVALID_PARAM: Invalid parameter
1059
+ - INVALID_MARKET: Invalid market code
1060
+ - INVALID_STOCKCODE: Invalid stock code
1061
+ - INVALID_EXPIRYDATE: Invalid expiry date
1062
+ - INVALID_STRIKEPRICE: Invalid strike price
1063
+ - GET_OPTION_CONDOR_FAILED: Failed to get condor data
1064
+
1065
+ Note:
1066
+ - Condor is a neutral options trading strategy
1067
+ - Involves four different strike prices
1068
+ - Limited risk and limited profit potential
1069
+ - Best used in low volatility environments
1070
+ """
1071
+ ret, data = quote_ctx.get_option_condor(symbol, expiry, strike_price)
1072
+ return data.to_dict() if ret == RET_OK else {'error': data}
1073
+
1074
+ @mcp.tool()
1075
+ async def get_option_butterfly(symbol: str, expiry: str, strike_price: float) -> Dict[str, Any]:
1076
+ """Get option butterfly strategy data
1077
+
1078
+ Args:
1079
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1080
+ Format: {market}.{code}
1081
+ - HK: Hong Kong stocks
1082
+ - US: US stocks
1083
+ expiry: Option expiration date in format "YYYY-MM-DD"
1084
+ strike_price: Strike price of the option
1085
+
1086
+ Returns:
1087
+ Dict containing butterfly strategy data including:
1088
+ - strategy_name: Strategy name
1089
+ - option_list: List of options in the strategy
1090
+ - risk_metrics: Risk metrics for the strategy
1091
+ - profit_loss: Profit/loss analysis
1092
+
1093
+ Raises:
1094
+ - INVALID_PARAM: Invalid parameter
1095
+ - INVALID_MARKET: Invalid market code
1096
+ - INVALID_STOCKCODE: Invalid stock code
1097
+ - INVALID_EXPIRYDATE: Invalid expiry date
1098
+ - INVALID_STRIKEPRICE: Invalid strike price
1099
+ - GET_OPTION_BUTTERFLY_FAILED: Failed to get butterfly data
1100
+
1101
+ Note:
1102
+ - Butterfly is a neutral options trading strategy
1103
+ - Involves three different strike prices
1104
+ - Limited risk and limited profit potential
1105
+ - Maximum profit at middle strike price
1106
+ - Best used when expecting low volatility
1107
+ """
1108
+ ret, data = quote_ctx.get_option_butterfly(symbol, expiry, strike_price)
1109
+ return data.to_dict() if ret == RET_OK else {'error': data}
1110
+
1111
+ # Account Query Tools
1112
+ @mcp.tool()
1113
+ async def get_account_list() -> Dict[str, Any]:
1114
+ """Get account list"""
1115
+ if not init_trade_connection():
1116
+ return {'error': 'Failed to initialize trade connection'}
1117
+ ret, data = trade_ctx.get_acc_list()
1118
+ return handle_return_data(ret, data)
1119
+
1120
+ @mcp.tool()
1121
+ async def get_funds() -> Dict[str, Any]:
1122
+ """Get account funds information"""
1123
+ if not init_trade_connection():
1124
+ return {'error': 'Failed to initialize trade connection'}
1125
+ try:
1126
+ ret, data = trade_ctx.accinfo_query()
1127
+ if ret != RET_OK:
1128
+ return {'error': str(data)}
1129
+
1130
+ if data is None or data.empty:
1131
+ return {'error': 'No account information available'}
1132
+
1133
+ return handle_return_data(ret, data)
1134
+ except Exception as e:
1135
+ return {'error': f'Failed to get account funds: {str(e)}'}
1136
+
1137
+ @mcp.tool()
1138
+ async def get_positions() -> Dict[str, Any]:
1139
+ """Get account positions"""
1140
+ if not init_trade_connection():
1141
+ return {'error': 'Failed to initialize trade connection'}
1142
+ ret, data = trade_ctx.position_list_query()
1143
+ return handle_return_data(ret, data)
1144
+
1145
+ @mcp.tool()
1146
+ async def get_max_power() -> Dict[str, Any]:
1147
+ """Get maximum trading power for the account"""
1148
+ if not init_trade_connection():
1149
+ return {'error': 'Failed to initialize trade connection'}
1150
+ ret, data = trade_ctx.get_max_power()
1151
+ return handle_return_data(ret, data)
1152
+
1153
+ @mcp.tool()
1154
+ async def get_margin_ratio(symbol: str) -> Dict[str, Any]:
1155
+ """Get margin ratio for a security"""
1156
+ if not init_trade_connection():
1157
+ return {'error': 'Failed to initialize trade connection'}
1158
+ ret, data = trade_ctx.get_margin_ratio(symbol)
1159
+ return handle_return_data(ret, data)
1160
+
1161
+ # Market Information Tools
1162
+ @mcp.tool()
1163
+ async def get_market_state(market: str) -> Dict[str, Any]:
1164
+ """Get market state
1165
+
1166
+ Args:
1167
+ market: Market code, options:
1168
+ - "HK": Hong Kong market (includes pre-market, continuous trading, afternoon, closing auction)
1169
+ - "US": US market (includes pre-market, continuous trading, after-hours)
1170
+ - "SH": Shanghai market (includes pre-opening, morning, afternoon, closing auction)
1171
+ - "SZ": Shenzhen market (includes pre-opening, morning, afternoon, closing auction)
1172
+
1173
+ Returns:
1174
+ Dict containing market state information including:
1175
+ - market: Market code
1176
+ - market_state: Market state code
1177
+ - NONE: Market not available
1178
+ - AUCTION: Auction period
1179
+ - WAITING_OPEN: Waiting for market open
1180
+ - MORNING: Morning session
1181
+ - REST: Lunch break
1182
+ - AFTERNOON: Afternoon session
1183
+ - CLOSED: Market closed
1184
+ - PRE_MARKET_BEGIN: Pre-market begin
1185
+ - PRE_MARKET_END: Pre-market end
1186
+ - AFTER_HOURS_BEGIN: After-hours begin
1187
+ - AFTER_HOURS_END: After-hours end
1188
+ - market_state_desc: Description of market state
1189
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
1190
+
1191
+ Raises:
1192
+ - INVALID_PARAM: Invalid parameter
1193
+ - INVALID_MARKET: Invalid market code
1194
+ - GET_MARKET_STATE_FAILED: Failed to get market state
1195
+
1196
+ Note:
1197
+ - Market state is updated in real-time
1198
+ - Different markets have different trading hours
1199
+ - Consider timezone differences
1200
+ - Market state affects trading operations
1201
+ - Recommended to check state before trading
1202
+ """
1203
+ ret, data = quote_ctx.get_market_state(market)
1204
+ return data.to_dict() if ret == RET_OK else {'error': data}
1205
+
1206
+ @mcp.tool()
1207
+ async def get_security_info(market: str, code: str) -> Dict[str, Any]:
1208
+ """Get security information
1209
+
1210
+ Args:
1211
+ market: Market code, options:
1212
+ - "HK": Hong Kong market
1213
+ - "US": US market
1214
+ - "SH": Shanghai market
1215
+ - "SZ": Shenzhen market
1216
+ code: Stock code without market prefix, e.g. "00700" for "HK.00700"
1217
+
1218
+ Returns:
1219
+ Dict containing security information including:
1220
+ - stock_code: Stock code
1221
+ - stock_name: Stock name
1222
+ - market: Market code
1223
+ - stock_type: Stock type (e.g., "STOCK", "ETF", "WARRANT")
1224
+ - stock_child_type: Stock subtype (e.g., "MAIN_BOARD", "GEM")
1225
+ - list_time: Listing date
1226
+ - delist_time: Delisting date (if applicable)
1227
+ - lot_size: Lot size
1228
+ - stock_owner: Company name
1229
+ - issue_price: IPO price
1230
+ - issue_size: IPO size
1231
+ - net_profit: Net profit
1232
+ - net_profit_growth: Net profit growth rate
1233
+ - revenue: Revenue
1234
+ - revenue_growth: Revenue growth rate
1235
+ - eps: Earnings per share
1236
+ - pe_ratio: Price-to-earnings ratio
1237
+ - pb_ratio: Price-to-book ratio
1238
+ - dividend_ratio: Dividend ratio
1239
+ - stock_derivatives: List of related derivatives
1240
+
1241
+ Raises:
1242
+ - INVALID_PARAM: Invalid parameter
1243
+ - INVALID_MARKET: Invalid market code
1244
+ - INVALID_STOCKCODE: Invalid stock code
1245
+ - GET_STOCK_BASICINFO_FAILED: Failed to get stock information
1246
+
1247
+ Note:
1248
+ - Contains static information about the security
1249
+ - Financial data may be delayed
1250
+ - Some fields may be empty for certain security types
1251
+ - Important for fundamental analysis
1252
+ """
1253
+ ret, data = quote_ctx.get_security_info(market, code)
1254
+ return data.to_dict() if ret == RET_OK else {'error': data}
1255
+
1256
+ @mcp.tool()
1257
+ async def get_security_list(market: str) -> Dict[str, Any]:
1258
+ """Get security list
1259
+
1260
+ Args:
1261
+ market: Market code, options:
1262
+ - "HK": Hong Kong market
1263
+ - "US": US market
1264
+ - "SH": Shanghai market
1265
+ - "SZ": Shenzhen market
1266
+
1267
+ Returns:
1268
+ Dict containing list of securities:
1269
+ - security_list: List of securities, each containing:
1270
+ - code: Security code
1271
+ - name: Security name
1272
+ - lot_size: Lot size
1273
+ - stock_type: Security type
1274
+ - list_time: Listing date
1275
+ - stock_id: Security ID
1276
+ - delisting: Whether delisted
1277
+ - main_contract: Whether it's the main contract (futures)
1278
+ - last_trade_time: Last trade time (futures/options)
1279
+
1280
+ Raises:
1281
+ - INVALID_PARAM: Invalid parameter
1282
+ - INVALID_MARKET: Invalid market code
1283
+ - GET_SECURITY_LIST_FAILED: Failed to get security list
1284
+
1285
+ Note:
1286
+ - Returns all securities in the specified market
1287
+ - Includes stocks, ETFs, warrants, etc.
1288
+ - Updated daily
1289
+ - Useful for market analysis and monitoring
1290
+ - Consider caching results for better performance
1291
+ """
1292
+ ret, data = quote_ctx.get_security_list(market)
1293
+ return data.to_dict() if ret == RET_OK else {'error': data}
1294
+
1295
+ # Prompts
1296
+ @mcp.prompt()
1297
+ async def market_analysis(symbol: str) -> str:
1298
+ """Create a market analysis prompt"""
1299
+ return f"Please analyze the market data for {symbol}"
1300
+
1301
+ @mcp.prompt()
1302
+ async def option_strategy(symbol: str, expiry: str) -> str:
1303
+ """Create an option strategy analysis prompt"""
1304
+ return f"Please analyze option strategies for {symbol} expiring on {expiry}"
1305
+
1306
+ @mcp.tool()
1307
+ async def get_stock_filter(base_filters: List[Dict[str, Any]] = None,
1308
+ accumulate_filters: List[Dict[str, Any]] = None,
1309
+ financial_filters: List[Dict[str, Any]] = None,
1310
+ market: str = None,
1311
+ page: int = 1,
1312
+ page_size: int = 200) -> Dict[str, Any]:
1313
+ """Get filtered stock list based on conditions
1314
+
1315
+ Args:
1316
+ base_filters: List of base filters with structure:
1317
+ {
1318
+ "field_name": int, # StockField enum value
1319
+ "filter_min": float, # Optional minimum value
1320
+ "filter_max": float, # Optional maximum value
1321
+ "is_no_filter": bool, # Optional, whether to skip filtering
1322
+ "sort_dir": int # Optional, sort direction (0: No sort, 1: Ascending, 2: Descending)
1323
+ }
1324
+ accumulate_filters: List of accumulate filters with structure:
1325
+ {
1326
+ "field_name": int, # AccumulateField enum value
1327
+ "filter_min": float,
1328
+ "filter_max": float,
1329
+ "is_no_filter": bool,
1330
+ "sort_dir": int, # 0: No sort, 1: Ascending, 2: Descending
1331
+ "days": int # Required, number of days to accumulate
1332
+ }
1333
+ financial_filters: List of financial filters with structure:
1334
+ {
1335
+ "field_name": int, # FinancialField enum value
1336
+ "filter_min": float,
1337
+ "filter_max": float,
1338
+ "is_no_filter": bool,
1339
+ "sort_dir": int, # 0: No sort, 1: Ascending, 2: Descending
1340
+ "quarter": int # Required, financial quarter
1341
+ }
1342
+ market: Market code, options:
1343
+ - "HK.Motherboard": Hong Kong Main Board
1344
+ - "HK.GEM": Hong Kong GEM
1345
+ - "HK.BK1911": H-Share Main Board
1346
+ - "HK.BK1912": H-Share GEM
1347
+ - "US.NYSE": NYSE
1348
+ - "US.AMEX": AMEX
1349
+ - "US.NASDAQ": NASDAQ
1350
+ - "SH.3000000": Shanghai Main Board
1351
+ - "SZ.3000001": Shenzhen Main Board
1352
+ - "SZ.3000004": Shenzhen ChiNext
1353
+ page: Page number, starting from 1 (default: 1)
1354
+ page_size: Number of results per page, max 200 (default: 200)
1355
+ """
1356
+ # Create filter request
1357
+ req = {
1358
+ "begin": (page - 1) * page_size,
1359
+ "num": page_size
1360
+ }
1361
+
1362
+ # Add market filter if specified
1363
+ if market:
1364
+ req["plate"] = {"plate_code": market}
1365
+
1366
+ # Add base filters
1367
+ if base_filters:
1368
+ req["baseFilterList"] = []
1369
+ for f in base_filters:
1370
+ filter_item = {"fieldName": f["field_name"]}
1371
+ if "filter_min" in f:
1372
+ filter_item["filterMin"] = f["filter_min"]
1373
+ if "filter_max" in f:
1374
+ filter_item["filterMax"] = f["filter_max"]
1375
+ if "is_no_filter" in f:
1376
+ filter_item["isNoFilter"] = f["is_no_filter"]
1377
+ if "sort_dir" in f:
1378
+ filter_item["sortDir"] = f["sort_dir"]
1379
+ req["baseFilterList"].append(filter_item)
1380
+
1381
+ # Add accumulate filters
1382
+ if accumulate_filters:
1383
+ req["accumulateFilterList"] = []
1384
+ for f in accumulate_filters:
1385
+ filter_item = {
1386
+ "fieldName": f["field_name"],
1387
+ "days": f["days"]
1388
+ }
1389
+ if "filter_min" in f:
1390
+ filter_item["filterMin"] = f["filter_min"]
1391
+ if "filter_max" in f:
1392
+ filter_item["filterMax"] = f["filter_max"]
1393
+ if "is_no_filter" in f:
1394
+ filter_item["isNoFilter"] = f["is_no_filter"]
1395
+ if "sort_dir" in f:
1396
+ filter_item["sortDir"] = f["sort_dir"]
1397
+ req["accumulateFilterList"].append(filter_item)
1398
+
1399
+ # Add financial filters
1400
+ if financial_filters:
1401
+ req["financialFilterList"] = []
1402
+ for f in financial_filters:
1403
+ filter_item = {
1404
+ "fieldName": f["field_name"],
1405
+ "quarter": f["quarter"]
1406
+ }
1407
+ if "filter_min" in f:
1408
+ filter_item["filterMin"] = f["filter_min"]
1409
+ if "filter_max" in f:
1410
+ filter_item["filterMax"] = f["filter_max"]
1411
+ if "is_no_filter" in f:
1412
+ filter_item["isNoFilter"] = f["is_no_filter"]
1413
+ if "sort_dir" in f:
1414
+ filter_item["sortDir"] = f["sort_dir"]
1415
+ req["financialFilterList"].append(filter_item)
1416
+
1417
+ ret, data = quote_ctx.get_stock_filter(req)
1418
+ return data.to_dict() if ret == RET_OK else {'error': data}
1419
+
1420
+ @mcp.tool()
1421
+ async def get_current_time() -> Dict[str, Any]:
1422
+ """Get current time information
1423
+
1424
+ Returns:
1425
+ Dict containing time information including:
1426
+ - timestamp: Unix timestamp in seconds
1427
+ - datetime: Formatted datetime string (YYYY-MM-DD HH:mm:ss)
1428
+ - date: Date string (YYYY-MM-DD)
1429
+ - time: Time string (HH:mm:ss)
1430
+ - timezone: Local timezone name
1431
+ """
1432
+ now = datetime.now()
1433
+ return {
1434
+ 'timestamp': int(now.timestamp()),
1435
+ 'datetime': now.strftime('%Y-%m-%d %H:%M:%S'),
1436
+ 'date': now.strftime('%Y-%m-%d'),
1437
+ 'time': now.strftime('%H:%M:%S'),
1438
+ 'timezone': datetime.now().astimezone().tzname()
1439
+ }
1440
+
1441
+ if __name__ == "__main__":
1442
+ try:
1443
+ # 清理旧的进程和文件
1444
+ cleanup_stale_processes()
1445
+
1446
+ # 获取锁
1447
+ lock_fd = acquire_lock()
1448
+ if lock_fd is None:
1449
+ logger.error("Failed to acquire lock. Another instance may be running.")
1450
+ sys.exit(1)
1451
+
1452
+ # 设置信号处理
1453
+ signal.signal(signal.SIGINT, signal_handler)
1454
+ signal.signal(signal.SIGTERM, signal_handler)
1455
+
1456
+ logger.info("Initializing Futu connection...")
1457
+ if init_futu_connection():
1458
+ logger.info("Successfully initialized Futu connection")
1459
+ logger.info("Starting MCP server in stdio mode...")
1460
+ try:
1461
+ mcp.run(transport='stdio')
1462
+ except KeyboardInterrupt:
1463
+ logger.info("Received keyboard interrupt, shutting down...")
1464
+ sys.exit(0)
1465
+ except Exception as e:
1466
+ logger.error(f"Error running server: {str(e)}")
1467
+ sys.exit(1)
1468
+ else:
1469
+ logger.error("Failed to initialize Futu connection. Server will not start.")
1470
+ sys.exit(1)
1471
+ except Exception as e:
1472
+ logger.error(f"Error starting server: {str(e)}")
1473
+ sys.exit(1)
1474
+ finally:
1475
+ cleanup_all()