httptrading 1.0.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.
@@ -0,0 +1,6 @@
1
+ from httptrading.broker.base import *
2
+ from httptrading.broker.futu import *
3
+ from httptrading.broker.longbridge import *
4
+ from httptrading.broker.tiger import *
5
+ from httptrading.broker.interactive_brokers import *
6
+ from httptrading.http_server import run
File without changes
@@ -0,0 +1,151 @@
1
+ import asyncio
2
+ import importlib
3
+ from abc import ABC
4
+ from typing import Type, Callable, Any
5
+ from httptrading.model import *
6
+
7
+
8
+ class BaseBroker(ABC):
9
+ def __init__(self, broker_args: dict = None, instance_id: str = None, tokens: list[str] = None):
10
+ self.detect_package()
11
+ self.broker_args = broker_args or dict()
12
+ self.instance_id = instance_id or ''
13
+ self.tokens: set[str] = set(tokens or list())
14
+ assert '' not in self.tokens
15
+
16
+ @property
17
+ def broker_name(self):
18
+ return BrokerRegister.get_meta(type(self)).name
19
+
20
+ @property
21
+ def broker_display(self):
22
+ return BrokerRegister.get_meta(type(self)).display
23
+
24
+ def detect_package(self):
25
+ pkg_info = BrokerRegister.get_meta(type(self)).detect_package
26
+ if pkg_info is None:
27
+ return
28
+ try:
29
+ importlib.import_module(pkg_info.import_name)
30
+ except ImportError:
31
+ raise ImportError(f'需要安装依赖包: {pkg_info.pkg_name}')
32
+
33
+ async def start(self):
34
+ pass
35
+
36
+ async def shutdown(self):
37
+ pass
38
+
39
+ async def call_sync(self, f: Callable[[], Any]):
40
+ try:
41
+ r = await asyncio.get_running_loop().run_in_executor(None, f)
42
+ return r
43
+ except Exception as ex:
44
+ raise BrokerError(self, ex)
45
+
46
+ async def call_async(self, coro):
47
+ try:
48
+ r = await coro
49
+ return r
50
+ except Exception as ex:
51
+ raise BrokerError(self, ex)
52
+
53
+ async def place_order(
54
+ self,
55
+ contract: Contract,
56
+ order_type: OrderType,
57
+ time_in_force: TimeInForce,
58
+ lifecycle: Lifecycle,
59
+ direction: str,
60
+ qty: int,
61
+ price: float = None,
62
+ **kwargs
63
+ ) -> str:
64
+ raise NotImplementedError
65
+
66
+ async def order(self, order_id: str) -> Order:
67
+ raise NotImplementedError
68
+
69
+ async def cancel_order(self, order_id: str):
70
+ raise NotImplementedError
71
+
72
+ async def positions(self) -> list[Position]:
73
+ raise NotImplementedError
74
+
75
+ async def cash(self) -> Cash:
76
+ raise NotImplementedError
77
+
78
+ async def ping(self) -> bool:
79
+ return True
80
+
81
+ async def quote(self, contract: Contract) -> Quote:
82
+ raise NotImplementedError
83
+
84
+ async def market_status(self) -> dict[str, dict[str, MarketStatus]]:
85
+ raise NotImplementedError
86
+
87
+
88
+ class SecuritiesBroker(BaseBroker):
89
+ @classmethod
90
+ def contract_to_tz(cls, contract: Contract) -> str:
91
+ region = contract.region
92
+ match region:
93
+ case 'CN':
94
+ return 'Asia/Shanghai'
95
+ case 'HK':
96
+ return 'Asia/Hong_Kong'
97
+ case 'US':
98
+ return 'US/Eastern'
99
+ case _:
100
+ raise Exception(f'不能映射{contract}为已知时区')
101
+
102
+ @classmethod
103
+ def contract_to_currency(cls, contract: Contract) -> str | None:
104
+ region = contract.region
105
+ match region:
106
+ case 'CN':
107
+ return 'CNY'
108
+ case 'HK':
109
+ return 'HKD'
110
+ case 'US':
111
+ return 'USD'
112
+ case _:
113
+ raise Exception(f'不能映射{contract}为已知币种')
114
+
115
+
116
+ class BrokerRegister:
117
+ _D: dict[str, Type[BaseBroker]] = dict()
118
+ _META: dict[str, BrokerMeta] = dict()
119
+
120
+ @classmethod
121
+ def register(cls, broker_class: Type[BaseBroker], name: str, display: str, detect_pkg: DetectPkg = None):
122
+ type_name = broker_class.__name__
123
+ if type_name in cls._D:
124
+ raise ValueError(f"Duplicate broker class name '{type_name}'")
125
+ cls._D[type_name] = broker_class
126
+ cls._META[type_name] = BrokerMeta(name=name, display=display, detect_package=detect_pkg)
127
+
128
+ @classmethod
129
+ def get_meta(cls, broker_class: Type[BaseBroker]) -> BrokerMeta:
130
+ return BrokerRegister._META.get(broker_class.__name__)
131
+
132
+
133
+ def broker_register(name: str, display: str, detect_pkg: DetectPkg = None):
134
+ def decorator(cls: Type[BaseBroker]):
135
+ BrokerRegister.register(cls, name, display, detect_pkg)
136
+ return cls
137
+ return decorator
138
+
139
+
140
+ class BrokerError(ValueError):
141
+ def __init__(self, broker: BaseBroker, ex, *args, **kwargs):
142
+ self.broker = broker
143
+ super().__init__(f'[{broker.instance_id}]<{broker.broker_name} {broker.broker_display}>异常: {ex}')
144
+
145
+
146
+ __all__ = [
147
+ 'BaseBroker',
148
+ 'SecuritiesBroker',
149
+ 'broker_register',
150
+ 'BrokerError',
151
+ ]
@@ -0,0 +1,418 @@
1
+ """
2
+ 接入富途证券的API文档
3
+ https://openapi.futunn.com/futu-api-doc/
4
+ """
5
+ import re
6
+ from datetime import datetime
7
+ from httptrading.tool.leaky_bucket import *
8
+ from httptrading.broker.base import *
9
+ from httptrading.model import *
10
+ from httptrading.tool.time import *
11
+
12
+
13
+ @broker_register(name='futu', display='富途证券', detect_pkg=DetectPkg('futu-api', 'futu'))
14
+ class Futu(SecuritiesBroker):
15
+ def __init__(self, broker_args: dict = None, instance_id: str = None, tokens: list[str] = None):
16
+ super().__init__(broker_args, instance_id, tokens)
17
+ self._unlock_pin = ''
18
+ self._trd_env = 'REAL'
19
+ self._quote_client = None
20
+ self._trade_client = None
21
+ self._market_status_bucket = LeakyBucket(10)
22
+ self._snapshot_bucket = LeakyBucket(120)
23
+ self._assets_bucket = LeakyBucket(20)
24
+ self._position_bucket = LeakyBucket(20)
25
+ self._unlock_bucket = LeakyBucket(20)
26
+ self._place_order_bucket = LeakyBucket(30)
27
+ self._cancel_order_bucket = LeakyBucket(30)
28
+ self._refresh_order_bucket = LeakyBucket(20)
29
+ self._on_init()
30
+
31
+ def _on_init(self):
32
+ from futu import SysConfig, OpenQuoteContext, OpenSecTradeContext, SecurityFirm, TrdMarket, TrdEnv
33
+ config_dict = self.broker_args
34
+ self._trd_env: str = config_dict.get('trade_env', TrdEnv.REAL) or TrdEnv.REAL
35
+ pk_path = config_dict.get('pk_path', '')
36
+ self._unlock_pin = config_dict.get('unlock_pin', '')
37
+ if pk_path:
38
+ SysConfig.enable_proto_encrypt(is_encrypt=True)
39
+ SysConfig.set_init_rsa_file(pk_path)
40
+ if self._trade_client is None:
41
+ SysConfig.set_all_thread_daemon(True)
42
+ host = config_dict.get('host', '127.0.0.1')
43
+ port = config_dict.get('port', 11111)
44
+ trade_ctx = OpenSecTradeContext(
45
+ filter_trdmarket=TrdMarket.US,
46
+ host=host,
47
+ port=port,
48
+ security_firm=SecurityFirm.FUTUSECURITIES,
49
+ )
50
+ trade_ctx.set_sync_query_connect_timeout(6.0)
51
+ self._trade_client = trade_ctx
52
+ if self._quote_client is None:
53
+ SysConfig.set_all_thread_daemon(True)
54
+ host = config_dict.get('host', '127.0.0.1')
55
+ port = config_dict.get('port', 11111)
56
+ quote_ctx = OpenQuoteContext(host=host, port=port)
57
+ quote_ctx.set_sync_query_connect_timeout(6.0)
58
+ self._quote_client = quote_ctx
59
+
60
+ @classmethod
61
+ def code_to_contract(cls, code) -> Contract | None:
62
+ region = ''
63
+ ticker = ''
64
+ if m := re.match(r'^US\.(\S+)$', code):
65
+ region = 'US'
66
+ ticker = m.groups()[0]
67
+ if m := re.match(r'^HK\.(\d{5})$', code):
68
+ region = 'HK'
69
+ ticker = m.groups()[0]
70
+ if m := re.match(r'^SH\.(\d{6})$', code):
71
+ region = 'CN'
72
+ ticker = m.groups()[0]
73
+ if m := re.match(r'^SZ\.(\d{6})$', code):
74
+ region = 'CN'
75
+ ticker = m.groups()[0]
76
+ if not region or not ticker:
77
+ return None
78
+ return Contract(
79
+ trade_type=TradeType.Securities,
80
+ ticker=ticker,
81
+ region=region,
82
+ )
83
+
84
+ @classmethod
85
+ def contract_to_code(cls, contract: Contract) -> str | None:
86
+ if contract.trade_type != TradeType.Securities:
87
+ return None
88
+ region, ticker = contract.region, contract.ticker
89
+ code = None
90
+ if region == 'CN' and re.match(r'^[56]\d{5}$', ticker):
91
+ code = f'SH.{ticker}'
92
+ elif region == 'CN' and re.match(r'^[013]\d{5}$', ticker):
93
+ code = f'SZ.{ticker}'
94
+ elif region == 'HK' and re.match(r'^\d{5}$', ticker):
95
+ code = f'HK.{ticker}'
96
+ elif region == 'US' and re.match(r'^\w+$', ticker):
97
+ code = f'US.{ticker}'
98
+ return code
99
+
100
+ @classmethod
101
+ def df_to_dict(cls, df) -> dict:
102
+ return df.to_dict(orient='records')
103
+
104
+ def _positions(self):
105
+ result = list()
106
+ from futu import RET_OK
107
+ with self._position_bucket:
108
+ resp, data = self._trade_client.position_list_query(
109
+ trd_env=self._trd_env,
110
+ refresh_cache=True,
111
+ )
112
+ if resp != RET_OK:
113
+ raise Exception(f'返回失败: {resp}')
114
+ positions = self.df_to_dict(data)
115
+ for d in positions:
116
+ code = d.get('code')
117
+ currency = d.get('currency')
118
+ if not code or not currency:
119
+ continue
120
+ contract = self.code_to_contract(code)
121
+ if not contract:
122
+ continue
123
+ qty = int(d['qty'])
124
+ position = Position(
125
+ broker=self.broker_name,
126
+ broker_display=self.broker_display,
127
+ contract=contract,
128
+ unit=Unit.Share,
129
+ currency=currency,
130
+ qty=qty,
131
+ )
132
+ result.append(position)
133
+ return result
134
+
135
+ async def positions(self):
136
+ return await self.call_sync(lambda : self._positions())
137
+
138
+ async def ping(self) -> bool:
139
+ try:
140
+ client = self._trade_client
141
+ conn_id = client.get_sync_conn_id()
142
+ return bool(conn_id)
143
+ except Exception as e:
144
+ return False
145
+
146
+ def _cash(self) -> Cash:
147
+ from futu import RET_OK, Currency
148
+ with self._assets_bucket:
149
+ resp, data = self._trade_client.accinfo_query(
150
+ trd_env=self._trd_env,
151
+ refresh_cache=True,
152
+ currency=Currency.USD,
153
+ )
154
+ if resp != RET_OK:
155
+ raise Exception(f'可用资金信息获取失败: {data}')
156
+ assets = self.df_to_dict(data)
157
+ if len(assets) == 1:
158
+ cash = Cash(
159
+ currency='USD',
160
+ amount=assets[0]['cash'],
161
+ )
162
+ return cash
163
+ else:
164
+ raise Exception(f'可用资金信息获取不到记录')
165
+
166
+ async def cash(self) -> Cash:
167
+ return await self.call_sync(lambda : self._cash())
168
+
169
+ def _market_status(self) -> dict[str, dict[str, MarketStatus]]:
170
+ # 各个市场的状态定义见:
171
+ # https://openapi.futunn.com/futu-api-doc/qa/quote.html#2090
172
+ from futu import RET_OK
173
+ sec_result = dict()
174
+ region_map = {
175
+ 'market_sh': 'CN',
176
+ 'market_hk': 'HK',
177
+ 'market_us': 'US',
178
+ }
179
+ status_map = {
180
+ 'CLOSED': UnifiedStatus.CLOSED,
181
+ 'PRE_MARKET_BEGIN': UnifiedStatus.PRE_HOURS,
182
+ 'MORNING': UnifiedStatus.RTH,
183
+ 'AFTERNOON': UnifiedStatus.RTH,
184
+ 'AFTER_HOURS_BEGIN': UnifiedStatus.AFTER_HOURS,
185
+ 'AFTER_HOURS_END': UnifiedStatus.CLOSED, # 根据文档, 盘后收盘时段跟夜盘时段重合
186
+ 'OVERNIGHT': UnifiedStatus.OVERNIGHT,
187
+ # 这些映射是A股港股市场的映射
188
+ 'REST': UnifiedStatus.REST,
189
+ 'HK_CAS': UnifiedStatus.CLOSED,
190
+ }
191
+ client = self._quote_client
192
+ with self._market_status_bucket:
193
+ ret, data = client.get_global_state()
194
+ if ret != RET_OK:
195
+ raise Exception(f'市场状态接口调用失败: {data}')
196
+ for k, origin_status in data.items():
197
+ if k not in region_map:
198
+ continue
199
+ region = region_map[k]
200
+ unified_status = status_map.get(origin_status, UnifiedStatus.UNKNOWN)
201
+ sec_result[region] = MarketStatus(
202
+ region=region,
203
+ origin_status=origin_status,
204
+ unified_status=unified_status,
205
+ )
206
+ return {
207
+ TradeType.Securities.name.lower(): sec_result,
208
+ }
209
+
210
+ async def market_status(self) -> dict[str, dict[str, MarketStatus]]:
211
+ return await self.call_sync(lambda : self._market_status())
212
+
213
+ def _quote(self, contract: Contract):
214
+ from futu import RET_OK
215
+ tz = self.contract_to_tz(contract)
216
+ code = self.contract_to_code(contract)
217
+ currency = self.contract_to_currency(contract)
218
+ with self._snapshot_bucket:
219
+ ret, data = self._quote_client.get_market_snapshot([code, ])
220
+ if ret != RET_OK:
221
+ raise ValueError(f'快照接口调用失败: {data}')
222
+ table = self.df_to_dict(data)
223
+ if len(table) != 1:
224
+ raise ValueError(f'快照接口调用无数据: {data}')
225
+ d = table[0]
226
+ """
227
+ 格式:yyyy-MM-dd HH:mm:ss
228
+ 港股和 A 股市场默认是北京时间,美股市场默认是美东时间
229
+ """
230
+ update_time: str = d['update_time']
231
+ update_dt = datetime.strptime(update_time, '%Y-%m-%d %H:%M:%S')
232
+ update_dt = TimeTools.from_params(
233
+ year=update_dt.year,
234
+ month=update_dt.month,
235
+ day=update_dt.day,
236
+ hour=update_dt.hour,
237
+ minute=update_dt.minute,
238
+ second=update_dt.second,
239
+ tz=tz,
240
+ )
241
+ is_tradable = d['sec_status'] == 'NORMAL'
242
+ return Quote(
243
+ contract=contract,
244
+ currency=currency,
245
+ is_tradable=is_tradable,
246
+ latest=d['last_price'],
247
+ pre_close=d['prev_close_price'],
248
+ open_price=d['open_price'],
249
+ high_price=d['high_price'],
250
+ low_price=d['low_price'],
251
+ time=update_dt,
252
+ )
253
+
254
+ async def quote(self, contract: Contract):
255
+ return await self.call_sync(lambda : self._quote(contract))
256
+
257
+ def _try_unlock(self):
258
+ from futu import RET_OK, TrdEnv
259
+ if self._trd_env != TrdEnv.REAL:
260
+ return
261
+ if not self._unlock_pin:
262
+ return
263
+ with self._unlock_bucket:
264
+ ret, data = self._trade_client.unlock_trade(password_md5=self._unlock_pin)
265
+ if ret != RET_OK:
266
+ raise Exception(f'解锁交易失败: {data}')
267
+
268
+ def _place_order(
269
+ self,
270
+ contract: Contract,
271
+ order_type: OrderType,
272
+ time_in_force: TimeInForce,
273
+ lifecycle: Lifecycle,
274
+ direction: str,
275
+ qty: int,
276
+ price: float = None,
277
+ **kwargs
278
+ ) -> str:
279
+ from futu import RET_OK, TrdSide, OrderType as FutuOrderType, TimeInForce as FutuTimeInForce, Session
280
+ if contract.trade_type != TradeType.Securities:
281
+ raise Exception(f'不支持的下单品种: {contract.trade_type}')
282
+ if contract.region == 'US' and order_type == OrderType.Market and lifecycle != Lifecycle.RTH:
283
+ raise Exception(f'交易时段不支持市价单')
284
+ code = self.contract_to_code(contract)
285
+ assert qty > 0
286
+ assert price > 0
287
+
288
+ def _map_trade_side():
289
+ match direction:
290
+ case 'BUY':
291
+ return TrdSide.BUY
292
+ case 'SELL':
293
+ return TrdSide.SELL
294
+ case _:
295
+ raise Exception(f'不支持的买卖方向: {direction}')
296
+
297
+ def _map_order_type():
298
+ match order_type:
299
+ case OrderType.Market:
300
+ return FutuOrderType.MARKET
301
+ case OrderType.Limit:
302
+ return FutuOrderType.NORMAL
303
+ case _:
304
+ raise Exception(f'不支持的订单类型: {order_type}')
305
+
306
+ def _map_time_in_force():
307
+ match time_in_force:
308
+ case TimeInForce.DAY:
309
+ return FutuTimeInForce.DAY
310
+ case TimeInForce.GTC:
311
+ return FutuTimeInForce.GTC
312
+ case _:
313
+ raise Exception(f'不支持的订单有效期: {time_in_force}')
314
+
315
+ def _map_lifecycle():
316
+ match lifecycle:
317
+ case Lifecycle.RTH:
318
+ return Session.RTH
319
+ case Lifecycle.ETH:
320
+ return Session.ETH
321
+ case Lifecycle.OVERNIGHT:
322
+ return Session.OVERNIGHT
323
+ case _:
324
+ raise Exception(f'不支持的交易时段: {lifecycle}')
325
+
326
+ self._try_unlock()
327
+ with self._place_order_bucket:
328
+ ret, data = self._trade_client.place_order(
329
+ code=code,
330
+ price=price or 10.0, # 富途必须要填充这个字段
331
+ qty=qty,
332
+ trd_side=_map_trade_side(),
333
+ order_type=_map_order_type(),
334
+ time_in_force=_map_time_in_force(),
335
+ trd_env=self._trd_env,
336
+ session=_map_lifecycle(),
337
+ )
338
+ if ret != RET_OK:
339
+ raise Exception(f'下单失败: {data}')
340
+ orders = self.df_to_dict(data)
341
+ assert len(orders) == 1
342
+ order_id = orders[0]['order_id']
343
+ assert order_id
344
+ return order_id
345
+
346
+ async def place_order(
347
+ self,
348
+ contract: Contract,
349
+ order_type: OrderType,
350
+ time_in_force: TimeInForce,
351
+ lifecycle: Lifecycle,
352
+ direction: str,
353
+ qty: int,
354
+ price: float = None,
355
+ **kwargs
356
+ ) -> str:
357
+ return await self.call_sync(lambda : self._place_order(
358
+ contract=contract,
359
+ order_type=order_type,
360
+ time_in_force=time_in_force,
361
+ lifecycle=lifecycle,
362
+ direction=direction,
363
+ qty=qty,
364
+ price=price,
365
+ **kwargs
366
+ ))
367
+
368
+ def _order(self, order_id: str) -> Order:
369
+ from futu import RET_OK, OrderStatus
370
+ error_set = {OrderStatus.FAILED, OrderStatus.DISABLED, OrderStatus.DELETED, }
371
+ cancel_set = {OrderStatus.CANCELLED_PART, OrderStatus.CANCELLED_ALL, }
372
+ with self._refresh_order_bucket:
373
+ ret, data = self._trade_client.order_list_query(
374
+ order_id=order_id,
375
+ refresh_cache=True,
376
+ trd_env=self._trd_env,
377
+ )
378
+ if ret != RET_OK:
379
+ raise Exception(f'调用获取订单失败, 订单: {order_id}')
380
+ orders = self.df_to_dict(data)
381
+ if len(orders) != 1:
382
+ raise Exception(f'找不到订单(未完成), 订单: {order_id}')
383
+ futu_order = orders[0]
384
+ reason = ''
385
+ if futu_order['order_status'] in error_set:
386
+ reason = futu_order['order_status']
387
+ return Order(
388
+ order_id=order_id,
389
+ currency=futu_order['currency'],
390
+ qty=int(futu_order['qty']),
391
+ filled_qty=int(futu_order['dealt_qty']),
392
+ avg_price=futu_order['dealt_avg_price'] or 0.0,
393
+ error_reason=reason,
394
+ is_canceled=futu_order['order_status'] in cancel_set,
395
+ )
396
+
397
+ async def order(self, order_id: str) -> Order:
398
+ return await self.call_sync(lambda : self._order(order_id=order_id))
399
+
400
+ def _cancel_order(self, order_id: str):
401
+ from futu import RET_OK, ModifyOrderOp
402
+ self._try_unlock()
403
+ with self._cancel_order_bucket:
404
+ ret, data = self._trade_client.modify_order(
405
+ modify_order_op=ModifyOrderOp.CANCEL,
406
+ order_id=order_id,
407
+ qty=100,
408
+ price=10.0,
409
+ trd_env=self._trd_env,
410
+ )
411
+ if ret != RET_OK:
412
+ raise Exception(f'撤单失败, 订单: {order_id}, 原因: {data}')
413
+
414
+ async def cancel_order(self, order_id: str):
415
+ await self.call_sync(lambda: self._cancel_order(order_id=order_id))
416
+
417
+
418
+ __all__ = ['Futu', ]