xtquant-share 1.1.2__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.
xqshare/client.py ADDED
@@ -0,0 +1,682 @@
1
+ """
2
+ XtQuant Share (xqshare) Client - Transparent remote proxy for xtquant
3
+ """
4
+
5
+ import os
6
+ import rpyc
7
+ import time
8
+ import threading
9
+ import ssl
10
+ import logging
11
+ import json
12
+ from typing import Any, Callable, Dict, List
13
+ from datetime import datetime
14
+
15
+ # 默认客户端配置(与服务端保持一致)
16
+ DEFAULT_CLIENT_ID = "client-standard"
17
+ DEFAULT_CLIENT_SECRET = "xqshare-default-secret"
18
+
19
+
20
+ # ==================== 日志配置 ====================
21
+
22
+ def setup_logging(log_level: str = "INFO", quiet: bool = False):
23
+ """配置客户端日志
24
+
25
+ Args:
26
+ log_level: 日志级别
27
+ quiet: 是否静默模式(不输出控制台日志)
28
+ """
29
+ # 日志目录:优先使用环境变量,默认为工作目录的 logs
30
+ log_dir = os.environ.get("XQSHARE_LOG_DIR", "logs")
31
+ os.makedirs(log_dir, exist_ok=True)
32
+
33
+ formatter = logging.Formatter(
34
+ fmt='%(asctime)s.%(msecs)03d | %(levelname)-8s | %(message)s',
35
+ datefmt='%Y-%m-%d %H:%M:%S'
36
+ )
37
+
38
+ root_logger = logging.getLogger('xtquant_client')
39
+ root_logger.setLevel(getattr(logging, log_level.upper()))
40
+
41
+ # 静默模式下不添加控制台 handler
42
+ if not quiet:
43
+ console_handler = logging.StreamHandler()
44
+ console_handler.setFormatter(formatter)
45
+ console_handler.setLevel(logging.INFO)
46
+ root_logger.addHandler(console_handler)
47
+
48
+ file_handler = logging.FileHandler(
49
+ os.path.join(log_dir, f"client_{datetime.now().strftime('%Y%m%d')}.log"),
50
+ encoding='utf-8'
51
+ )
52
+ file_handler.setFormatter(formatter)
53
+ file_handler.setLevel(logging.DEBUG)
54
+ root_logger.addHandler(file_handler)
55
+
56
+ return root_logger
57
+
58
+
59
+ _logger = None
60
+ _quiet_mode = False
61
+
62
+
63
+ def set_quiet_mode(quiet: bool = True):
64
+ """设置静默模式"""
65
+ global _quiet_mode
66
+ _quiet_mode = quiet
67
+
68
+
69
+ def get_logger():
70
+ global _logger
71
+ if _logger is None:
72
+ _logger = setup_logging(quiet=_quiet_mode)
73
+ return _logger
74
+
75
+
76
+ # ==================== 反序列化传输数据 ====================
77
+
78
+ SERIALIZED_MARKER = "__xqshare_serialized__"
79
+
80
+
81
+ def _deserialize_from_transfer(result):
82
+ """反序列化服务端优化传输的数据
83
+
84
+ Args:
85
+ result: 服务端返回的数据
86
+
87
+ Returns:
88
+ 反序列化后的 Python 对象
89
+ """
90
+ # 检查是否为序列化数据
91
+ if not isinstance(result, dict) or SERIALIZED_MARKER not in result:
92
+ return result
93
+
94
+ serialized_type = result[SERIALIZED_MARKER]
95
+ data = result["data"]
96
+
97
+ if serialized_type == "none":
98
+ return None
99
+
100
+ if serialized_type == "json":
101
+ return json.loads(data)
102
+
103
+ if serialized_type == "dataframe_csv":
104
+ import io
105
+ try:
106
+ import pandas as pd
107
+ return pd.read_csv(io.StringIO(data), index_col=0)
108
+ except ImportError:
109
+ # 无 pandas 时返回原始 CSV 字符串
110
+ return data
111
+
112
+ if serialized_type == "dict_with_dataframe":
113
+ import io
114
+ try:
115
+ import pandas as pd
116
+
117
+ def deserialize_dataframes(obj):
118
+ """递归反序列化 DataFrame"""
119
+ if isinstance(obj, dict):
120
+ if obj.get("__df__"):
121
+ return pd.read_csv(io.StringIO(obj["csv"]), index_col=0)
122
+ return {k: deserialize_dataframes(v) for k, v in obj.items()}
123
+ if isinstance(obj, list):
124
+ return [deserialize_dataframes(item) for item in obj]
125
+ return obj
126
+
127
+ deserialized = json.loads(data)
128
+ return deserialize_dataframes(deserialized)
129
+ except ImportError:
130
+ # 无 pandas 时返回原始 JSON
131
+ return json.loads(data)
132
+
133
+ # 未知类型,返回原始数据
134
+ return result
135
+
136
+
137
+ # ==================== 异常定义 ====================
138
+
139
+ class ConnectionError(Exception):
140
+ """连接错误"""
141
+ pass
142
+
143
+
144
+ class AuthenticationError(Exception):
145
+ """认证错误"""
146
+ pass
147
+
148
+
149
+ class CallbackError(Exception):
150
+ """回调错误"""
151
+ pass
152
+
153
+
154
+ # ==================== 重连策略 ====================
155
+
156
+ class ReconnectPolicy:
157
+ """重连策略"""
158
+
159
+ def __init__(self, max_retries=5, base_delay=1, max_delay=30, backoff_factor=2):
160
+ self.max_retries = max_retries
161
+ self.base_delay = base_delay
162
+ self.max_delay = max_delay
163
+ self.backoff_factor = backoff_factor
164
+
165
+ def get_delay(self, retry_count):
166
+ delay = self.base_delay * (self.backoff_factor ** retry_count)
167
+ return min(delay, self.max_delay)
168
+
169
+
170
+ # ==================== 后台服务线程 ====================
171
+
172
+ from rpyc.utils.helpers import BgServingThread
173
+
174
+
175
+ # ==================== 远程模块代理 ====================
176
+
177
+ class RemoteModule:
178
+ """远程模块代理 - 完全透明的动态代理"""
179
+
180
+ def __init__(self, client, module_name, module=None):
181
+ self._client = client
182
+ self._module_name = module_name
183
+ self._module = module # 支持直接传入对象
184
+ self._logger = get_logger()
185
+
186
+ def _ensure_module(self):
187
+ if self._module is None:
188
+ self._client._ensure_connected()
189
+ try:
190
+ method = getattr(self._client._conn.root, f'get_{self._module_name}')
191
+ self._module = method()
192
+ except Exception as e:
193
+ self._module = None
194
+ raise
195
+ return self._module
196
+
197
+ def __getattr__(self, name):
198
+ module = self._ensure_module()
199
+ try:
200
+ attr = getattr(module, name)
201
+ if callable(attr):
202
+ return self._wrap_call(attr, name)
203
+ return attr
204
+ except Exception as e:
205
+ if self._client._should_reconnect(e):
206
+ self._module = None
207
+ module = self._ensure_module()
208
+ attr = getattr(module, name)
209
+ if callable(attr):
210
+ return self._wrap_call(attr, name)
211
+ return attr
212
+ raise
213
+
214
+ def _wrap_call(self, func, func_name: str):
215
+ def wrapper(*args, **kwargs):
216
+ start_time = time.perf_counter()
217
+ args_str = self._summarize_args(args, kwargs)
218
+ self._logger.info(f"[CALL] {self._module_name}.{func_name}({args_str})")
219
+
220
+ try:
221
+ result = func(*args, **kwargs)
222
+ # 反序列化服务端优化传输的数据
223
+ result = _deserialize_from_transfer(result)
224
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
225
+ result_summary = self._summarize_result(result)
226
+ self._logger.info(f"[OK] {self._module_name}.{func_name} | {elapsed_ms:.2f}ms | {result_summary}")
227
+ return result
228
+ except Exception as e:
229
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
230
+ self._logger.error(f"[ERROR] {self._module_name}.{func_name} | {elapsed_ms:.2f}ms | {type(e).__name__}: {e}")
231
+ raise
232
+
233
+ # 手动设置属性,避免 Python 3.13 functools.wraps 的 __annotations__ 兼容性问题
234
+ wrapper.__name__ = func_name
235
+ wrapper.__qualname__ = f"{self._module_name}.{func_name}"
236
+ return wrapper
237
+
238
+ def _summarize_args(self, args, kwargs, max_len: int = 100) -> str:
239
+ parts = []
240
+ if args:
241
+ for arg in args[:3]:
242
+ try:
243
+ s = str(arg)[:30]
244
+ parts.append(s)
245
+ except:
246
+ parts.append("?")
247
+ if len(args) > 3:
248
+ parts.append(f"...+{len(args)-3}")
249
+ if kwargs:
250
+ for k, v in list(kwargs.items())[:2]:
251
+ try:
252
+ s = f"{k}={str(v)[:20]}"
253
+ parts.append(s)
254
+ except:
255
+ parts.append(f"{k}=?")
256
+ if len(kwargs) > 2:
257
+ parts.append(f"...+{len(kwargs)-2}")
258
+ return ", ".join(parts)[:max_len]
259
+
260
+ def _summarize_result(self, result, max_len: int = 100) -> str:
261
+ try:
262
+ if result is None:
263
+ return "None"
264
+ elif isinstance(result, (int, float, bool)):
265
+ return str(result)
266
+ elif isinstance(result, str):
267
+ return result[:max_len] if len(result) > max_len else result
268
+ elif isinstance(result, (list, tuple)):
269
+ return f"{type(result).__name__}[len={len(result)}]"
270
+ elif isinstance(result, dict):
271
+ return f"dict[{len(result)} keys]"
272
+ else:
273
+ return f"<{type(result).__name__}>"
274
+ except:
275
+ return "?"
276
+
277
+ def __dir__(self):
278
+ module = self._ensure_module()
279
+ return dir(module)
280
+
281
+
282
+ # ==================== 主客户端类 ====================
283
+
284
+ class XtQuantRemote:
285
+ """
286
+ 远程 xtquant 完全透明代理
287
+
288
+ 功能:
289
+ - 自动认证
290
+ - 断线自动重连
291
+ - SSL 加密(可选)
292
+ - 异步回调支持
293
+ - API调用日志
294
+
295
+ 使用示例:
296
+ xt = XtQuantRemote("192.168.1.100")
297
+ stocks = xt.xtdata.get_stock_list_in_sector("沪深A股")
298
+ xt.close()
299
+
300
+ with XtQuantRemote("192.168.1.100") as xt:
301
+ df = xt.xtdata.get_market_data(["000001.SZ"])
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ host=None,
307
+ port=None,
308
+ client_id=None,
309
+ client_secret=None,
310
+ use_ssl=False,
311
+ ssl_verify=True,
312
+ auto_reconnect=True,
313
+ max_retries=5,
314
+ heartbeat_interval=30,
315
+ log_level="INFO",
316
+ env_file=None,
317
+ ):
318
+ # 加载环境变量文件(None 时自动查找 .env)
319
+ try:
320
+ from dotenv import load_dotenv
321
+ load_dotenv(env_file)
322
+ except ImportError:
323
+ pass
324
+
325
+ # 支持环境变量:显式参数 > 环境变量 > 默认值
326
+ if host is None:
327
+ host = os.environ.get("XQSHARE_REMOTE_HOST", "localhost")
328
+ if port is None:
329
+ port = int(os.environ.get("XQSHARE_REMOTE_PORT", "18812"))
330
+ if client_id is None:
331
+ client_id = os.environ.get("XQSHARE_CLIENT_ID", DEFAULT_CLIENT_ID)
332
+ if client_secret is None:
333
+ client_secret = os.environ.get("XQSHARE_CLIENT_SECRET", DEFAULT_CLIENT_SECRET)
334
+
335
+ self._host = host
336
+ self._port = port
337
+ self._client_id = client_id
338
+ self._client_secret = client_secret
339
+ self._use_ssl = use_ssl
340
+ self._ssl_verify = ssl_verify
341
+ self._auto_reconnect = auto_reconnect
342
+ self._reconnect_policy = ReconnectPolicy(max_retries=max_retries)
343
+ self._heartbeat_interval = heartbeat_interval
344
+ self._log_level = log_level
345
+
346
+ self._conn = None
347
+ self._authenticated = False
348
+ self._connected = False
349
+ self._reconnecting = False
350
+ self._heartbeat_thread = None
351
+ self._stop_heartbeat = threading.Event()
352
+ self._bg_thread = None # BgServingThread for async callbacks
353
+ self._account_level = None # 账号等级
354
+ self._subscriptions = [] # 订阅列表,重连时恢复用
355
+
356
+ self._xtdata = RemoteModule(self, 'xtdata')
357
+ self._xttype = RemoteModule(self, 'xttype')
358
+ self._xtconstant = RemoteModule(self, 'xtconstant')
359
+ self._xtview = RemoteModule(self, 'xtview')
360
+ self._datadir = RemoteModule(self, 'datadir')
361
+ self._logger = get_logger()
362
+
363
+ self._connect()
364
+
365
+ def _should_reconnect(self, error):
366
+ if not self._auto_reconnect:
367
+ return False
368
+ error_str = str(error).lower()
369
+ hints = ['connection', 'closed', 'reset', 'broken', 'timeout', 'refused', 'eof', 'socket']
370
+ return any(h in error_str for h in hints)
371
+
372
+ def _create_ssl_context(self):
373
+ if not self._use_ssl:
374
+ return None
375
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
376
+ if self._ssl_verify:
377
+ ctx.verify_mode = ssl.CERT_REQUIRED
378
+ ctx.check_hostname = True
379
+ else:
380
+ ctx.check_hostname = False
381
+ ctx.verify_mode = ssl.CERT_NONE
382
+ return ctx
383
+
384
+ def _connect(self):
385
+ config = {
386
+ 'allow_public_attrs': True,
387
+ 'allow_pickle': True,
388
+ 'allow_getattr': True,
389
+ 'allow_setattr': True,
390
+ 'allow_delattr': True,
391
+ 'allow_all_attrs': True,
392
+ 'sync_request_timeout': 300,
393
+ }
394
+
395
+ ssl_context = self._create_ssl_context()
396
+
397
+ try:
398
+ # 尝试新版本 rpyc API
399
+ try:
400
+ self._conn = rpyc.connect(self._host, self._port, config=config, ssl_context=ssl_context)
401
+ except TypeError:
402
+ # 旧版本 rpyc 不支持 ssl_context 参数
403
+ if ssl_context and self._use_ssl:
404
+ # 使用 SSL 包装 socket
405
+ import socket
406
+ sock = socket.create_connection((self._host, self._port))
407
+ sock = ssl_context.wrap_socket(sock, server_hostname=self._host)
408
+ self._conn = rpyc.connect_stream(sock, config=config)
409
+ else:
410
+ self._conn = rpyc.connect(self._host, self._port, config=config)
411
+
412
+ self._connected = True
413
+
414
+ # 启动后台服务线程处理异步回调
415
+ self._bg_thread = BgServingThread(self._conn)
416
+ self._logger.debug("后台服务线程已启动")
417
+
418
+ if self._client_secret:
419
+ result = self._conn.root.authenticate(self._client_id, self._client_secret)
420
+ # 处理认证响应(支持新格式)
421
+ if isinstance(result, dict):
422
+ self._account_level = result.get("level", "free")
423
+ self._logger.info(f"认证成功: client_id={self._client_id} | level={self._account_level}")
424
+ else:
425
+ self._logger.info(f"认证成功: client_id={self._client_id}")
426
+
427
+ if self._heartbeat_interval > 0:
428
+ self._start_heartbeat()
429
+
430
+ self._logger.info(f"连接成功: {self._host}:{self._port}")
431
+ except Exception as e:
432
+ self._connected = False
433
+ raise ConnectionError(f"连接失败: {e}")
434
+
435
+ def _ensure_connected(self):
436
+ if self._connected and self._conn:
437
+ return
438
+ if not self._auto_reconnect:
439
+ raise ConnectionError("连接已断开,自动重连已禁用")
440
+ self._reconnect()
441
+
442
+ def _reconnect(self):
443
+ if self._reconnecting:
444
+ for _ in range(10):
445
+ time.sleep(0.5)
446
+ if self._connected:
447
+ return
448
+ raise ConnectionError("重连超时")
449
+
450
+ self._reconnecting = True
451
+ retry_count = 0
452
+
453
+ try:
454
+ while retry_count < self._reconnect_policy.max_retries:
455
+ try:
456
+ self._logger.info(f"重连中... 第 {retry_count + 1} 次尝试")
457
+
458
+ if self._conn:
459
+ try:
460
+ self._conn.close()
461
+ except:
462
+ pass
463
+
464
+ self._conn = None
465
+ self._connected = False
466
+ self._token = None
467
+ self._connect()
468
+
469
+ # 重连后重置所有 RemoteModule 的内部缓存,下次调用时自动重新获取远程对象
470
+ self._xtdata._module = None
471
+ self._xttype._module = None
472
+ self._xtconstant._module = None
473
+ self._xtview._module = None
474
+ self._datadir._module = None
475
+
476
+ for sub in self._subscriptions:
477
+ if sub._active:
478
+ try:
479
+ sub.start()
480
+ except:
481
+ pass
482
+
483
+ self._logger.info("重连成功")
484
+ return
485
+ except Exception as e:
486
+ retry_count += 1
487
+ delay = self._reconnect_policy.get_delay(retry_count - 1)
488
+ self._logger.warning(f"重连失败: {e},{delay}秒后重试...")
489
+ time.sleep(delay)
490
+
491
+ raise ConnectionError(f"重连失败,已尝试 {retry_count} 次")
492
+ finally:
493
+ self._reconnecting = False
494
+
495
+ def _start_heartbeat(self):
496
+ if self._heartbeat_thread and self._heartbeat_thread.is_alive():
497
+ return
498
+ self._stop_heartbeat.clear()
499
+ self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
500
+ self._heartbeat_thread.start()
501
+
502
+ def _heartbeat_loop(self):
503
+ while not self._stop_heartbeat.is_set():
504
+ try:
505
+ if self._connected and self._conn:
506
+ try:
507
+ self._conn.root.heartbeat()
508
+ except Exception as e:
509
+ if self._auto_reconnect:
510
+ self._logger.warning(f"心跳失败: {e},尝试重连...")
511
+ try:
512
+ self._reconnect()
513
+ except:
514
+ pass
515
+ except Exception:
516
+ pass
517
+ self._stop_heartbeat.wait(self._heartbeat_interval)
518
+
519
+ def _stop_heartbeat_thread(self):
520
+ self._stop_heartbeat.set()
521
+ if self._heartbeat_thread:
522
+ self._heartbeat_thread.join(timeout=2)
523
+
524
+ # ==================== 公共接口 ====================
525
+
526
+ @property
527
+ def xtdata(self):
528
+ return self._xtdata
529
+
530
+ @property
531
+ def xttype(self):
532
+ return self._xttype
533
+
534
+ @property
535
+ def xtconstant(self):
536
+ return self._xtconstant
537
+
538
+ @property
539
+ def xtview(self):
540
+ return self._xtview
541
+
542
+ @property
543
+ def datadir(self):
544
+ """QMT datadir 文件解析能力代理。
545
+
546
+ 通过 RPyC 远程调用 Windows 端的 QmtDataReader,
547
+ 接口风格与 xtdata 完全一致。
548
+
549
+ Raises:
550
+ RuntimeError: 当 server 端未配置 QMT_DATADIR_PATH 时抛出
551
+ """
552
+ return self._datadir
553
+
554
+ def create_trader(self, userdata_path: str = None, session_id: int = None):
555
+ """
556
+ 创建交易实例
557
+
558
+ Args:
559
+ userdata_path: QMT 客户端 userdata_mini 目录路径
560
+ (可选,默认从环境变量 QMT_USERDATA_PATH 读取)
561
+ session_id: 会话ID(可选,默认自动生成时间戳)
562
+
563
+ Returns:
564
+ XtQuantTrader 实例
565
+
566
+ Example:
567
+ # 方式1:使用环境变量
568
+ export QMT_USERDATA_PATH="C:\\QMT\\userdata_mini"
569
+ trader = xt.create_trader()
570
+
571
+ # 方式2:直接传参
572
+ trader = xt.create_trader("C:\\QMT\\userdata_mini")
573
+ """
574
+ self._ensure_connected()
575
+ trader = self._conn.root.create_trader(userdata_path, session_id)
576
+ # 用 RemoteModule 包装,添加日志和反序列化支持
577
+ return RemoteModule(self, 'xttrader', trader)
578
+
579
+ def get_all_stocks(self):
580
+ self._ensure_connected()
581
+ return self._conn.root.get_all_stocks()
582
+
583
+ def get_index_list(self):
584
+ self._ensure_connected()
585
+ return self._conn.root.get_index_list()
586
+
587
+ def download_history_data2(self, stock_list: list, period: str = "1d",
588
+ start_time: str = "", end_time: str = "", incrementally: bool = None):
589
+ """
590
+ 下载历史数据(服务端封装版本,返回完整状态)
591
+
592
+ 返回: {'finished': n, 'total': n, 'done': bool, 'message': str, 'result': {}}
593
+ """
594
+ self._ensure_connected()
595
+ return self._conn.root.download_history_data2(stock_list, period, start_time, end_time, incrementally)
596
+
597
+ def is_connected(self):
598
+ return self._connected
599
+
600
+ def get_service_status(self):
601
+ self._ensure_connected()
602
+ return self._conn.root.get_service_status()
603
+
604
+ def reconnect(self):
605
+ self._reconnect()
606
+
607
+ def close(self):
608
+ self._stop_heartbeat_thread()
609
+ if self._bg_thread:
610
+ self._bg_thread.stop()
611
+ self._bg_thread = None
612
+ self._connected = False
613
+ if self._conn:
614
+ try:
615
+ self._conn.close()
616
+ except:
617
+ pass
618
+ self._conn = None
619
+ self._logger.info("连接已关闭")
620
+
621
+ def __enter__(self):
622
+ return self
623
+
624
+ def __exit__(self, *args):
625
+ self.close()
626
+
627
+ def __repr__(self):
628
+ status = "已连接" if self._connected else "已断开"
629
+ ssl_status = "SSL" if self._use_ssl else "明文"
630
+ return f"<XtQuantRemote {self._host}:{self._port} [{status}] [{ssl_status}]>"
631
+
632
+
633
+ # ==================== 全局便捷函数 ====================
634
+
635
+ _global_client = None
636
+
637
+ def connect(host=None, port=None, **kwargs):
638
+ """创建全局连接
639
+
640
+ 支持环境变量配置:
641
+ - XQSHARE_REMOTE_HOST: 服务端地址
642
+ - XQSHARE_REMOTE_PORT: 服务端端口
643
+ - XQSHARE_CLIENT_ID: 客户端标识
644
+ - XQSHARE_CLIENT_SECRET: 客户端密钥
645
+
646
+ 优先级:显式参数 > 环境变量 > 默认值
647
+ """
648
+ global _global_client
649
+ if host is None:
650
+ host = os.environ.get("XQSHARE_REMOTE_HOST", "localhost")
651
+ if port is None:
652
+ port = int(os.environ.get("XQSHARE_REMOTE_PORT", "18812"))
653
+ _global_client = XtQuantRemote(host, port, **kwargs)
654
+ return _global_client
655
+
656
+ def disconnect():
657
+ """断开全局连接"""
658
+ global _global_client
659
+ if _global_client:
660
+ _global_client.close()
661
+ _global_client = None
662
+
663
+ def get_client():
664
+ """获取全局客户端"""
665
+ return _global_client
666
+
667
+
668
+ class _ModuleProxy:
669
+ def __init__(self, name):
670
+ self._name = name
671
+
672
+ def __getattr__(self, attr):
673
+ if _global_client is None:
674
+ raise RuntimeError("请先调用 connect() 建立连接")
675
+ return getattr(getattr(_global_client, self._name), attr)
676
+
677
+
678
+ xtdata = _ModuleProxy('xtdata')
679
+ xttrader = _ModuleProxy('xttrader')
680
+ xttype = _ModuleProxy('xttype')
681
+ xtview = _ModuleProxy('xtview')
682
+ datadir = _ModuleProxy('datadir')