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.
- httptrading/__init__.py +6 -0
- httptrading/broker/__init__.py +0 -0
- httptrading/broker/base.py +151 -0
- httptrading/broker/futu.py +418 -0
- httptrading/broker/interactive_brokers.py +318 -0
- httptrading/broker/longbridge.py +380 -0
- httptrading/broker/tiger.py +347 -0
- httptrading/http_server.py +295 -0
- httptrading/model.py +174 -0
- httptrading/tool/__init__.py +0 -0
- httptrading/tool/leaky_bucket.py +87 -0
- httptrading/tool/locate.py +85 -0
- httptrading/tool/time.py +77 -0
- httptrading-1.0.0.dist-info/METADATA +538 -0
- httptrading-1.0.0.dist-info/RECORD +18 -0
- httptrading-1.0.0.dist-info/WHEEL +5 -0
- httptrading-1.0.0.dist-info/licenses/LICENSE +21 -0
- httptrading-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,318 @@
|
|
1
|
+
"""
|
2
|
+
接入盈透证券的API文档
|
3
|
+
https://ib-insync.readthedocs.io/readme.html
|
4
|
+
"""
|
5
|
+
import re
|
6
|
+
import asyncio
|
7
|
+
from typing import Any
|
8
|
+
from httptrading.tool.leaky_bucket import *
|
9
|
+
from httptrading.tool.time import *
|
10
|
+
from httptrading.broker.base import *
|
11
|
+
from httptrading.model import *
|
12
|
+
|
13
|
+
|
14
|
+
@broker_register(name='interactiveBrokers', display='盈透证券', detect_pkg=DetectPkg('ib-insync', 'ib_insync'))
|
15
|
+
class InteractiveBrokers(SecuritiesBroker):
|
16
|
+
def __init__(self, broker_args: dict = None, instance_id: str = None, tokens: list[str] = None):
|
17
|
+
super().__init__(broker_args, instance_id, tokens)
|
18
|
+
self._lock = asyncio.Lock()
|
19
|
+
self._client = None
|
20
|
+
self._account_id = None
|
21
|
+
self._client_id = None
|
22
|
+
self._ib_contracts: dict[Contract, Any] = dict()
|
23
|
+
self._plugin_bucket = LeakyBucket(60)
|
24
|
+
self._account_bucket = LeakyBucket(60)
|
25
|
+
self._order_bucket = LeakyBucket(60)
|
26
|
+
self._quote_bucket = LeakyBucket(60)
|
27
|
+
self._on_init()
|
28
|
+
|
29
|
+
def _on_init(self):
|
30
|
+
self._account_id = self.broker_args.get('account_id')
|
31
|
+
self._client_id = self.broker_args.get('client_id')
|
32
|
+
|
33
|
+
async def start(self):
|
34
|
+
await self._try_create_client()
|
35
|
+
|
36
|
+
async def shutdown(self):
|
37
|
+
ib_socket = self._client
|
38
|
+
if ib_socket:
|
39
|
+
ib_socket.disconnect()
|
40
|
+
self._client = None
|
41
|
+
|
42
|
+
@property
|
43
|
+
def timeout(self):
|
44
|
+
timeout = max(4, self.broker_args.get('timeout', 8))
|
45
|
+
return timeout
|
46
|
+
|
47
|
+
@classmethod
|
48
|
+
def ib_contract_to_contract(cls, contract) -> Contract | None:
|
49
|
+
if contract.secType != 'STK':
|
50
|
+
return None
|
51
|
+
symbol = contract.symbol
|
52
|
+
trade_type = TradeType.Securities
|
53
|
+
region = ''
|
54
|
+
ticker = ''
|
55
|
+
if re.match(r'^[01356]\d{5}$', symbol):
|
56
|
+
region = 'CN'
|
57
|
+
ticker = symbol
|
58
|
+
if re.match(r'^\d{5}$', symbol):
|
59
|
+
region = 'HK'
|
60
|
+
ticker = symbol
|
61
|
+
if re.match(r'^\w{1,5}$', symbol):
|
62
|
+
region = 'US'
|
63
|
+
ticker = symbol
|
64
|
+
if not region or not ticker:
|
65
|
+
return None
|
66
|
+
return Contract(
|
67
|
+
trade_type=trade_type,
|
68
|
+
ticker=ticker,
|
69
|
+
region=region,
|
70
|
+
)
|
71
|
+
|
72
|
+
async def contract_to_ib_contract(self, contract) -> Any | None:
|
73
|
+
import ib_insync
|
74
|
+
async with self._lock:
|
75
|
+
if contract in self._ib_contracts:
|
76
|
+
return self._ib_contracts[contract]
|
77
|
+
print(f'{contract}未命中')
|
78
|
+
currency = self.contract_to_currency(contract)
|
79
|
+
ib_contract = ib_insync.Stock(contract.ticker, 'SMART', currency=currency)
|
80
|
+
client = self._client
|
81
|
+
client.qualifyContracts(*[ib_contract, ])
|
82
|
+
self._ib_contracts[contract] = ib_contract
|
83
|
+
return ib_contract
|
84
|
+
|
85
|
+
async def _try_create_client(self):
|
86
|
+
import ib_insync
|
87
|
+
async with self._lock:
|
88
|
+
ib_socket = self._client
|
89
|
+
if ib_socket:
|
90
|
+
try:
|
91
|
+
ib_dt = await ib_socket.reqCurrentTimeAsync()
|
92
|
+
now = TimeTools.utc_now()
|
93
|
+
if TimeTools.timedelta(ib_dt, seconds=self.timeout) <= now:
|
94
|
+
raise TimeoutError
|
95
|
+
assert ib_socket.isConnected()
|
96
|
+
return
|
97
|
+
except Exception as e:
|
98
|
+
pass
|
99
|
+
if ib_socket:
|
100
|
+
ib_socket.disconnect()
|
101
|
+
ib = ib_socket
|
102
|
+
else:
|
103
|
+
ib = ib_insync.IB()
|
104
|
+
host = self.broker_args.get('host', '127.0.0.1')
|
105
|
+
port = self.broker_args.get('port', 4000)
|
106
|
+
client_id = self.broker_args.get('client_id', self._client_id)
|
107
|
+
timeout = self.timeout
|
108
|
+
account_id = self._account_id
|
109
|
+
new_client = await ib.connectAsync(
|
110
|
+
host=host,
|
111
|
+
port=port,
|
112
|
+
clientId=client_id,
|
113
|
+
timeout=timeout,
|
114
|
+
account=account_id,
|
115
|
+
)
|
116
|
+
self._client = new_client
|
117
|
+
|
118
|
+
async def ping(self) -> bool:
|
119
|
+
async with self._plugin_bucket:
|
120
|
+
try:
|
121
|
+
await self._try_create_client()
|
122
|
+
return True
|
123
|
+
except Exception as e:
|
124
|
+
return False
|
125
|
+
|
126
|
+
async def _cash(self) -> Cash:
|
127
|
+
import ib_insync
|
128
|
+
async with self._account_bucket:
|
129
|
+
_client: ib_insync.IB = self._client
|
130
|
+
if not _client:
|
131
|
+
raise Exception('盈透连接对象未准备好')
|
132
|
+
l = await _client.accountSummaryAsync(account=self._account_id)
|
133
|
+
d: dict[str, ib_insync.AccountValue] = {i.tag: i for i in l}
|
134
|
+
item = d['TotalCashValue']
|
135
|
+
assert item.currency == 'USD'
|
136
|
+
amount = float(item.value)
|
137
|
+
cash = Cash(
|
138
|
+
currency='USD',
|
139
|
+
amount=amount,
|
140
|
+
)
|
141
|
+
return cash
|
142
|
+
|
143
|
+
async def cash(self) -> Cash:
|
144
|
+
return await self.call_async(self._cash())
|
145
|
+
|
146
|
+
async def _positions(self):
|
147
|
+
import ib_insync
|
148
|
+
result = list()
|
149
|
+
async with self._account_bucket:
|
150
|
+
_client: ib_insync.IB = self._client
|
151
|
+
if not _client:
|
152
|
+
raise Exception('盈透连接对象未准备好')
|
153
|
+
l = _client.positions(account=self._account_id)
|
154
|
+
for position in l:
|
155
|
+
ib_contract = position.contract
|
156
|
+
contract = self.ib_contract_to_contract(ib_contract)
|
157
|
+
if not contract:
|
158
|
+
continue
|
159
|
+
currency = ib_contract.currency
|
160
|
+
qty = int(position.position)
|
161
|
+
position = Position(
|
162
|
+
broker=self.broker_name,
|
163
|
+
broker_display=self.broker_display,
|
164
|
+
contract=contract,
|
165
|
+
unit=Unit.Share,
|
166
|
+
currency=currency,
|
167
|
+
qty=qty,
|
168
|
+
)
|
169
|
+
result.append(position)
|
170
|
+
return result
|
171
|
+
|
172
|
+
async def positions(self):
|
173
|
+
return await self.call_async(self._positions())
|
174
|
+
|
175
|
+
async def _place_order(
|
176
|
+
self,
|
177
|
+
contract: Contract,
|
178
|
+
order_type: OrderType,
|
179
|
+
time_in_force: TimeInForce,
|
180
|
+
lifecycle: Lifecycle,
|
181
|
+
direction: str,
|
182
|
+
qty: int,
|
183
|
+
price: float = None,
|
184
|
+
**kwargs
|
185
|
+
) -> str:
|
186
|
+
import ib_insync
|
187
|
+
with self._order_bucket:
|
188
|
+
client = self._client
|
189
|
+
ib_contract = await self.contract_to_ib_contract(contract)
|
190
|
+
|
191
|
+
def _map_time_in_force():
|
192
|
+
match time_in_force:
|
193
|
+
case TimeInForce.DAY:
|
194
|
+
return 'DAY'
|
195
|
+
case TimeInForce.GTC:
|
196
|
+
return 'GTC'
|
197
|
+
case _:
|
198
|
+
raise Exception(f'不支持的订单有效期: {time_in_force}')
|
199
|
+
|
200
|
+
def _map_lifecycle():
|
201
|
+
match lifecycle:
|
202
|
+
case Lifecycle.RTH:
|
203
|
+
return False
|
204
|
+
case Lifecycle.ETH:
|
205
|
+
return True
|
206
|
+
case _:
|
207
|
+
raise Exception(f'不支持的交易时段: {lifecycle}')
|
208
|
+
|
209
|
+
def _map_order():
|
210
|
+
match order_type:
|
211
|
+
case OrderType.Limit:
|
212
|
+
return ib_insync.LimitOrder(
|
213
|
+
action=direction,
|
214
|
+
totalQuantity=qty,
|
215
|
+
lmtPrice=price,
|
216
|
+
)
|
217
|
+
case OrderType.Market:
|
218
|
+
return ib_insync.MarketOrder(
|
219
|
+
action=direction,
|
220
|
+
totalQuantity=qty,
|
221
|
+
)
|
222
|
+
case _:
|
223
|
+
raise Exception(f'不支持的订单类型: {order_type}')
|
224
|
+
|
225
|
+
ib_order = _map_order()
|
226
|
+
ib_order.tif = _map_time_in_force()
|
227
|
+
ib_order.outsideRth = _map_lifecycle()
|
228
|
+
trade: ib_insync.Trade = client.placeOrder(ib_contract, ib_order)
|
229
|
+
await asyncio.sleep(2.0)
|
230
|
+
order_id = str(trade.order.permId)
|
231
|
+
assert order_id
|
232
|
+
return order_id
|
233
|
+
|
234
|
+
async def place_order(
|
235
|
+
self,
|
236
|
+
contract: Contract,
|
237
|
+
order_type: OrderType,
|
238
|
+
time_in_force: TimeInForce,
|
239
|
+
lifecycle: Lifecycle,
|
240
|
+
direction: str,
|
241
|
+
qty: int,
|
242
|
+
price: float = None,
|
243
|
+
**kwargs
|
244
|
+
) -> str:
|
245
|
+
return await self.call_async(self._place_order(
|
246
|
+
contract=contract,
|
247
|
+
order_type=order_type,
|
248
|
+
time_in_force=time_in_force,
|
249
|
+
lifecycle=lifecycle,
|
250
|
+
direction=direction,
|
251
|
+
qty=qty,
|
252
|
+
price=price,
|
253
|
+
**kwargs
|
254
|
+
))
|
255
|
+
|
256
|
+
async def _cancel_order(self, order_id: str):
|
257
|
+
order_id_int = int(order_id)
|
258
|
+
with self._order_bucket:
|
259
|
+
client = self._client
|
260
|
+
trades = client.trades()
|
261
|
+
for ib_trade in trades:
|
262
|
+
ib_order = ib_trade.order
|
263
|
+
if ib_order.permId != order_id_int:
|
264
|
+
continue
|
265
|
+
client.cancelOrder(ib_order)
|
266
|
+
break
|
267
|
+
|
268
|
+
async def cancel_order(self, order_id: str):
|
269
|
+
await self.call_async(self._cancel_order(order_id=order_id))
|
270
|
+
|
271
|
+
async def _order(self, order_id: str) -> Order:
|
272
|
+
import ib_insync
|
273
|
+
|
274
|
+
def _total_fills(trade) -> int:
|
275
|
+
return int(trade.filled())
|
276
|
+
|
277
|
+
def _avg_price(trade) -> float:
|
278
|
+
total_fills = _total_fills(trade)
|
279
|
+
if not total_fills:
|
280
|
+
return 0
|
281
|
+
cap = sum([fill.execution.shares * fill.execution.avgPrice for fill in trade.fills], 0.0)
|
282
|
+
return round(cap / total_fills, 5)
|
283
|
+
|
284
|
+
with self._order_bucket:
|
285
|
+
client = self._client
|
286
|
+
trades = client.trades()
|
287
|
+
order_id_int = int(order_id)
|
288
|
+
for ib_trade in trades:
|
289
|
+
ib_order = ib_trade.order
|
290
|
+
if ib_order.permId != order_id_int:
|
291
|
+
continue
|
292
|
+
qty = int(_total_fills(ib_trade) + ib_trade.remaining())
|
293
|
+
filled_qty = _total_fills(ib_trade)
|
294
|
+
qty = qty or filled_qty
|
295
|
+
assert qty >= filled_qty
|
296
|
+
avg_fill_price = _avg_price(ib_trade)
|
297
|
+
reason = ''
|
298
|
+
if ib_trade.orderStatus.status == ib_insync.OrderStatus.Inactive:
|
299
|
+
reason = 'Inactive'
|
300
|
+
|
301
|
+
cancel_status = {ib_insync.OrderStatus.Cancelled, ib_insync.OrderStatus.ApiCancelled, }
|
302
|
+
is_cancelled = ib_trade.orderStatus.status in cancel_status
|
303
|
+
return Order(
|
304
|
+
order_id=order_id,
|
305
|
+
currency=ib_trade.contract.currency,
|
306
|
+
qty=qty,
|
307
|
+
filled_qty=filled_qty,
|
308
|
+
avg_price=avg_fill_price,
|
309
|
+
error_reason=reason,
|
310
|
+
is_canceled=is_cancelled,
|
311
|
+
)
|
312
|
+
raise Exception(f'查询不到订单{order_id}')
|
313
|
+
|
314
|
+
async def order(self, order_id: str) -> Order:
|
315
|
+
return await self.call_async(self._order(order_id))
|
316
|
+
|
317
|
+
|
318
|
+
__all__ = ['InteractiveBrokers', ]
|
@@ -0,0 +1,380 @@
|
|
1
|
+
"""
|
2
|
+
长桥证券文档见
|
3
|
+
https://open.longportapp.com/docs
|
4
|
+
"""
|
5
|
+
import re
|
6
|
+
import tomllib
|
7
|
+
import threading
|
8
|
+
from decimal import Decimal
|
9
|
+
from datetime import datetime
|
10
|
+
import tomlkit
|
11
|
+
from httptrading.tool.leaky_bucket import *
|
12
|
+
from httptrading.broker.base import *
|
13
|
+
from httptrading.model import *
|
14
|
+
from httptrading.tool.time import *
|
15
|
+
from httptrading.tool.locate import *
|
16
|
+
|
17
|
+
|
18
|
+
class TokenKeeper:
|
19
|
+
"""
|
20
|
+
长桥证券的会话需要通过下发的 token 凭据验证身份,
|
21
|
+
因为 token 有有效期, 该类负责判断 token 是否快要到过期时间(默认剩余3天以内), 使得交易通道对象可以通过 SDK 接口去下载新 token.
|
22
|
+
|
23
|
+
因此, 初次使用的时候, 必须去他们的开发者网站上拿到初代 token, 做成一个 toml 格式文件, TokenKeeper 将读写这个文件.
|
24
|
+
这个 toml 文件需要设置两个字段, 分别是 token 和 expiry,
|
25
|
+
token 字段填入给的令牌字符串,
|
26
|
+
expiry 是过期时间的字符串, 例如 "2025-01-01T00:00:00.000000+00:00"
|
27
|
+
没有这个 toml 文件, 或者 token 文件的 expiry 已经过期了, 系统便没办法连接到他们的接口上做自动更新, 因此会有启动上的问题.
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self, token_file: str):
|
31
|
+
assert token_file
|
32
|
+
self.token_file = token_file
|
33
|
+
self.lock = threading.RLock()
|
34
|
+
with self.lock:
|
35
|
+
text = LocateTools.read_file(token_file)
|
36
|
+
d = tomllib.loads(text)
|
37
|
+
token, expiry = d.get('token'), d.get('expiry')
|
38
|
+
assert token
|
39
|
+
self.token = token
|
40
|
+
self.expiry = datetime.fromisoformat(expiry)
|
41
|
+
if self.is_expired:
|
42
|
+
raise ValueError(f'长桥证券的访问令牌已经过期{self.expiry}')
|
43
|
+
|
44
|
+
@property
|
45
|
+
def is_expired(self):
|
46
|
+
if not self.expiry:
|
47
|
+
return False
|
48
|
+
if TimeTools.utc_now() >= self.expiry:
|
49
|
+
return True
|
50
|
+
return False
|
51
|
+
|
52
|
+
@property
|
53
|
+
def should_refresh(self):
|
54
|
+
now = TimeTools.utc_now()
|
55
|
+
if now >= self.expiry:
|
56
|
+
return False
|
57
|
+
if now >= TimeTools.timedelta(self.expiry, days=-3):
|
58
|
+
return True
|
59
|
+
return False
|
60
|
+
|
61
|
+
def update_token(self, token: str, expiry: datetime):
|
62
|
+
assert self.token_file
|
63
|
+
assert token
|
64
|
+
assert expiry
|
65
|
+
with self.lock:
|
66
|
+
self.token = token
|
67
|
+
self.expiry = expiry
|
68
|
+
d = {
|
69
|
+
'token': self.token,
|
70
|
+
'expiry': self.expiry.isoformat(),
|
71
|
+
}
|
72
|
+
text = tomlkit.dumps(d)
|
73
|
+
LocateTools.write_file(self.token_file, text)
|
74
|
+
|
75
|
+
|
76
|
+
@broker_register(name='longBridge', display='长桥证券', detect_pkg=DetectPkg('longport', 'longport'))
|
77
|
+
class LongBridge(SecuritiesBroker):
|
78
|
+
def __init__(self, broker_args: dict = None, instance_id: str = None, tokens: list[str] = None):
|
79
|
+
super().__init__(broker_args, instance_id, tokens)
|
80
|
+
self._token_file = ''
|
81
|
+
self._token_keeper: TokenKeeper | None = None
|
82
|
+
self._auto_refresh_token = False
|
83
|
+
self._token_bucket = LeakyBucket(6)
|
84
|
+
self._quote_bucket = LeakyBucket(60)
|
85
|
+
self._assets_bucket = LeakyBucket(60)
|
86
|
+
self._on_init()
|
87
|
+
|
88
|
+
def _on_init(self):
|
89
|
+
config_dict = self.broker_args
|
90
|
+
token_file = config_dict.get('token_file', '')
|
91
|
+
assert token_file
|
92
|
+
self._token_file = token_file
|
93
|
+
|
94
|
+
keeper = TokenKeeper(token_file)
|
95
|
+
self._token_keeper = keeper
|
96
|
+
|
97
|
+
auto_refresh_token = config_dict.get('auto_refresh_token', False)
|
98
|
+
self._auto_refresh_token = auto_refresh_token
|
99
|
+
self._reset_client()
|
100
|
+
|
101
|
+
def _reset_client(self):
|
102
|
+
cfg = self.broker_args
|
103
|
+
from longport.openapi import Config, QuoteContext, TradeContext
|
104
|
+
app_key = cfg.get('app_key')
|
105
|
+
app_secret = cfg.get('app_secret')
|
106
|
+
assert app_key
|
107
|
+
assert app_secret
|
108
|
+
self._token_keeper = self._token_keeper or TokenKeeper(self._token_file)
|
109
|
+
config = Config(
|
110
|
+
app_key=app_key,
|
111
|
+
app_secret=app_secret,
|
112
|
+
access_token=self._token_keeper.token,
|
113
|
+
)
|
114
|
+
quote_ctx = QuoteContext(config)
|
115
|
+
trade_ctx = TradeContext(config)
|
116
|
+
self._lp_config = config
|
117
|
+
self._quote_client = quote_ctx
|
118
|
+
self._trade_client = trade_ctx
|
119
|
+
|
120
|
+
def _try_refresh(self):
|
121
|
+
if not self._auto_refresh_token:
|
122
|
+
return
|
123
|
+
cfg = self._lp_config
|
124
|
+
keeper = self._token_keeper
|
125
|
+
with keeper.lock:
|
126
|
+
if not keeper.should_refresh:
|
127
|
+
return
|
128
|
+
now = TimeTools.utc_now()
|
129
|
+
expiry = TimeTools.timedelta(now, days=90)
|
130
|
+
with self._token_bucket:
|
131
|
+
token = cfg.refresh_access_token()
|
132
|
+
assert token
|
133
|
+
keeper.update_token(token, expiry)
|
134
|
+
self._reset_client()
|
135
|
+
|
136
|
+
@classmethod
|
137
|
+
def symbol_to_contract(cls, symbol: str) -> Contract | None:
|
138
|
+
region = ''
|
139
|
+
ticker = ''
|
140
|
+
if m := re.match(r'^(\S+)\.US$', symbol):
|
141
|
+
region = 'US'
|
142
|
+
ticker = m.groups()[0]
|
143
|
+
if m := re.match(r'^(\d{5})\.HK$', symbol):
|
144
|
+
region = 'HK'
|
145
|
+
ticker = m.groups()[0]
|
146
|
+
if m := re.match(r'^(\d{6})\.SH$', symbol):
|
147
|
+
region = 'CN'
|
148
|
+
ticker = m.groups()[0]
|
149
|
+
if m := re.match(r'^(\d{6})\.SZ$', symbol):
|
150
|
+
region = 'CN'
|
151
|
+
ticker = m.groups()[0]
|
152
|
+
if not region or not ticker:
|
153
|
+
return None
|
154
|
+
return Contract(
|
155
|
+
trade_type=TradeType.Securities,
|
156
|
+
ticker=ticker,
|
157
|
+
region=region,
|
158
|
+
)
|
159
|
+
|
160
|
+
@classmethod
|
161
|
+
def contract_to_symbol(cls, contract: Contract) -> str:
|
162
|
+
if contract.trade_type != TradeType.Securities:
|
163
|
+
raise Exception(f'不能支持的交易品种{contract}映射为交易代码')
|
164
|
+
region, ticker = contract.region, contract.ticker
|
165
|
+
code = None
|
166
|
+
if region == 'CN' and re.match(r'^[56]\d{5}$', ticker):
|
167
|
+
code = f'{ticker}.SH'
|
168
|
+
elif region == 'CN' and re.match(r'^[013]\d{5}$', ticker):
|
169
|
+
code = f'{ticker}.SZ'
|
170
|
+
elif region == 'HK' and re.match(r'^\d{5}$', ticker):
|
171
|
+
code = f'{ticker}.HK'
|
172
|
+
elif region == 'US' and re.match(r'^\w+$', ticker):
|
173
|
+
code = f'{ticker}.US'
|
174
|
+
if not code:
|
175
|
+
raise Exception(f'不能映射{contract}为交易代码')
|
176
|
+
return code
|
177
|
+
|
178
|
+
def _positions(self):
|
179
|
+
result = list()
|
180
|
+
with self._assets_bucket:
|
181
|
+
self._try_refresh()
|
182
|
+
items = self._trade_client.stock_positions().channels
|
183
|
+
for channel in items:
|
184
|
+
if channel.account_channel != 'lb':
|
185
|
+
continue
|
186
|
+
for node in channel.positions:
|
187
|
+
symbol = node.symbol
|
188
|
+
contract = self.symbol_to_contract(symbol)
|
189
|
+
if not contract:
|
190
|
+
continue
|
191
|
+
position = Position(
|
192
|
+
broker=self.broker_name,
|
193
|
+
broker_display=self.broker_display,
|
194
|
+
contract=contract,
|
195
|
+
unit=Unit.Share,
|
196
|
+
currency=node.currency,
|
197
|
+
qty=int(node.quantity),
|
198
|
+
)
|
199
|
+
result.append(position)
|
200
|
+
return result
|
201
|
+
|
202
|
+
async def positions(self):
|
203
|
+
return await self.call_sync(lambda : self._positions())
|
204
|
+
|
205
|
+
def _cash(self) -> Cash:
|
206
|
+
with self._assets_bucket:
|
207
|
+
self._try_refresh()
|
208
|
+
resp = self._trade_client.account_balance('USD')
|
209
|
+
for node in resp:
|
210
|
+
if node.currency != 'USD':
|
211
|
+
continue
|
212
|
+
cash = Cash(
|
213
|
+
currency=node.currency,
|
214
|
+
amount=float(node.total_cash),
|
215
|
+
)
|
216
|
+
return cash
|
217
|
+
raise Exception('可用资金信息获取不到记录')
|
218
|
+
|
219
|
+
async def cash(self) -> Cash:
|
220
|
+
return await self.call_sync(lambda : self._cash())
|
221
|
+
|
222
|
+
def _quote(self, contract: Contract):
|
223
|
+
from longport.openapi import TradeStatus
|
224
|
+
symbol = self.contract_to_symbol(contract)
|
225
|
+
tz = self.contract_to_tz(contract)
|
226
|
+
currency = self.contract_to_currency(contract)
|
227
|
+
|
228
|
+
ctx = self._quote_client
|
229
|
+
with self._quote_bucket:
|
230
|
+
self._try_refresh()
|
231
|
+
resp = ctx.quote([symbol, ])
|
232
|
+
if not resp:
|
233
|
+
raise Exception(f'查询不到{contract}的快照报价')
|
234
|
+
item = resp[0]
|
235
|
+
update_dt = TimeTools.from_timestamp(item.timestamp.timestamp(), tz)
|
236
|
+
is_tradable = bool(item.trade_status == TradeStatus.Normal)
|
237
|
+
return Quote(
|
238
|
+
contract=contract,
|
239
|
+
currency=currency,
|
240
|
+
is_tradable=is_tradable,
|
241
|
+
latest=float(item.last_done),
|
242
|
+
pre_close=float(item.prev_close),
|
243
|
+
open_price=float(item.open),
|
244
|
+
high_price=float(item.high),
|
245
|
+
low_price=float(item.low),
|
246
|
+
time=update_dt,
|
247
|
+
)
|
248
|
+
|
249
|
+
async def quote(self, contract: Contract):
|
250
|
+
return await self.call_sync(lambda : self._quote(contract))
|
251
|
+
|
252
|
+
def _place_order(
|
253
|
+
self,
|
254
|
+
contract: Contract,
|
255
|
+
order_type: OrderType,
|
256
|
+
time_in_force: TimeInForce,
|
257
|
+
lifecycle: Lifecycle,
|
258
|
+
direction: str,
|
259
|
+
qty: int,
|
260
|
+
price: float = None,
|
261
|
+
**kwargs
|
262
|
+
) -> str:
|
263
|
+
from longport.openapi import OrderType as LbOrderType, OrderSide, TimeInForceType, OutsideRTH
|
264
|
+
if contract.trade_type != TradeType.Securities:
|
265
|
+
raise Exception(f'不支持的下单品种: {contract.trade_type}')
|
266
|
+
if contract.region == 'US' and order_type == OrderType.Market and lifecycle != Lifecycle.RTH:
|
267
|
+
raise Exception(f'交易时段不支持市价单')
|
268
|
+
symbol = self.contract_to_symbol(contract)
|
269
|
+
assert qty > 0
|
270
|
+
assert price > 0
|
271
|
+
|
272
|
+
def _map_trade_side():
|
273
|
+
match direction:
|
274
|
+
case 'BUY':
|
275
|
+
return OrderSide.Buy
|
276
|
+
case 'SELL':
|
277
|
+
return OrderSide.Sell
|
278
|
+
case _:
|
279
|
+
raise Exception(f'不支持的买卖方向: {direction}')
|
280
|
+
|
281
|
+
def _map_order_type():
|
282
|
+
match order_type:
|
283
|
+
case OrderType.Market:
|
284
|
+
return LbOrderType.MO
|
285
|
+
case OrderType.Limit:
|
286
|
+
return LbOrderType.LO
|
287
|
+
case _:
|
288
|
+
raise Exception(f'不支持的订单类型: {order_type}')
|
289
|
+
|
290
|
+
def _map_time_in_force():
|
291
|
+
match time_in_force:
|
292
|
+
case TimeInForce.DAY:
|
293
|
+
return TimeInForceType.Day
|
294
|
+
case TimeInForce.GTC:
|
295
|
+
return TimeInForceType.GoodTilCanceled
|
296
|
+
case _:
|
297
|
+
raise Exception(f'不支持的订单有效期: {time_in_force}')
|
298
|
+
|
299
|
+
def _map_lifecycle():
|
300
|
+
match lifecycle:
|
301
|
+
case Lifecycle.RTH:
|
302
|
+
return OutsideRTH.RTHOnly
|
303
|
+
case Lifecycle.ETH:
|
304
|
+
return OutsideRTH.AnyTime # 没错, 允许盘前盘后
|
305
|
+
case Lifecycle.OVERNIGHT:
|
306
|
+
return OutsideRTH.Overnight
|
307
|
+
case _:
|
308
|
+
raise Exception(f'不支持的交易时段: {lifecycle}')
|
309
|
+
|
310
|
+
with self._assets_bucket:
|
311
|
+
self._try_refresh()
|
312
|
+
resp = self._trade_client.submit_order(
|
313
|
+
symbol=symbol,
|
314
|
+
order_type=_map_order_type(),
|
315
|
+
side=_map_trade_side(),
|
316
|
+
outside_rth=_map_lifecycle(),
|
317
|
+
submitted_quantity=Decimal(qty),
|
318
|
+
time_in_force=_map_time_in_force(),
|
319
|
+
submitted_price=Decimal(price) if price is not None else None,
|
320
|
+
)
|
321
|
+
assert resp.order_id
|
322
|
+
return resp.order_id
|
323
|
+
|
324
|
+
async def place_order(
|
325
|
+
self,
|
326
|
+
contract: Contract,
|
327
|
+
order_type: OrderType,
|
328
|
+
time_in_force: TimeInForce,
|
329
|
+
lifecycle: Lifecycle,
|
330
|
+
direction: str,
|
331
|
+
qty: int,
|
332
|
+
price: float = None,
|
333
|
+
**kwargs
|
334
|
+
) -> str:
|
335
|
+
return await self.call_sync(lambda : self._place_order(
|
336
|
+
contract=contract,
|
337
|
+
order_type=order_type,
|
338
|
+
time_in_force=time_in_force,
|
339
|
+
lifecycle=lifecycle,
|
340
|
+
direction=direction,
|
341
|
+
qty=qty,
|
342
|
+
price=price,
|
343
|
+
**kwargs
|
344
|
+
))
|
345
|
+
|
346
|
+
def _order(self, order_id: str) -> Order:
|
347
|
+
from longport.openapi import OrderStatus
|
348
|
+
with self._assets_bucket:
|
349
|
+
self._try_refresh()
|
350
|
+
resp = self._trade_client.order_detail(order_id=order_id)
|
351
|
+
reason = ''
|
352
|
+
if resp.status == OrderStatus.Rejected:
|
353
|
+
reason = '已拒绝'
|
354
|
+
if resp.status == OrderStatus.Expired:
|
355
|
+
reason = '已过期'
|
356
|
+
if resp.status == OrderStatus.PartialWithdrawal:
|
357
|
+
reason = '部分撤单'
|
358
|
+
return Order(
|
359
|
+
order_id=order_id,
|
360
|
+
currency=resp.currency,
|
361
|
+
qty=int(resp.quantity),
|
362
|
+
filled_qty=int(resp.executed_quantity),
|
363
|
+
avg_price=float(resp.executed_price) if resp.executed_price else 0.0,
|
364
|
+
error_reason=reason,
|
365
|
+
is_canceled=resp.status == OrderStatus.Canceled,
|
366
|
+
)
|
367
|
+
|
368
|
+
async def order(self, order_id: str) -> Order:
|
369
|
+
return await self.call_sync(lambda : self._order(order_id=order_id))
|
370
|
+
|
371
|
+
def _cancel_order(self, order_id: str):
|
372
|
+
with self._assets_bucket:
|
373
|
+
self._try_refresh()
|
374
|
+
self._trade_client.cancel_order(order_id=order_id)
|
375
|
+
|
376
|
+
async def cancel_order(self, order_id: str):
|
377
|
+
await self.call_sync(lambda: self._cancel_order(order_id=order_id))
|
378
|
+
|
379
|
+
|
380
|
+
__all__ = ['LongBridge', ]
|