futu-stock-mcp-server 0.1.9__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,1850 @@
1
+ import os
2
+ import sys
3
+ import warnings
4
+ import logging
5
+ import argparse
6
+
7
+ # CRITICAL: Check if this is a help command before setting MCP mode
8
+ _is_help_command = any(arg in ['--help', '-h', '--version', '-v'] for arg in sys.argv)
9
+
10
+ # CRITICAL: Set MCP mode BEFORE any logging to ensure clean stdout
11
+ # But not if this is a help command - in that case, we want normal stdout
12
+ if not _is_help_command:
13
+ os.environ['MCP_MODE'] = os.environ.get('MCP_MODE', '1')
14
+
15
+ # CRITICAL: Completely disable all potential stdout pollution sources
16
+ # This must be done BEFORE any other imports or operations
17
+
18
+ # 1. Disable all warnings that might go to stdout/stderr
19
+ warnings.filterwarnings("ignore")
20
+ warnings.simplefilter("ignore")
21
+
22
+ # 2. Completely disable the standard logging system
23
+ logging.disable(logging.CRITICAL)
24
+
25
+ # 3. Set environment variables to prevent ANSI escape sequences
26
+ os.environ['NO_COLOR'] = '1'
27
+ os.environ['TERM'] = 'dumb'
28
+ os.environ['FORCE_COLOR'] = '0'
29
+ os.environ['COLORTERM'] = ''
30
+ os.environ['ANSI_COLORS_DISABLED'] = '1'
31
+ os.environ['PYTHONUNBUFFERED'] = '1'
32
+ os.environ['PYTHONIOENCODING'] = 'utf-8'
33
+
34
+ # 4. Create a custom stdout wrapper to catch any accidental writes
35
+ class StdoutProtector:
36
+ """Protects stdout from any non-MCP content"""
37
+ def __init__(self, original_stdout):
38
+ self.original = original_stdout
39
+ self.buffer = ""
40
+
41
+ def write(self, text):
42
+ # Only allow JSON-like content or empty strings
43
+ if not text or text.isspace():
44
+ self.original.write(text)
45
+ elif text.strip().startswith(('{', '[', '"')) or text.strip() == '':
46
+ self.original.write(text)
47
+ else:
48
+ # Silently drop non-JSON content
49
+ pass
50
+
51
+ def flush(self):
52
+ self.original.flush()
53
+
54
+ def readable(self):
55
+ """Delegate readable() method to original stdout"""
56
+ return getattr(self.original, 'readable', lambda: False)()
57
+
58
+ def writable(self):
59
+ """Delegate writable() method to original stdout"""
60
+ return getattr(self.original, 'writable', lambda: True)()
61
+
62
+ def seekable(self):
63
+ """Delegate seekable() method to original stdout"""
64
+ return getattr(self.original, 'seekable', lambda: False)()
65
+
66
+ def __getattr__(self, name):
67
+ return getattr(self.original, name)
68
+
69
+ # Apply stdout protection in MCP mode VERY EARLY, before any imports
70
+ # if os.getenv('MCP_MODE') == '1':
71
+ # sys.stdout = StdoutProtector(sys.stdout)
72
+
73
+ # 5. Don't redirect stderr in MCP mode - let it work normally
74
+ # MCP servers can use stderr for logging, only stdout needs protection
75
+ _stderr_redirected = False
76
+ _stderr_backup = None
77
+
78
+ # Now we can safely import other modules
79
+ from contextlib import asynccontextmanager
80
+ from collections.abc import AsyncIterator
81
+ from typing import Dict, Any, List, Optional
82
+ try:
83
+ from futu import OpenQuoteContext, OpenSecTradeContext, TrdMarket, SecurityFirm, RET_OK
84
+ except ImportError as e:
85
+ # In MCP mode, we should avoid printing to stdout/stderr
86
+ # Log to file only
87
+ logger.error(f"Failed to import futu: {e}")
88
+ sys.exit(1)
89
+ import json
90
+ import asyncio
91
+ from loguru import logger
92
+ from dotenv import load_dotenv
93
+ from mcp.server.fastmcp import FastMCP, Context
94
+ from mcp.types import TextContent, PromptMessage
95
+ from mcp.server import Server
96
+ from mcp.server.session import ServerSession
97
+ import atexit
98
+ import signal
99
+ import fcntl
100
+ import psutil
101
+ import time
102
+ from datetime import datetime
103
+
104
+ # Get the user home directory and create logs directory there
105
+ home_dir = os.path.expanduser("~")
106
+ log_dir = os.path.join(home_dir, "logs")
107
+ os.makedirs(log_dir, exist_ok=True)
108
+
109
+ # CRITICAL: Configure logging to be MCP-compatible
110
+ # According to MCP best practices, we should:
111
+ # 1. Never write to stdout (reserved for MCP JSON communication)
112
+ # 2. Use file logging for debugging
113
+ # 3. Use MCP Context for operational logging when available
114
+ # 4. Suppress third-party library logs that might pollute output
115
+
116
+ # Completely silence warnings and third-party logs
117
+ warnings.filterwarnings("ignore")
118
+
119
+ # Configure loguru for file-only logging
120
+ logger.remove() # Remove all default handlers
121
+
122
+ # CRITICAL: In MCP mode, ensure NO stderr output at all
123
+ if os.getenv('MCP_MODE') == '1':
124
+ # Remove any remaining handlers that might output to stderr
125
+ logger.remove()
126
+ # Add only file handler - NO console output
127
+ logger.add(
128
+ os.path.join(log_dir, "futu_mcp_server.log"),
129
+ rotation="500 MB",
130
+ retention="10 days",
131
+ level="DEBUG",
132
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
133
+ enqueue=True, # Thread-safe logging
134
+ backtrace=True,
135
+ diagnose=True
136
+ )
137
+ else:
138
+ # Non-MCP mode: add file handler
139
+ logger.add(
140
+ os.path.join(log_dir, "futu_mcp_server.log"),
141
+ rotation="500 MB",
142
+ retention="10 days",
143
+ level="DEBUG",
144
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
145
+ enqueue=True, # Thread-safe logging
146
+ backtrace=True,
147
+ diagnose=True
148
+ )
149
+
150
+ # Only add stderr logging if explicitly in debug mode and not in MCP mode
151
+ if os.getenv('FUTU_DEBUG_MODE') == '1' and not os.getenv('MCP_MODE') == '1':
152
+ logger.add(
153
+ sys.stderr,
154
+ level="INFO",
155
+ format="{time:HH:mm:ss} | {level} | {message}",
156
+ colorize=False,
157
+ filter=lambda record: record["level"].name in ["INFO", "WARNING", "ERROR", "CRITICAL"]
158
+ )
159
+
160
+ # Suppress all third-party library logging to prevent stdout pollution
161
+ logging.disable(logging.CRITICAL)
162
+
163
+ # Set up null handlers for problematic loggers
164
+ class NullHandler(logging.Handler):
165
+ def emit(self, record):
166
+ pass
167
+
168
+ null_handler = NullHandler()
169
+ root_logger = logging.getLogger()
170
+ root_logger.addHandler(null_handler)
171
+ root_logger.setLevel(logging.CRITICAL + 1)
172
+
173
+ # Specifically silence known problematic loggers
174
+ for logger_name in [
175
+ 'mcp', 'fastmcp', 'futu', 'uvicorn', 'asyncio',
176
+ 'websockets', 'aiohttp', 'urllib3', 'requests'
177
+ ]:
178
+ lib_logger = logging.getLogger(logger_name)
179
+ lib_logger.disabled = True
180
+ lib_logger.addHandler(null_handler)
181
+ lib_logger.setLevel(logging.CRITICAL + 1)
182
+ lib_logger.propagate = False
183
+
184
+ # Even more aggressive suppression for futu library
185
+ # This is critical because futu library may output logs during connection
186
+ try:
187
+ # Suppress futu library logging completely
188
+ futu_logger = logging.getLogger('futu')
189
+ futu_logger.disabled = True
190
+ futu_logger.setLevel(logging.CRITICAL + 1)
191
+ futu_logger.propagate = False
192
+
193
+ # Suppress specific futu sub-modules that are known to output logs
194
+ for sub_logger_name in [
195
+ 'futu', 'futu.common', 'futu.quote', 'futu.trade',
196
+ 'futu.common.constant', 'futu.common.sys_utils',
197
+ 'futu.quote.open_quote_context', 'futu.quote.quote_response_handler',
198
+ 'futu.trade.open_trade_context', 'futu.trade.trade_response_handler',
199
+ 'futu.common.open_context_base', 'futu.common.network_manager'
200
+ ]:
201
+ sub_logger = logging.getLogger(sub_logger_name)
202
+ sub_logger.disabled = True
203
+ sub_logger.setLevel(logging.CRITICAL + 1)
204
+ sub_logger.propagate = False
205
+
206
+ # Also redirect any direct print statements from futu to a file
207
+ if os.getenv('MCP_MODE') == '1':
208
+ # Create a special log file for futu connection logs
209
+ home_dir = os.path.expanduser("~")
210
+ log_dir = os.path.join(home_dir, "logs")
211
+ os.makedirs(log_dir, exist_ok=True)
212
+
213
+ futu_conn_log_file = os.path.join(log_dir, "futu_connection.log")
214
+ futu_conn_log = open(futu_conn_log_file, 'a')
215
+
216
+ # This is a last resort to catch any print statements from futu
217
+ # We'll redirect stderr to this file temporarily during connection
218
+ # and then restore it to devnull
219
+ except Exception as e:
220
+ # If we can't set up additional logging, continue anyway
221
+ pass
222
+
223
+ # MCP-compatible logging helper functions
224
+ async def log_to_mcp(ctx: Context, level: str, message: str):
225
+ """Send log message through MCP Context when available"""
226
+ try:
227
+ if level.upper() == "DEBUG":
228
+ await ctx.debug(message)
229
+ elif level.upper() == "INFO":
230
+ await ctx.info(message)
231
+ elif level.upper() == "WARNING":
232
+ await ctx.warning(message)
233
+ elif level.upper() == "ERROR":
234
+ await ctx.error(message)
235
+ else:
236
+ await ctx.info(f"[{level}] {message}")
237
+ except Exception:
238
+ # Fallback to file logging if MCP context fails
239
+ logger.log(level.upper(), message)
240
+
241
+ def safe_log(level: str, message: str, ctx: Context = None):
242
+ """Safe logging that uses MCP context when available, file logging otherwise"""
243
+ # Always log to file
244
+ logger.log(level.upper(), message)
245
+
246
+ # Also send to MCP if context is available
247
+ if ctx and os.getenv('MCP_MODE') == '1' and not _stderr_redirected:
248
+ try:
249
+ import asyncio
250
+ loop = asyncio.get_event_loop()
251
+ if loop.is_running():
252
+ asyncio.create_task(log_to_mcp(ctx, level, message))
253
+ except Exception:
254
+ pass # Ignore MCP logging errors
255
+
256
+ # Only log to file, never to stdout/stderr in MCP mode
257
+ # These logs will only be written to file, not to stdout/stderr
258
+
259
+ # Get project root directory
260
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
261
+
262
+ # PID file path
263
+ PID_FILE = os.path.join(project_root, '.futu_mcp.pid')
264
+ LOCK_FILE = os.path.join(project_root, '.futu_mcp.lock')
265
+
266
+ # Global variables
267
+ quote_ctx = None
268
+ trade_ctx = None
269
+ lock_fd = None
270
+ _is_shutting_down = False
271
+ _is_trade_initialized = False
272
+ _futu_host = '127.0.0.1'
273
+ _futu_port = 11111
274
+
275
+ def is_process_running(pid):
276
+ """Check if a process with given PID is running"""
277
+ try:
278
+ return psutil.pid_exists(pid)
279
+ except:
280
+ return False
281
+
282
+ def cleanup_stale_processes():
283
+ """Clean up any stale Futu processes"""
284
+ global _is_shutting_down
285
+ if _is_shutting_down:
286
+ return
287
+
288
+ try:
289
+ # 只检查 PID 文件中的进程
290
+ if os.path.exists(PID_FILE):
291
+ try:
292
+ with open(PID_FILE, 'r') as f:
293
+ old_pid = int(f.read().strip())
294
+ if old_pid != os.getpid():
295
+ try:
296
+ old_proc = psutil.Process(old_pid)
297
+ if any('futu_stock_mcp_server' in cmd for cmd in old_proc.cmdline()):
298
+ logger.info(f"Found stale process {old_pid}")
299
+ old_proc.terminate()
300
+ try:
301
+ old_proc.wait(timeout=3)
302
+ except psutil.TimeoutExpired:
303
+ old_proc.kill()
304
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
305
+ pass
306
+ except (IOError, ValueError):
307
+ pass
308
+
309
+ # 清理 PID 文件
310
+ try:
311
+ os.unlink(PID_FILE)
312
+ except OSError:
313
+ pass
314
+
315
+ # 清理锁文件
316
+ if os.path.exists(LOCK_FILE):
317
+ try:
318
+ os.unlink(LOCK_FILE)
319
+ except OSError:
320
+ pass
321
+
322
+ except Exception as e:
323
+ logger.error(f"Error cleaning up stale processes: {str(e)}")
324
+
325
+ def cleanup_connections():
326
+ """Clean up Futu connections"""
327
+ global quote_ctx, trade_ctx
328
+ try:
329
+ if quote_ctx:
330
+ try:
331
+ quote_ctx.close()
332
+ logger.info("Successfully closed quote context")
333
+ except Exception as e:
334
+ logger.error(f"Error closing quote context: {str(e)}")
335
+ quote_ctx = None
336
+
337
+ if trade_ctx:
338
+ try:
339
+ trade_ctx.close()
340
+ logger.info("Successfully closed trade context")
341
+ except Exception as e:
342
+ logger.error(f"Error closing trade context: {str(e)}")
343
+ trade_ctx = None
344
+
345
+ # 等待连接完全关闭
346
+ time.sleep(1)
347
+ except Exception as e:
348
+ logger.error(f"Error during connection cleanup: {str(e)}")
349
+
350
+ def release_lock():
351
+ """Release the process lock"""
352
+ global lock_fd
353
+ try:
354
+ if lock_fd is not None:
355
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
356
+ os.close(lock_fd)
357
+ lock_fd = None
358
+ if os.path.exists(LOCK_FILE):
359
+ os.unlink(LOCK_FILE)
360
+ if os.path.exists(PID_FILE):
361
+ os.unlink(PID_FILE)
362
+ except Exception as e:
363
+ logger.error(f"Error releasing lock: {str(e)}")
364
+
365
+ def cleanup_all():
366
+ """Clean up all resources on exit"""
367
+ global _is_shutting_down
368
+ if _is_shutting_down:
369
+ return
370
+ _is_shutting_down = True
371
+
372
+ cleanup_connections()
373
+ release_lock()
374
+ cleanup_stale_processes()
375
+
376
+ def signal_handler(signum, frame):
377
+ """Handle process signals"""
378
+ global _is_shutting_down
379
+ if _is_shutting_down:
380
+ logger.info("Already shutting down, forcing exit...")
381
+ os._exit(1)
382
+
383
+ # 只处理 SIGINT 和 SIGTERM
384
+ if signum not in (signal.SIGINT, signal.SIGTERM):
385
+ return
386
+
387
+ logger.info(f"Received signal {signum}, cleaning up...")
388
+ _is_shutting_down = True
389
+
390
+ try:
391
+ cleanup_all()
392
+ logger.info("Cleanup completed, exiting...")
393
+ except Exception as e:
394
+ logger.error(f"Error during cleanup: {e}")
395
+ finally:
396
+ # 强制退出,确保进程能够终止
397
+ os._exit(0)
398
+
399
+ # Register cleanup functions
400
+ atexit.register(cleanup_all)
401
+ signal.signal(signal.SIGINT, signal_handler)
402
+ signal.signal(signal.SIGTERM, signal_handler)
403
+
404
+ def acquire_lock():
405
+ """Try to acquire the process lock"""
406
+ try:
407
+ # 先检查 PID 文件
408
+ if os.path.exists(PID_FILE):
409
+ try:
410
+ with open(PID_FILE, 'r') as f:
411
+ old_pid = int(f.read().strip())
412
+ if old_pid != os.getpid() and psutil.pid_exists(old_pid):
413
+ try:
414
+ old_proc = psutil.Process(old_pid)
415
+ if any('futu_stock_mcp_server' in cmd for cmd in old_proc.cmdline()):
416
+ logger.error(f"Another instance is already running (PID: {old_pid})")
417
+ return None
418
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
419
+ pass
420
+ except (IOError, ValueError):
421
+ pass
422
+
423
+ # 创建锁文件
424
+ lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR)
425
+ try:
426
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
427
+ except IOError:
428
+ os.close(lock_fd)
429
+ return None
430
+
431
+ # 写入 PID 文件
432
+ with open(PID_FILE, 'w') as f:
433
+ f.write(str(os.getpid()))
434
+
435
+ return lock_fd
436
+ except Exception as e:
437
+ logger.error(f"Failed to acquire lock: {str(e)}")
438
+ if 'lock_fd' in locals():
439
+ try:
440
+ os.close(lock_fd)
441
+ except:
442
+ pass
443
+ return None
444
+
445
+ def init_quote_connection():
446
+ """Initialize quote connection only"""
447
+ global quote_ctx, _futu_host, _futu_port
448
+
449
+ try:
450
+ # Check if OpenD is running by attempting to get global state
451
+ try:
452
+ temp_ctx = OpenQuoteContext(
453
+ host=_futu_host,
454
+ port=_futu_port
455
+ )
456
+ ret, _ = temp_ctx.get_global_state()
457
+ temp_ctx.close()
458
+ if ret != RET_OK:
459
+ logger.error("OpenD is not running or not accessible")
460
+ return False
461
+ except Exception as e:
462
+ logger.error(f"Failed to connect to OpenD: {str(e)}")
463
+ return False
464
+
465
+ # Initialize Futu connection
466
+ quote_ctx = OpenQuoteContext(
467
+ host=_futu_host,
468
+ port=_futu_port
469
+ )
470
+ logger.info("Successfully connected to Futu Quote API")
471
+ return True
472
+
473
+ except Exception as e:
474
+ logger.error(f"Failed to initialize quote connection: {str(e)}")
475
+ cleanup_connections()
476
+ return False
477
+
478
+ def init_trade_connection():
479
+ """Initialize trade connection only"""
480
+ global trade_ctx, _is_trade_initialized, _futu_host, _futu_port
481
+
482
+ if _is_trade_initialized and trade_ctx:
483
+ return True
484
+
485
+ try:
486
+ # Initialize trade context with proper market access
487
+ trade_env = os.getenv('FUTU_TRADE_ENV', 'SIMULATE')
488
+ security_firm = getattr(SecurityFirm, os.getenv('FUTU_SECURITY_FIRM', 'FUTUSECURITIES'))
489
+
490
+ # 只支持港股和美股
491
+ market_map = {
492
+ 'HK': 1, # TrdMarket.HK
493
+ 'US': 2 # TrdMarket.US
494
+ }
495
+ trd_market = market_map.get(os.getenv('FUTU_TRD_MARKET', 'HK'), 1)
496
+
497
+ # 创建交易上下文
498
+ trade_ctx = OpenSecTradeContext(
499
+ filter_trdmarket=trd_market,
500
+ host=_futu_host,
501
+ port=_futu_port,
502
+ security_firm=security_firm
503
+ )
504
+
505
+ # 等待连接就绪
506
+ time.sleep(1)
507
+
508
+ # 验证连接状态
509
+ if not trade_ctx:
510
+ raise Exception("Failed to create trade context")
511
+
512
+ # Set trade environment
513
+ if hasattr(trade_ctx, 'set_trade_env'):
514
+ ret, data = trade_ctx.set_trade_env(trade_env)
515
+ if ret != RET_OK:
516
+ logger.warning(f"Failed to set trade environment: {data}")
517
+
518
+ # Verify account access and permissions
519
+ ret, data = trade_ctx.get_acc_list()
520
+ if ret != RET_OK:
521
+ logger.warning(f"Failed to get account list: {data}")
522
+ cleanup_connections()
523
+ return False
524
+
525
+ if data is None or len(data) == 0:
526
+ logger.warning("No trading accounts available")
527
+ cleanup_connections()
528
+ return False
529
+
530
+ # Convert DataFrame to records if necessary
531
+ if hasattr(data, 'to_dict'):
532
+ accounts = data.to_dict('records')
533
+ else:
534
+ accounts = data
535
+
536
+ logger.info(f"Found {len(accounts)} trading account(s)")
537
+
538
+ # 检查账户状态
539
+ for acc in accounts:
540
+ if isinstance(acc, dict):
541
+ acc_id = acc.get('acc_id', 'Unknown')
542
+ acc_type = acc.get('acc_type', 'Unknown')
543
+ acc_state = acc.get('acc_state', 'Unknown')
544
+ trd_env = acc.get('trd_env', 'Unknown')
545
+ trd_market = acc.get('trd_market', 'Unknown')
546
+ else:
547
+ acc_id = getattr(acc, 'acc_id', 'Unknown')
548
+ acc_type = getattr(acc, 'acc_type', 'Unknown')
549
+ acc_state = getattr(acc, 'acc_state', 'Unknown')
550
+ trd_env = getattr(acc, 'trd_env', 'Unknown')
551
+ trd_market = getattr(acc, 'trd_market', 'Unknown')
552
+
553
+ logger.info(f"Account: {acc_id}, Type: {acc_type}, State: {acc_state}, Environment: {trd_env}, Market: {trd_market}")
554
+
555
+ _is_trade_initialized = True
556
+ logger.info(f"Successfully initialized trade connection (Trade Environment: {trade_env}, Security Firm: {security_firm}, Market: {trd_market})")
557
+ return True
558
+
559
+ except Exception as e:
560
+ logger.error(f"Failed to initialize trade connection: {str(e)}")
561
+ cleanup_connections()
562
+ _is_trade_initialized = False
563
+ return False
564
+
565
+ def init_futu_connection(host: str = '127.0.0.1', port: int = 11111) -> bool:
566
+ """
567
+ Initialize connection to Futu OpenD.
568
+
569
+ Args:
570
+ host: Futu OpenD host address
571
+ port: Futu OpenD port number
572
+
573
+ Returns True if successful, False otherwise.
574
+ """
575
+ global quote_ctx, trade_ctx, _is_trade_initialized, _futu_host, _futu_port
576
+
577
+ try:
578
+ # Set global connection parameters
579
+ _futu_host = host
580
+ _futu_port = port
581
+
582
+ # Log to file only
583
+ logger.info(f"Initializing Futu connection to {host}:{port}")
584
+
585
+ # Initialize quote context
586
+ quote_ctx = OpenQuoteContext(host=host, port=port)
587
+
588
+ # Initialize trade context if needed
589
+ if os.getenv('FUTU_ENABLE_TRADING', '0') == '1':
590
+ # Get trading parameters
591
+ trade_env = os.getenv('FUTU_TRADE_ENV', 'SIMULATE')
592
+ security_firm = os.getenv('FUTU_SECURITY_FIRM', 'FUTUSECURITIES')
593
+ trd_market = os.getenv('FUTU_TRD_MARKET', 'HK')
594
+
595
+ # Map environment strings to Futu enums
596
+ trade_env_enum = {
597
+ 'REAL': TrdMarket.REAL,
598
+ 'SIMULATE': TrdMarket.SIMULATE
599
+ }.get(trade_env, TrdMarket.SIMULATE)
600
+
601
+ security_firm_enum = {
602
+ 'FUTUSECURITIES': SecurityFirm.FUTUSECURITIES,
603
+ 'FUTUINC': SecurityFirm.FUTUINC
604
+ }.get(security_firm, SecurityFirm.FUTUSECURITIES)
605
+
606
+ trd_market_enum = {
607
+ 'HK': TrdMarket.HK,
608
+ 'US': TrdMarket.US,
609
+ 'CN': TrdMarket.CN,
610
+ 'HKCC': TrdMarket.HKCC,
611
+ 'AU': TrdMarket.AU
612
+ }.get(trd_market, TrdMarket.HK)
613
+
614
+ # Initialize trade context
615
+ trade_ctx = OpenSecTradeContext(
616
+ host=host,
617
+ port=port,
618
+ security_firm=security_firm_enum
619
+ )
620
+ _is_trade_initialized = True
621
+ logger.info("Trade context initialized successfully")
622
+
623
+ logger.info("Futu connection initialized successfully")
624
+ return True
625
+
626
+ except Exception as e:
627
+ error_msg = f"Failed to initialize Futu connection: {str(e)}"
628
+ logger.error(error_msg)
629
+ return False
630
+
631
+ @asynccontextmanager
632
+ async def lifespan(server: Server):
633
+ # Startup - connections are already initialized in main()
634
+ # No need to initialize here as it's done before mcp.run()
635
+ try:
636
+ yield
637
+ finally:
638
+ # Shutdown - ensure connections are closed
639
+ cleanup_all()
640
+
641
+ # Create MCP server instance
642
+ mcp = FastMCP("futu-stock-server", lifespan=lifespan)
643
+
644
+ def handle_return_data(ret: int, data: Any) -> Dict[str, Any]:
645
+ """Helper function to handle return data from Futu API
646
+
647
+ Args:
648
+ ret: Return code from Futu API
649
+ data: Data returned from Futu API
650
+
651
+ Returns:
652
+ Dict containing either the data or error message
653
+ """
654
+ if ret != RET_OK:
655
+ return {'error': str(data)}
656
+
657
+ # If data is already a dict, return it directly
658
+ if isinstance(data, dict):
659
+ return data
660
+
661
+ # If data has to_dict method, call it
662
+ if hasattr(data, 'to_dict'):
663
+ return data.to_dict()
664
+
665
+ # If data is a pandas DataFrame, convert to dict
666
+ if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
667
+ return data.to_dict('records')
668
+
669
+ # For other types, try to convert to dict or return as is
670
+ try:
671
+ return dict(data)
672
+ except (TypeError, ValueError):
673
+ return {'data': data}
674
+
675
+ # Market Data Tools
676
+ @mcp.tool()
677
+ async def get_stock_quote(symbols: List[str], ctx: Context[ServerSession, None] = None) -> Dict[str, Any]:
678
+ """Get stock quote data for given symbols
679
+
680
+ Args:
681
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL", "SH.600519"]
682
+ Format: {market}.{code}
683
+ - HK: Hong Kong stocks
684
+ - US: US stocks
685
+ - SH: Shanghai stocks
686
+ - SZ: Shenzhen stocks
687
+
688
+ Returns:
689
+ Dict containing quote data including:
690
+ - quote_list: List of quote data entries, each containing:
691
+ - code: Stock code
692
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
693
+ - last_price: Latest price
694
+ - open_price: Opening price
695
+ - high_price: Highest price
696
+ - low_price: Lowest price
697
+ - prev_close_price: Previous closing price
698
+ - volume: Trading volume
699
+ - turnover: Trading amount
700
+ - turnover_rate: Turnover rate
701
+ - amplitude: Price amplitude
702
+ - dark_status: Dark pool status (0: Normal)
703
+ - list_time: Listing date
704
+ - price_spread: Price spread
705
+ - stock_owner: Stock owner
706
+ - lot_size: Lot size
707
+ - sec_status: Security status
708
+
709
+ Raises:
710
+ - INVALID_PARAM: Invalid parameter
711
+ - INVALID_CODE: Invalid stock code format
712
+ - GET_STOCK_QUOTE_FAILED: Failed to get stock quote
713
+
714
+ Note:
715
+ - Stock quote contains latest market data
716
+ - Can request multiple stocks at once
717
+ - Does not include historical data
718
+ - Consider actual needs when selecting stocks
719
+ - Handle exceptions properly
720
+ """
721
+ safe_log("info", f"Getting stock quotes for symbols: {symbols}", ctx)
722
+
723
+ try:
724
+ ret, data = quote_ctx.get_stock_quote(symbols)
725
+ if ret != RET_OK:
726
+ error_msg = f"Failed to get stock quote: {str(data)}"
727
+ safe_log("error", error_msg, ctx)
728
+ return {'error': error_msg}
729
+
730
+ # Convert DataFrame to dict if necessary
731
+ if hasattr(data, 'to_dict'):
732
+ result = {
733
+ 'quote_list': data.to_dict('records')
734
+ }
735
+ else:
736
+ result = {
737
+ 'quote_list': data
738
+ }
739
+
740
+ safe_log("info", f"Successfully retrieved quotes for {len(symbols)} symbols", ctx)
741
+ return result
742
+
743
+ except Exception as e:
744
+ error_msg = f"Exception in get_stock_quote: {str(e)}"
745
+ safe_log("error", error_msg, ctx)
746
+ return {'error': error_msg}
747
+
748
+ @mcp.tool()
749
+ async def get_market_snapshot(symbols: List[str]) -> Dict[str, Any]:
750
+ """Get market snapshot for given symbols
751
+
752
+ Args:
753
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL", "SH.600519"]
754
+ Format: {market}.{code}
755
+ - HK: Hong Kong stocks
756
+ - US: US stocks
757
+ - SH: Shanghai stocks
758
+ - SZ: Shenzhen stocks
759
+
760
+ Returns:
761
+ Dict containing snapshot data including:
762
+ - snapshot_list: List of snapshot data entries, each containing:
763
+ - code: Stock code
764
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
765
+ - last_price: Latest price
766
+ - open_price: Opening price
767
+ - high_price: Highest price
768
+ - low_price: Lowest price
769
+ - prev_close_price: Previous closing price
770
+ - volume: Trading volume
771
+ - turnover: Trading amount
772
+ - turnover_rate: Turnover rate
773
+ - amplitude: Price amplitude
774
+ - dark_status: Dark pool status (0: Normal)
775
+ - list_time: Listing date
776
+ - price_spread: Price spread
777
+ - stock_owner: Stock owner
778
+ - lot_size: Lot size
779
+ - sec_status: Security status
780
+ - bid_price: List of bid prices
781
+ - bid_volume: List of bid volumes
782
+ - ask_price: List of ask prices
783
+ - ask_volume: List of ask volumes
784
+
785
+ Raises:
786
+ - INVALID_PARAM: Invalid parameter
787
+ - INVALID_CODE: Invalid stock code format
788
+ - GET_MARKET_SNAPSHOT_FAILED: Failed to get market snapshot
789
+
790
+ Note:
791
+ - Market snapshot contains latest market data
792
+ - Can request multiple stocks at once
793
+ - Does not include historical data
794
+ - Consider actual needs when selecting stocks
795
+ - Handle exceptions properly
796
+ """
797
+ ret, data = quote_ctx.get_market_snapshot(symbols)
798
+ if ret != RET_OK:
799
+ return {'error': str(data)}
800
+
801
+ # Convert DataFrame to dict if necessary
802
+ if hasattr(data, 'to_dict'):
803
+ result = {
804
+ 'snapshot_list': data.to_dict('records')
805
+ }
806
+ else:
807
+ result = {
808
+ 'snapshot_list': data
809
+ }
810
+
811
+ return result
812
+
813
+ @mcp.tool()
814
+ async def get_cur_kline(symbol: str, ktype: str, count: int = 100) -> Dict[str, Any]:
815
+ """Get current K-line data
816
+
817
+ Args:
818
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
819
+ Format: {market}.{code}
820
+ - HK: Hong Kong stocks
821
+ - US: US stocks
822
+ - SH: Shanghai stocks
823
+ - SZ: Shenzhen stocks
824
+ ktype: K-line type, options:
825
+ - "K_1M": 1 minute
826
+ - "K_5M": 5 minutes
827
+ - "K_15M": 15 minutes
828
+ - "K_30M": 30 minutes
829
+ - "K_60M": 60 minutes
830
+ - "K_DAY": Daily
831
+ - "K_WEEK": Weekly
832
+ - "K_MON": Monthly
833
+ - "K_QUARTER": Quarterly
834
+ - "K_YEAR": Yearly
835
+ count: Number of K-lines to return (default: 100)
836
+ Range: 1-1000
837
+
838
+ Returns:
839
+ Dict containing K-line data including:
840
+ - kline_list: List of K-line data entries, each containing:
841
+ - code: Stock code
842
+ - kline_type: K-line type
843
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
844
+ - open_price: Opening price
845
+ - high_price: Highest price
846
+ - low_price: Lowest price
847
+ - close_price: Closing price
848
+ - volume: Trading volume
849
+ - turnover: Trading amount
850
+ - pe_ratio: Price-to-earnings ratio
851
+ - turnover_rate: Turnover rate
852
+ - timestamp: K-line time
853
+ - kline_status: K-line status (0: Normal)
854
+
855
+ Raises:
856
+ - INVALID_PARAM: Invalid parameter
857
+ - INVALID_CODE: Invalid stock code format
858
+ - INVALID_SUBTYPE: Invalid K-line type
859
+ - GET_CUR_KLINE_FAILED: Failed to get K-line data
860
+
861
+ Note:
862
+ - IMPORTANT: Must subscribe to the K-line data first using subscribe() with the corresponding K-line type
863
+ - K-line data contains latest market data
864
+ - Can request multiple stocks at once
865
+ - Different periods have different update frequencies
866
+ - Consider actual needs when selecting stocks and K-line types
867
+ - Handle exceptions properly
868
+ """
869
+ ret, data = quote_ctx.get_cur_kline(
870
+ code=symbol,
871
+ ktype=ktype,
872
+ num=count
873
+ )
874
+ if ret != RET_OK:
875
+ return {'error': str(data)}
876
+
877
+ # Convert DataFrame to dict if necessary
878
+ if hasattr(data, 'to_dict'):
879
+ result = {
880
+ 'kline_list': data.to_dict('records')
881
+ }
882
+ else:
883
+ result = {
884
+ 'kline_list': data
885
+ }
886
+
887
+ return result
888
+
889
+ @mcp.tool()
890
+ async def get_history_kline(symbol: str, ktype: str, start: str, end: str, count: int = 100) -> Dict[str, Any]:
891
+ """Get historical K-line data
892
+
893
+ Args:
894
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
895
+ Format: {market}.{code}
896
+ - HK: Hong Kong stocks
897
+ - US: US stocks
898
+ - SH: Shanghai stocks
899
+ - SZ: Shenzhen stocks
900
+ ktype: K-line type, options:
901
+ - "K_1M": 1 minute
902
+ - "K_3M": 3 minutes
903
+ - "K_5M": 5 minutes
904
+ - "K_15M": 15 minutes
905
+ - "K_30M": 30 minutes
906
+ - "K_60M": 60 minutes
907
+ - "K_DAY": Daily
908
+ - "K_WEEK": Weekly
909
+ - "K_MON": Monthly
910
+ start: Start date in format "YYYY-MM-DD"
911
+ end: End date in format "YYYY-MM-DD"
912
+ count: Number of K-lines to return (default: 100)
913
+ Range: 1-1000
914
+
915
+ Note:
916
+ - Limited to 30 stocks per 30 days
917
+ - Used quota will be automatically released after 30 days
918
+ - Different K-line types have different update frequencies
919
+ - Historical data availability varies by market and stock
920
+
921
+ Returns:
922
+ Dict containing K-line data including:
923
+ - code: Stock code
924
+ - kline_type: K-line type
925
+ - time_key: K-line time (YYYY-MM-DD HH:mm:ss)
926
+ - open: Opening price
927
+ - close: Closing price
928
+ - high: Highest price
929
+ - low: Lowest price
930
+ - volume: Trading volume
931
+ - turnover: Trading amount
932
+ - pe_ratio: Price-to-earnings ratio
933
+ - turnover_rate: Turnover rate
934
+ - change_rate: Price change rate
935
+ - last_close: Last closing price
936
+
937
+ Raises:
938
+ - INVALID_PARAM: Invalid parameter
939
+ - INVALID_CODE: Invalid stock code format
940
+ - INVALID_SUBTYPE: Invalid K-line type
941
+ - GET_HISTORY_KLINE_FAILED: Failed to get historical K-line data
942
+ """
943
+ ret, data, page_req_key = quote_ctx.request_history_kline(
944
+ code=symbol,
945
+ start=start,
946
+ end=end,
947
+ ktype=ktype,
948
+ max_count=count
949
+ )
950
+
951
+ if ret != RET_OK:
952
+ return {'error': data}
953
+
954
+ result = data.to_dict()
955
+
956
+ # If there are more pages, continue fetching
957
+ while page_req_key is not None:
958
+ ret, data, page_req_key = quote_ctx.request_history_kline(
959
+ code=symbol,
960
+ start=start,
961
+ end=end,
962
+ ktype=ktype,
963
+ max_count=count,
964
+ page_req_key=page_req_key
965
+ )
966
+ if ret != RET_OK:
967
+ return {'error': data}
968
+ # Append new data to result
969
+ new_data = data.to_dict()
970
+ for key in result:
971
+ if isinstance(result[key], list):
972
+ result[key].extend(new_data[key])
973
+
974
+ return result
975
+
976
+ @mcp.tool()
977
+ async def get_rt_data(symbol: str) -> Dict[str, Any]:
978
+ """Get real-time data
979
+
980
+ Args:
981
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
982
+ Format: {market}.{code}
983
+ - HK: Hong Kong stocks
984
+ - US: US stocks
985
+ - SH: Shanghai stocks
986
+ - SZ: Shenzhen stocks
987
+
988
+ Returns:
989
+ Dict containing real-time data including:
990
+ - rt_data_list: List of real-time data entries, each containing:
991
+ - code: Stock code
992
+ - time: Time (HH:mm:ss)
993
+ - price: Latest price
994
+ - volume: Trading volume
995
+ - turnover: Trading amount
996
+ - avg_price: Average price
997
+ - timestamp: Update time (YYYY-MM-DD HH:mm:ss)
998
+ - rt_data_status: Real-time data status (0: Normal)
999
+
1000
+ Raises:
1001
+ - INVALID_PARAM: Invalid parameter
1002
+ - INVALID_CODE: Invalid stock code format
1003
+ - GET_RT_DATA_FAILED: Failed to get real-time data
1004
+
1005
+ Note:
1006
+ - IMPORTANT: Must subscribe to RT_DATA first using subscribe()
1007
+ - Real-time data is updated frequently
1008
+ - Contains latest data only, not historical data
1009
+ - Update frequency varies by market and stock
1010
+ - Consider using callbacks for real-time processing
1011
+ """
1012
+ ret, data = quote_ctx.get_rt_data(symbol)
1013
+ if ret != RET_OK:
1014
+ return {'error': str(data)}
1015
+
1016
+ # Convert DataFrame to dict if necessary
1017
+ if hasattr(data, 'to_dict'):
1018
+ result = {
1019
+ 'rt_data_list': data.to_dict('records')
1020
+ }
1021
+ else:
1022
+ result = {
1023
+ 'rt_data_list': data
1024
+ }
1025
+
1026
+ return result
1027
+
1028
+ @mcp.tool()
1029
+ async def get_ticker(symbol: str) -> Dict[str, Any]:
1030
+ """Get ticker data
1031
+
1032
+ Args:
1033
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
1034
+ Format: {market}.{code}
1035
+ - HK: Hong Kong stocks
1036
+ - US: US stocks
1037
+ - SH: Shanghai stocks
1038
+ - SZ: Shenzhen stocks
1039
+
1040
+ Returns:
1041
+ Dict containing ticker data including:
1042
+ - code: Stock code
1043
+ - sequence: Sequence number
1044
+ - price: Deal price
1045
+ - volume: Deal volume
1046
+ - turnover: Deal amount
1047
+ - ticker_direction: Ticker direction
1048
+ 1: Bid order
1049
+ 2: Ask order
1050
+ 3: Neutral order
1051
+ - ticker_type: Ticker type
1052
+ 1: Regular trade
1053
+ 2: Cancel trade
1054
+ 3: Trading at closing price
1055
+ 4: Off-exchange trade
1056
+ 5: After-hours trade
1057
+ - timestamp: Deal time (YYYY-MM-DD HH:mm:ss)
1058
+ - ticker_status: Ticker status (0: Normal)
1059
+
1060
+ Raises:
1061
+ - INVALID_PARAM: Invalid parameter
1062
+ - INVALID_CODE: Invalid stock code format
1063
+ - GET_RT_TICKER_FAILED: Failed to get ticker data
1064
+
1065
+ Note:
1066
+ - IMPORTANT: Must subscribe to TICKER first using subscribe()
1067
+ - Ticker data is updated in real-time
1068
+ - High update frequency, large data volume
1069
+ - Update frequency varies by market and stock
1070
+ - Consider using callbacks for real-time processing
1071
+ """
1072
+ ret, data = quote_ctx.get_rt_ticker(symbol)
1073
+ return handle_return_data(ret, data)
1074
+
1075
+ @mcp.tool()
1076
+ async def get_order_book(symbol: str) -> Dict[str, Any]:
1077
+ """Get order book data
1078
+
1079
+ Args:
1080
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
1081
+ Format: {market}.{code}
1082
+ - HK: Hong Kong stocks
1083
+ - US: US stocks
1084
+ - SH: Shanghai stocks
1085
+ - SZ: Shenzhen stocks
1086
+
1087
+ Returns:
1088
+ Dict containing order book data including:
1089
+ - code: Stock code
1090
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
1091
+ - bid_price: List of bid prices (up to 10 levels)
1092
+ - bid_volume: List of bid volumes (up to 10 levels)
1093
+ - ask_price: List of ask prices (up to 10 levels)
1094
+ - ask_volume: List of ask volumes (up to 10 levels)
1095
+
1096
+ Raises:
1097
+ - INVALID_PARAM: Invalid parameter
1098
+ - INVALID_CODE: Invalid stock code format
1099
+ - GET_ORDER_BOOK_FAILED: Failed to get order book data
1100
+
1101
+ Note:
1102
+ - IMPORTANT: Must subscribe to ORDER_BOOK first using subscribe()
1103
+ - Order book data is updated in real-time
1104
+ - Contains latest bid/ask information only
1105
+ - Number of price levels may vary by market
1106
+ - Update frequency varies by market and stock
1107
+ """
1108
+ ret, data = quote_ctx.get_order_book(symbol)
1109
+ return handle_return_data(ret, data)
1110
+
1111
+ @mcp.tool()
1112
+ async def get_broker_queue(symbol: str) -> Dict[str, Any]:
1113
+ """Get broker queue data
1114
+
1115
+ Args:
1116
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL", "SH.600519"
1117
+ Format: {market}.{code}
1118
+ - HK: Hong Kong stocks
1119
+ - US: US stocks
1120
+ - SH: Shanghai stocks
1121
+ - SZ: Shenzhen stocks
1122
+
1123
+ Returns:
1124
+ Dict containing broker queue data including:
1125
+ - code: Stock code
1126
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
1127
+ - bid_broker_id: List of bid broker IDs
1128
+ - bid_broker_name: List of bid broker names
1129
+ - bid_broker_pos: List of bid broker positions
1130
+ - ask_broker_id: List of ask broker IDs
1131
+ - ask_broker_name: List of ask broker names
1132
+ - ask_broker_pos: List of ask broker positions
1133
+ - timestamp: Update timestamp
1134
+ - broker_status: Broker queue status (0: Normal)
1135
+
1136
+ Raises:
1137
+ - INVALID_PARAM: Invalid parameter
1138
+ - INVALID_CODE: Invalid stock code format
1139
+ - GET_BROKER_QUEUE_FAILED: Failed to get broker queue data
1140
+
1141
+ Note:
1142
+ - IMPORTANT: Must subscribe to BROKER first using subscribe()
1143
+ - Broker queue data is updated in real-time
1144
+ - Shows broker information for both bid and ask sides
1145
+ - Number of brokers may vary by market
1146
+ - Update frequency varies by market and stock
1147
+ - Mainly used for displaying broker trading activities
1148
+ """
1149
+ ret, data = quote_ctx.get_broker_queue(symbol)
1150
+ return handle_return_data(ret, data)
1151
+
1152
+ @mcp.tool()
1153
+ async def subscribe(symbols: List[str], sub_types: List[str]) -> Dict[str, Any]:
1154
+ """Subscribe to real-time data
1155
+
1156
+ Args:
1157
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL"]
1158
+ Format: {market}.{code}
1159
+ - HK: Hong Kong stocks
1160
+ - US: US stocks
1161
+ - SH: Shanghai stocks
1162
+ - SZ: Shenzhen stocks
1163
+ sub_types: List of subscription types, options:
1164
+ - "QUOTE": Basic quote (price, volume, etc.)
1165
+ - "ORDER_BOOK": Order book (bid/ask)
1166
+ - "TICKER": Ticker (trades)
1167
+ - "RT_DATA": Real-time data
1168
+ - "BROKER": Broker queue
1169
+ - "K_1M": 1-minute K-line
1170
+ - "K_3M": 3-minute K-line
1171
+ - "K_5M": 5-minute K-line
1172
+ - "K_15M": 15-minute K-line
1173
+ - "K_30M": 30-minute K-line
1174
+ - "K_60M": 60-minute K-line
1175
+ - "K_DAY": Daily K-line
1176
+ - "K_WEEK": Weekly K-line
1177
+ - "K_MON": Monthly K-line
1178
+ - "K_QUARTER": Quarterly K-line
1179
+ - "K_YEAR": Yearly K-line
1180
+
1181
+ Note:
1182
+ - Maximum 100 symbols per request
1183
+ - Maximum 5 subscription types per request
1184
+ - Each socket can subscribe up to 500 symbols
1185
+ - Data will be pushed through callbacks
1186
+ - Consider unsubscribing when data is no longer needed
1187
+
1188
+ Returns:
1189
+ Dict containing subscription result:
1190
+ - status: "success" or error message
1191
+
1192
+ Raises:
1193
+ - INVALID_PARAM: Invalid parameter
1194
+ - INVALID_CODE: Invalid stock code format
1195
+ - INVALID_SUBTYPE: Invalid subscription type
1196
+ - SUBSCRIBE_FAILED: Failed to subscribe
1197
+ """
1198
+ ret, data = quote_ctx.subscribe(symbols, sub_types)
1199
+ if ret != RET_OK:
1200
+ return {'error': data}
1201
+ return {"status": "success"}
1202
+
1203
+ @mcp.tool()
1204
+ async def unsubscribe(symbols: List[str], sub_types: List[str]) -> Dict[str, Any]:
1205
+ """Unsubscribe from real-time data
1206
+
1207
+ Args:
1208
+ symbols: List of stock codes, e.g. ["HK.00700", "US.AAPL"]
1209
+ Format: {market}.{code}
1210
+ - HK: Hong Kong stocks
1211
+ - US: US stocks
1212
+ - SH: Shanghai stocks
1213
+ - SZ: Shenzhen stocks
1214
+ sub_types: List of subscription types, options:
1215
+ - "QUOTE": Basic quote (price, volume, etc.)
1216
+ - "ORDER_BOOK": Order book (bid/ask)
1217
+ - "TICKER": Ticker (trades)
1218
+ - "RT_DATA": Real-time data
1219
+ - "BROKER": Broker queue
1220
+ - "K_1M": 1-minute K-line
1221
+ - "K_5M": 5-minute K-line
1222
+ - "K_15M": 15-minute K-line
1223
+ - "K_30M": 30-minute K-line
1224
+ - "K_60M": 60-minute K-line
1225
+ - "K_DAY": Daily K-line
1226
+ - "K_WEEK": Weekly K-line
1227
+ - "K_MON": Monthly K-line
1228
+
1229
+ Returns:
1230
+ Dict containing unsubscription result:
1231
+ - status: "success" or error message
1232
+
1233
+ Raises:
1234
+ - INVALID_PARAM: Invalid parameter
1235
+ - INVALID_CODE: Invalid stock code format
1236
+ - INVALID_SUBTYPE: Invalid subscription type
1237
+ - UNSUBSCRIBE_FAILED: Failed to unsubscribe
1238
+ """
1239
+ ret, data = quote_ctx.unsubscribe(symbols, sub_types)
1240
+ if ret != RET_OK:
1241
+ return {'error': data}
1242
+ return {"status": "success"}
1243
+
1244
+ # Derivatives Tools
1245
+ @mcp.tool()
1246
+ async def get_option_chain(symbol: str, start: str, end: str) -> Dict[str, Any]:
1247
+ """Get option chain data
1248
+
1249
+ Args:
1250
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1251
+ Format: {market}.{code}
1252
+ - HK: Hong Kong stocks
1253
+ - US: US stocks
1254
+ start: Start date in format "YYYY-MM-DD"
1255
+ end: End date in format "YYYY-MM-DD"
1256
+
1257
+ Returns:
1258
+ Dict containing option chain data including:
1259
+ - stock_code: Underlying stock code
1260
+ - stock_name: Underlying stock name
1261
+ - option_list: List of option contracts, each containing:
1262
+ - option_code: Option code
1263
+ - option_name: Option name
1264
+ - option_type: Option type (CALL/PUT)
1265
+ - strike_price: Strike price
1266
+ - expiry_date: Expiry date
1267
+ - last_price: Latest price
1268
+ - volume: Trading volume
1269
+ - open_interest: Open interest
1270
+ - implied_volatility: Implied volatility
1271
+ - delta: Delta value
1272
+ - gamma: Gamma value
1273
+ - theta: Theta value
1274
+ - vega: Vega value
1275
+ - update_time: Update time
1276
+
1277
+ Raises:
1278
+ - INVALID_PARAM: Invalid parameter
1279
+ - INVALID_MARKET: Invalid market code
1280
+ - INVALID_STOCKCODE: Invalid stock code
1281
+ - INVALID_EXPIRYDATE: Invalid expiry date
1282
+ - GET_OPTION_CHAIN_FAILED: Failed to get option chain
1283
+
1284
+ Note:
1285
+ - Option chain data is essential for options trading
1286
+ - Contains both call and put options
1287
+ - Includes Greeks for risk management
1288
+ - Data is updated during trading hours
1289
+ - Consider using with option expiration dates API
1290
+ """
1291
+ ret, data = quote_ctx.get_option_chain(code=symbol, start=start, end=end)
1292
+ return data.to_dict() if ret == RET_OK else {'error': data}
1293
+
1294
+ @mcp.tool()
1295
+ async def get_option_expiration_date(symbol: str) -> Dict[str, Any]:
1296
+ """Get option expiration dates
1297
+
1298
+ Args:
1299
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1300
+ Format: {market}.{code}
1301
+ - HK: Hong Kong stocks
1302
+ - US: US stocks
1303
+
1304
+ Returns:
1305
+ Dict containing expiration dates:
1306
+ - strike_time: List of expiration dates in format "YYYY-MM-DD"
1307
+ - option_expiry_info: Additional expiry information
1308
+
1309
+ Raises:
1310
+ - INVALID_PARAM: Invalid parameter
1311
+ - INVALID_MARKET: Invalid market code
1312
+ - INVALID_STOCKCODE: Invalid stock code
1313
+ - GET_OPTION_EXPIRATION_FAILED: Failed to get expiration dates
1314
+
1315
+ Note:
1316
+ - Use this API before querying option chain
1317
+ - Different stocks may have different expiry dates
1318
+ - Expiry dates are typically on monthly/weekly cycles
1319
+ - Not all stocks have listed options
1320
+ """
1321
+ ret, data = quote_ctx.get_option_expiration_date(symbol)
1322
+ return data.to_dict() if ret == RET_OK else {'error': data}
1323
+
1324
+ @mcp.tool()
1325
+ async def get_option_condor(symbol: str, expiry: str, strike_price: float) -> Dict[str, Any]:
1326
+ """Get option condor strategy data
1327
+
1328
+ Args:
1329
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1330
+ Format: {market}.{code}
1331
+ - HK: Hong Kong stocks
1332
+ - US: US stocks
1333
+ expiry: Option expiration date in format "YYYY-MM-DD"
1334
+ strike_price: Strike price of the option
1335
+
1336
+ Returns:
1337
+ Dict containing condor strategy data including:
1338
+ - strategy_name: Strategy name
1339
+ - option_list: List of options in the strategy
1340
+ - risk_metrics: Risk metrics for the strategy
1341
+ - profit_loss: Profit/loss analysis
1342
+
1343
+ Raises:
1344
+ - INVALID_PARAM: Invalid parameter
1345
+ - INVALID_MARKET: Invalid market code
1346
+ - INVALID_STOCKCODE: Invalid stock code
1347
+ - INVALID_EXPIRYDATE: Invalid expiry date
1348
+ - INVALID_STRIKEPRICE: Invalid strike price
1349
+ - GET_OPTION_CONDOR_FAILED: Failed to get condor data
1350
+
1351
+ Note:
1352
+ - Condor is a neutral options trading strategy
1353
+ - Involves four different strike prices
1354
+ - Limited risk and limited profit potential
1355
+ - Best used in low volatility environments
1356
+ """
1357
+ ret, data = quote_ctx.get_option_condor(symbol, expiry, strike_price)
1358
+ return data.to_dict() if ret == RET_OK else {'error': data}
1359
+
1360
+ @mcp.tool()
1361
+ async def get_option_butterfly(symbol: str, expiry: str, strike_price: float) -> Dict[str, Any]:
1362
+ """Get option butterfly strategy data
1363
+
1364
+ Args:
1365
+ symbol: Stock code, e.g. "HK.00700", "US.AAPL"
1366
+ Format: {market}.{code}
1367
+ - HK: Hong Kong stocks
1368
+ - US: US stocks
1369
+ expiry: Option expiration date in format "YYYY-MM-DD"
1370
+ strike_price: Strike price of the option
1371
+
1372
+ Returns:
1373
+ Dict containing butterfly strategy data including:
1374
+ - strategy_name: Strategy name
1375
+ - option_list: List of options in the strategy
1376
+ - risk_metrics: Risk metrics for the strategy
1377
+ - profit_loss: Profit/loss analysis
1378
+
1379
+ Raises:
1380
+ - INVALID_PARAM: Invalid parameter
1381
+ - INVALID_MARKET: Invalid market code
1382
+ - INVALID_STOCKCODE: Invalid stock code
1383
+ - INVALID_EXPIRYDATE: Invalid expiry date
1384
+ - INVALID_STRIKEPRICE: Invalid strike price
1385
+ - GET_OPTION_BUTTERFLY_FAILED: Failed to get butterfly data
1386
+
1387
+ Note:
1388
+ - Butterfly is a neutral options trading strategy
1389
+ - Involves three different strike prices
1390
+ - Limited risk and limited profit potential
1391
+ - Maximum profit at middle strike price
1392
+ - Best used when expecting low volatility
1393
+ """
1394
+ ret, data = quote_ctx.get_option_butterfly(symbol, expiry, strike_price)
1395
+ return data.to_dict() if ret == RET_OK else {'error': data}
1396
+
1397
+ # Account Query Tools
1398
+ @mcp.tool()
1399
+ async def get_account_list(ctx: Context[ServerSession, None] = None) -> Dict[str, Any]:
1400
+ """Get account list"""
1401
+ safe_log("info", "Attempting to get account list", ctx)
1402
+
1403
+ if not init_trade_connection():
1404
+ error_msg = 'Failed to initialize trade connection'
1405
+ safe_log("error", error_msg, ctx)
1406
+ return {'error': error_msg}
1407
+
1408
+ try:
1409
+ ret, data = trade_ctx.get_acc_list()
1410
+ result = handle_return_data(ret, data)
1411
+
1412
+ if 'error' not in result:
1413
+ safe_log("info", "Successfully retrieved account list", ctx)
1414
+ else:
1415
+ safe_log("error", f"Failed to get account list: {result['error']}", ctx)
1416
+
1417
+ return result
1418
+ except Exception as e:
1419
+ error_msg = f"Exception in get_account_list: {str(e)}"
1420
+ safe_log("error", error_msg, ctx)
1421
+ return {'error': error_msg}
1422
+
1423
+ @mcp.tool()
1424
+ async def get_funds() -> Dict[str, Any]:
1425
+ """Get account funds information"""
1426
+ if not init_trade_connection():
1427
+ return {'error': 'Failed to initialize trade connection'}
1428
+ try:
1429
+ ret, data = trade_ctx.accinfo_query()
1430
+ if ret != RET_OK:
1431
+ return {'error': str(data)}
1432
+
1433
+ if data is None or data.empty:
1434
+ return {'error': 'No account information available'}
1435
+
1436
+ return handle_return_data(ret, data)
1437
+ except Exception as e:
1438
+ return {'error': f'Failed to get account funds: {str(e)}'}
1439
+
1440
+ @mcp.tool()
1441
+ async def get_positions() -> Dict[str, Any]:
1442
+ """Get account positions"""
1443
+ if not init_trade_connection():
1444
+ return {'error': 'Failed to initialize trade connection'}
1445
+ ret, data = trade_ctx.position_list_query()
1446
+ return handle_return_data(ret, data)
1447
+
1448
+ @mcp.tool()
1449
+ async def get_max_power() -> Dict[str, Any]:
1450
+ """Get maximum trading power for the account"""
1451
+ if not init_trade_connection():
1452
+ return {'error': 'Failed to initialize trade connection'}
1453
+ ret, data = trade_ctx.get_max_power()
1454
+ return handle_return_data(ret, data)
1455
+
1456
+ @mcp.tool()
1457
+ async def get_margin_ratio(symbol: str) -> Dict[str, Any]:
1458
+ """Get margin ratio for a security"""
1459
+ if not init_trade_connection():
1460
+ return {'error': 'Failed to initialize trade connection'}
1461
+ ret, data = trade_ctx.get_margin_ratio(symbol)
1462
+ return handle_return_data(ret, data)
1463
+
1464
+ # Market Information Tools
1465
+ @mcp.tool()
1466
+ async def get_market_state(market: str) -> Dict[str, Any]:
1467
+ """Get market state
1468
+
1469
+ Args:
1470
+ market: Market code, options:
1471
+ - "HK": Hong Kong market (includes pre-market, continuous trading, afternoon, closing auction)
1472
+ - "US": US market (includes pre-market, continuous trading, after-hours)
1473
+ - "SH": Shanghai market (includes pre-opening, morning, afternoon, closing auction)
1474
+ - "SZ": Shenzhen market (includes pre-opening, morning, afternoon, closing auction)
1475
+
1476
+ Returns:
1477
+ Dict containing market state information including:
1478
+ - market: Market code
1479
+ - market_state: Market state code
1480
+ - NONE: Market not available
1481
+ - AUCTION: Auction period
1482
+ - WAITING_OPEN: Waiting for market open
1483
+ - MORNING: Morning session
1484
+ - REST: Lunch break
1485
+ - AFTERNOON: Afternoon session
1486
+ - CLOSED: Market closed
1487
+ - PRE_MARKET_BEGIN: Pre-market begin
1488
+ - PRE_MARKET_END: Pre-market end
1489
+ - AFTER_HOURS_BEGIN: After-hours begin
1490
+ - AFTER_HOURS_END: After-hours end
1491
+ - market_state_desc: Description of market state
1492
+ - update_time: Update time (YYYY-MM-DD HH:mm:ss)
1493
+
1494
+ Raises:
1495
+ - INVALID_PARAM: Invalid parameter
1496
+ - INVALID_MARKET: Invalid market code
1497
+ - GET_MARKET_STATE_FAILED: Failed to get market state
1498
+
1499
+ Note:
1500
+ - Market state is updated in real-time
1501
+ - Different markets have different trading hours
1502
+ - Consider timezone differences
1503
+ - Market state affects trading operations
1504
+ - Recommended to check state before trading
1505
+ """
1506
+ ret, data = quote_ctx.get_market_state(market)
1507
+ return data.to_dict() if ret == RET_OK else {'error': data}
1508
+
1509
+ @mcp.tool()
1510
+ async def get_security_info(market: str, code: str) -> Dict[str, Any]:
1511
+ """Get security information
1512
+
1513
+ Args:
1514
+ market: Market code, options:
1515
+ - "HK": Hong Kong market
1516
+ - "US": US market
1517
+ - "SH": Shanghai market
1518
+ - "SZ": Shenzhen market
1519
+ code: Stock code without market prefix, e.g. "00700" for "HK.00700"
1520
+
1521
+ Returns:
1522
+ Dict containing security information including:
1523
+ - stock_code: Stock code
1524
+ - stock_name: Stock name
1525
+ - market: Market code
1526
+ - stock_type: Stock type (e.g., "STOCK", "ETF", "WARRANT")
1527
+ - stock_child_type: Stock subtype (e.g., "MAIN_BOARD", "GEM")
1528
+ - list_time: Listing date
1529
+ - delist_time: Delisting date (if applicable)
1530
+ - lot_size: Lot size
1531
+ - stock_owner: Company name
1532
+ - issue_price: IPO price
1533
+ - issue_size: IPO size
1534
+ - net_profit: Net profit
1535
+ - net_profit_growth: Net profit growth rate
1536
+ - revenue: Revenue
1537
+ - revenue_growth: Revenue growth rate
1538
+ - eps: Earnings per share
1539
+ - pe_ratio: Price-to-earnings ratio
1540
+ - pb_ratio: Price-to-book ratio
1541
+ - dividend_ratio: Dividend ratio
1542
+ - stock_derivatives: List of related derivatives
1543
+
1544
+ Raises:
1545
+ - INVALID_PARAM: Invalid parameter
1546
+ - INVALID_MARKET: Invalid market code
1547
+ - INVALID_STOCKCODE: Invalid stock code
1548
+ - GET_STOCK_BASICINFO_FAILED: Failed to get stock information
1549
+
1550
+ Note:
1551
+ - Contains static information about the security
1552
+ - Financial data may be delayed
1553
+ - Some fields may be empty for certain security types
1554
+ - Important for fundamental analysis
1555
+ """
1556
+ ret, data = quote_ctx.get_security_info(market, code)
1557
+ return data.to_dict() if ret == RET_OK else {'error': data}
1558
+
1559
+ @mcp.tool()
1560
+ async def get_security_list(market: str) -> Dict[str, Any]:
1561
+ """Get security list
1562
+
1563
+ Args:
1564
+ market: Market code, options:
1565
+ - "HK": Hong Kong market
1566
+ - "US": US market
1567
+ - "SH": Shanghai market
1568
+ - "SZ": Shenzhen market
1569
+
1570
+ Returns:
1571
+ Dict containing list of securities:
1572
+ - security_list: List of securities, each containing:
1573
+ - code: Security code
1574
+ - name: Security name
1575
+ - lot_size: Lot size
1576
+ - stock_type: Security type
1577
+ - list_time: Listing date
1578
+ - stock_id: Security ID
1579
+ - delisting: Whether delisted
1580
+ - main_contract: Whether it's the main contract (futures)
1581
+ - last_trade_time: Last trade time (futures/options)
1582
+
1583
+ Raises:
1584
+ - INVALID_PARAM: Invalid parameter
1585
+ - INVALID_MARKET: Invalid market code
1586
+ - GET_SECURITY_LIST_FAILED: Failed to get security list
1587
+
1588
+ Note:
1589
+ - Returns all securities in the specified market
1590
+ - Includes stocks, ETFs, warrants, etc.
1591
+ - Updated daily
1592
+ - Useful for market analysis and monitoring
1593
+ - Consider caching results for better performance
1594
+ """
1595
+ ret, data = quote_ctx.get_security_list(market)
1596
+ return data.to_dict() if ret == RET_OK else {'error': data}
1597
+
1598
+ # Prompts
1599
+ @mcp.prompt()
1600
+ async def market_analysis(symbol: str) -> str:
1601
+ """Create a market analysis prompt"""
1602
+ return f"Please analyze the market data for {symbol}"
1603
+
1604
+ @mcp.prompt()
1605
+ async def option_strategy(symbol: str, expiry: str) -> str:
1606
+ """Create an option strategy analysis prompt"""
1607
+ return f"Please analyze option strategies for {symbol} expiring on {expiry}"
1608
+
1609
+ @mcp.tool()
1610
+ async def get_stock_filter(base_filters: List[Dict[str, Any]] = None,
1611
+ accumulate_filters: List[Dict[str, Any]] = None,
1612
+ financial_filters: List[Dict[str, Any]] = None,
1613
+ market: str = None,
1614
+ page: int = 1,
1615
+ page_size: int = 200) -> Dict[str, Any]:
1616
+ """Get filtered stock list based on conditions
1617
+
1618
+ Args:
1619
+ base_filters: List of base filters with structure:
1620
+ {
1621
+ "field_name": int, # StockField enum value
1622
+ "filter_min": float, # Optional minimum value
1623
+ "filter_max": float, # Optional maximum value
1624
+ "is_no_filter": bool, # Optional, whether to skip filtering
1625
+ "sort_dir": int # Optional, sort direction (0: No sort, 1: Ascending, 2: Descending)
1626
+ }
1627
+ accumulate_filters: List of accumulate filters with structure:
1628
+ {
1629
+ "field_name": int, # AccumulateField enum value
1630
+ "filter_min": float,
1631
+ "filter_max": float,
1632
+ "is_no_filter": bool,
1633
+ "sort_dir": int, # 0: No sort, 1: Ascending, 2: Descending
1634
+ "days": int # Required, number of days to accumulate
1635
+ }
1636
+ financial_filters: List of financial filters with structure:
1637
+ {
1638
+ "field_name": int, # FinancialField enum value
1639
+ "filter_min": float,
1640
+ "filter_max": float,
1641
+ "is_no_filter": bool,
1642
+ "sort_dir": int, # 0: No sort, 1: Ascending, 2: Descending
1643
+ "quarter": int # Required, financial quarter
1644
+ }
1645
+ market: Market code, options:
1646
+ - "HK.Motherboard": Hong Kong Main Board
1647
+ - "HK.GEM": Hong Kong GEM
1648
+ - "HK.BK1911": H-Share Main Board
1649
+ - "HK.BK1912": H-Share GEM
1650
+ - "US.NYSE": NYSE
1651
+ - "US.AMEX": AMEX
1652
+ - "US.NASDAQ": NASDAQ
1653
+ - "SH.3000000": Shanghai Main Board
1654
+ - "SZ.3000001": Shenzhen Main Board
1655
+ - "SZ.3000004": Shenzhen ChiNext
1656
+ page: Page number, starting from 1 (default: 1)
1657
+ page_size: Number of results per page, max 200 (default: 200)
1658
+ """
1659
+ # Create filter request
1660
+ req = {
1661
+ "begin": (page - 1) * page_size,
1662
+ "num": page_size
1663
+ }
1664
+
1665
+ # Add market filter if specified
1666
+ if market:
1667
+ req["plate"] = {"plate_code": market}
1668
+
1669
+ # Add base filters
1670
+ if base_filters:
1671
+ req["baseFilterList"] = []
1672
+ for f in base_filters:
1673
+ filter_item = {"fieldName": f["field_name"]}
1674
+ if "filter_min" in f:
1675
+ filter_item["filterMin"] = f["filter_min"]
1676
+ if "filter_max" in f:
1677
+ filter_item["filterMax"] = f["filter_max"]
1678
+ if "is_no_filter" in f:
1679
+ filter_item["isNoFilter"] = f["is_no_filter"]
1680
+ if "sort_dir" in f:
1681
+ filter_item["sortDir"] = f["sort_dir"]
1682
+ req["baseFilterList"].append(filter_item)
1683
+
1684
+ # Add accumulate filters
1685
+ if accumulate_filters:
1686
+ req["accumulateFilterList"] = []
1687
+ for f in accumulate_filters:
1688
+ filter_item = {
1689
+ "fieldName": f["field_name"],
1690
+ "days": f["days"]
1691
+ }
1692
+ if "filter_min" in f:
1693
+ filter_item["filterMin"] = f["filter_min"]
1694
+ if "filter_max" in f:
1695
+ filter_item["filterMax"] = f["filter_max"]
1696
+ if "is_no_filter" in f:
1697
+ filter_item["isNoFilter"] = f["is_no_filter"]
1698
+ if "sort_dir" in f:
1699
+ filter_item["sortDir"] = f["sort_dir"]
1700
+ req["accumulateFilterList"].append(filter_item)
1701
+
1702
+ # Add financial filters
1703
+ if financial_filters:
1704
+ req["financialFilterList"] = []
1705
+ for f in financial_filters:
1706
+ filter_item = {
1707
+ "fieldName": f["field_name"],
1708
+ "quarter": f["quarter"]
1709
+ }
1710
+ if "filter_min" in f:
1711
+ filter_item["filterMin"] = f["filter_min"]
1712
+ if "filter_max" in f:
1713
+ filter_item["filterMax"] = f["filter_max"]
1714
+ if "is_no_filter" in f:
1715
+ filter_item["isNoFilter"] = f["is_no_filter"]
1716
+ if "sort_dir" in f:
1717
+ filter_item["sortDir"] = f["sort_dir"]
1718
+ req["financialFilterList"].append(filter_item)
1719
+
1720
+ ret, data = quote_ctx.get_stock_filter(req)
1721
+ return data.to_dict() if ret == RET_OK else {'error': data}
1722
+
1723
+ @mcp.tool()
1724
+ async def get_current_time() -> Dict[str, Any]:
1725
+ """Get current time information
1726
+
1727
+ Returns:
1728
+ Dict containing time information including:
1729
+ - timestamp: Unix timestamp in seconds
1730
+ - datetime: Formatted datetime string (YYYY-MM-DD HH:mm:ss)
1731
+ - date: Date string (YYYY-MM-DD)
1732
+ - time: Time string (HH:mm:ss)
1733
+ - timezone: Local timezone name
1734
+ """
1735
+ now = datetime.now()
1736
+ return {
1737
+ 'timestamp': int(now.timestamp()),
1738
+ 'datetime': now.strftime('%Y-%m-%d %H:%M:%S'),
1739
+ 'date': now.strftime('%Y-%m-%d'),
1740
+ 'time': now.strftime('%H:%M:%S'),
1741
+ 'timezone': datetime.now().astimezone().tzname()
1742
+ }
1743
+
1744
+ def main():
1745
+ """Main entry point for the futu-mcp-server command."""
1746
+ # Parse command line arguments first
1747
+ parser = argparse.ArgumentParser(
1748
+ description="Futu Stock MCP Server - A Model Context Protocol server for accessing Futu OpenAPI functionality",
1749
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1750
+ epilog="""
1751
+ Examples:
1752
+ futu-mcp-server # Start the MCP server with default settings
1753
+ futu-mcp-server --host 192.168.1.100 --port 11111 # Connect to remote OpenD
1754
+ futu-mcp-server --help # Show this help message
1755
+
1756
+ Arguments:
1757
+ --host # Futu OpenD host (default: 127.0.0.1)
1758
+ --port # Futu OpenD port (default: 11111)
1759
+
1760
+ Environment Variables:
1761
+ FUTU_ENABLE_TRADING # Enable trading features (default: 0)
1762
+ FUTU_TRADE_ENV # Trading environment: SIMULATE or REAL (default: SIMULATE)
1763
+ FUTU_SECURITY_FIRM # Security firm: FUTUSECURITIES or FUTUINC (default: FUTUSECURITIES)
1764
+ FUTU_TRD_MARKET # Trading market: HK or US (default: HK)
1765
+ FUTU_DEBUG_MODE # Enable debug logging (default: 0)
1766
+ """
1767
+ )
1768
+
1769
+ parser.add_argument(
1770
+ '--host',
1771
+ default='127.0.0.1',
1772
+ help='Futu OpenD host address (default: 127.0.0.1)'
1773
+ )
1774
+
1775
+ parser.add_argument(
1776
+ '--port',
1777
+ type=int,
1778
+ default=11111,
1779
+ help='Futu OpenD port number (default: 11111)'
1780
+ )
1781
+
1782
+ parser.add_argument(
1783
+ '--version',
1784
+ action='version',
1785
+ version='futu-stock-mcp-server 0.1.3'
1786
+ )
1787
+
1788
+ args = parser.parse_args()
1789
+
1790
+ try:
1791
+ # CRITICAL: Set MCP mode BEFORE any logging to ensure clean stdout
1792
+ os.environ['MCP_MODE'] = '1'
1793
+
1794
+ # Ensure no color output or ANSI escape sequences in MCP mode
1795
+ os.environ['NO_COLOR'] = '1'
1796
+ os.environ['TERM'] = 'dumb'
1797
+ os.environ['FORCE_COLOR'] = '0'
1798
+ os.environ['COLORTERM'] = ''
1799
+ os.environ['ANSI_COLORS_DISABLED'] = '1'
1800
+ os.environ['PYTHONUNBUFFERED'] = '1'
1801
+ os.environ['PYTHONIOENCODING'] = 'utf-8'
1802
+
1803
+ # Disable Python buffering to ensure clean MCP JSON communication
1804
+
1805
+ # Clean up stale processes and acquire lock
1806
+ cleanup_stale_processes()
1807
+
1808
+ lock_fd = acquire_lock()
1809
+ if lock_fd is None:
1810
+ # Use file logging only - no stderr output in MCP mode
1811
+ logger.error("Failed to acquire lock. Another instance may be running.")
1812
+ sys.exit(1)
1813
+
1814
+ # Set up signal handlers
1815
+ signal.signal(signal.SIGINT, signal_handler)
1816
+ signal.signal(signal.SIGTERM, signal_handler)
1817
+
1818
+ # Initialize Futu connection with file logging only
1819
+ logger.info("Initializing Futu connection for MCP server...")
1820
+ if init_futu_connection(args.host, args.port):
1821
+ logger.info("Successfully initialized Futu connection")
1822
+ logger.info("Starting MCP server in stdio mode - stdout reserved for JSON communication")
1823
+
1824
+ try:
1825
+ # Run MCP server - stdout will be used for JSON communication only
1826
+ logger.info("About to call mcp.run() without transport parameter")
1827
+ mcp.run()
1828
+ logger.info("mcp.run() completed successfully")
1829
+ except KeyboardInterrupt:
1830
+ logger.info("Received keyboard interrupt, shutting down gracefully...")
1831
+ cleanup_all()
1832
+ os._exit(0)
1833
+ except Exception as e:
1834
+ logger.error(f"Error running MCP server: {str(e)}")
1835
+ cleanup_all()
1836
+ os._exit(1)
1837
+ else:
1838
+ logger.error("Failed to initialize Futu connection. MCP server will not start.")
1839
+ os._exit(1)
1840
+
1841
+ except Exception as e:
1842
+ # In MCP mode, we should avoid printing to stdout
1843
+ # Log to file only
1844
+ logger.error(f"Error starting MCP server: {str(e)}")
1845
+ sys.exit(1)
1846
+ finally:
1847
+ cleanup_all()
1848
+
1849
+ if __name__ == "__main__":
1850
+ main()