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,347 @@
1
+ """
2
+ 接入老虎证券的API文档
3
+ https://quant.itigerup.com/openapi/zh/python/overview/introduction.html
4
+ """
5
+ import re
6
+ import threading
7
+ from httptrading.tool.leaky_bucket import *
8
+ from httptrading.broker.base import *
9
+ from httptrading.model import *
10
+
11
+
12
+ @broker_register(name='tiger', display='老虎证券', detect_pkg=DetectPkg('tigeropen', 'tigeropen'))
13
+ class Tiger(SecuritiesBroker):
14
+ def __init__(self, broker_args: dict = None, instance_id: str = None, tokens: list[str] = None):
15
+ super().__init__(broker_args, instance_id, tokens)
16
+ self._has_grab = False
17
+ self._config = None
18
+ self._quote_client = None
19
+ self._trade_client = None
20
+ self._push_client = None
21
+ self._grab_lock = threading.Lock()
22
+ self._market_status_bucket = LeakyBucket(9)
23
+ self._quote_bucket = LeakyBucket(119)
24
+ self._order_bucket = LeakyBucket(119)
25
+ self._assets_bucket = LeakyBucket(59)
26
+ self._on_init()
27
+
28
+ def _on_init(self):
29
+ from tigeropen.tiger_open_config import get_client_config
30
+ from tigeropen.quote.quote_client import QuoteClient
31
+ from tigeropen.trade.trade_client import TradeClient
32
+ from tigeropen.push.push_client import PushClient
33
+ config_dict = self.broker_args
34
+ pk_path = config_dict.get('pk_path')
35
+ tiger_id = config_dict.get('tiger_id')
36
+ account = config_dict.get('account')
37
+ timeout = config_dict.get('timeout', 8)
38
+ client_config = get_client_config(
39
+ private_key_path=pk_path,
40
+ tiger_id=tiger_id,
41
+ account=account,
42
+ timeout=timeout,
43
+ )
44
+ self._config = client_config
45
+ self._quote_client = self._quote_client or QuoteClient(client_config, is_grab_permission=False)
46
+ self._trade_client = self._trade_client or TradeClient(client_config)
47
+ protocol, host, port = client_config.socket_host_port
48
+ self._push_client = self._push_client or PushClient(host, port, use_ssl=(protocol == 'ssl'))
49
+
50
+ def _grab_quote(self):
51
+ with self._grab_lock:
52
+ if self._has_grab:
53
+ return
54
+ try:
55
+ config_dict = self.broker_args
56
+ if mac_address := config_dict.get('mac_address', None):
57
+ from tigeropen.tiger_open_config import TigerOpenClientConfig
58
+ TigerOpenClientConfig.__get_device_id = lambda: mac_address
59
+ except Exception as e:
60
+ pass
61
+ self._quote_client.grab_quote_permission()
62
+ self._has_grab = True
63
+
64
+ @classmethod
65
+ def symbol_to_contract(cls, symbol) -> Contract | None:
66
+ region = ''
67
+ ticker = ''
68
+ if re.match(r'^[01356]\d{5}$', symbol):
69
+ region = 'CN'
70
+ ticker = symbol
71
+ if re.match(r'^\d{5}$', symbol):
72
+ region = 'HK'
73
+ ticker = symbol
74
+ if re.match(r'^\w{1,5}$', symbol):
75
+ region = 'US'
76
+ ticker = symbol
77
+ if not region or not ticker:
78
+ return None
79
+ return Contract(
80
+ trade_type=TradeType.Securities,
81
+ ticker=ticker,
82
+ region=region,
83
+ )
84
+
85
+ @classmethod
86
+ def contract_to_symbol(cls, contract: Contract) -> str | None:
87
+ if contract.trade_type != TradeType.Securities:
88
+ return None
89
+ region, ticker = contract.region, contract.ticker
90
+ symbol = None
91
+ if region == 'CN' and re.match(r'^[01356]\d{5}$', ticker):
92
+ symbol = ticker
93
+ elif region == 'HK' and re.match(r'^\d{5}$', ticker):
94
+ symbol = ticker
95
+ elif region == 'US' and re.match(r'^\w{1,5}$', ticker):
96
+ symbol = ticker
97
+ return symbol
98
+
99
+ def _positions(self):
100
+ from tigeropen.trade.domain.position import Position as TigerPosition
101
+ result = list()
102
+ with self._assets_bucket:
103
+ positions: list[TigerPosition] = self._trade_client.get_positions()
104
+ for p in positions or list():
105
+ symbol = p.contract.symbol
106
+ currency = p.contract.currency
107
+
108
+ if not symbol or not currency:
109
+ continue
110
+ contract = self.symbol_to_contract(symbol)
111
+ if not contract:
112
+ continue
113
+ qty = int(p.quantity)
114
+ position = Position(
115
+ broker=self.broker_name,
116
+ broker_display=self.broker_display,
117
+ contract=contract,
118
+ unit=Unit.Share,
119
+ currency=currency,
120
+ qty=qty,
121
+ )
122
+ result.append(position)
123
+ return result
124
+
125
+ async def positions(self):
126
+ return await self.call_sync(lambda: self._positions())
127
+
128
+ def _cash(self) -> Cash:
129
+ from tigeropen.trade.domain.prime_account import PortfolioAccount, Segment
130
+ with self._assets_bucket:
131
+ portfolio_account: PortfolioAccount = self._trade_client.get_prime_assets()
132
+ s: Segment = portfolio_account.segments.get('S')
133
+ currency = s.currency
134
+ assert currency == 'USD'
135
+ cash_balance = s.cash_balance
136
+ cash = Cash(
137
+ currency='USD',
138
+ amount=cash_balance,
139
+ )
140
+ return cash
141
+
142
+ async def cash(self) -> Cash:
143
+ return await self.call_sync(lambda: self._cash())
144
+
145
+ def _market_status(self) -> dict[str, dict[str, MarketStatus]]:
146
+ from tigeropen.common.consts import Market
147
+ from tigeropen.quote.domain.market_status import MarketStatus as TigerMarketStatus
148
+ client = self._quote_client
149
+ with self._market_status_bucket:
150
+ ms_list: list[TigerMarketStatus] = client.get_market_status(Market.ALL)
151
+
152
+ # 各个市场的状态定义见:
153
+ # https://quant.itigerup.com/openapi/zh/python/operation/quotation/stock.html#get-market-status-%E8%8E%B7%E5%8F%96%E5%B8%82%E5%9C%BA%E7%8A%B6%E6%80%81
154
+ sec_result = dict()
155
+ status_map = {
156
+ 'CLOSING': UnifiedStatus.CLOSED,
157
+ 'EARLY_CLOSED': UnifiedStatus.CLOSED,
158
+ 'MARKET_CLOSED': UnifiedStatus.CLOSED,
159
+ 'PRE_HOUR_TRADING': UnifiedStatus.PRE_HOURS,
160
+ 'TRADING': UnifiedStatus.RTH,
161
+ 'POST_HOUR_TRADING': UnifiedStatus.AFTER_HOURS,
162
+ # 这些映射是A股港股市场的映射
163
+ 'MIDDLE_CLOSE': UnifiedStatus.REST,
164
+ }
165
+ for ms in ms_list:
166
+ region = ms.market
167
+ origin_status = ms.trading_status
168
+ unified_status = status_map.get(origin_status, UnifiedStatus.UNKNOWN)
169
+ sec_result[region] = MarketStatus(
170
+ region=region,
171
+ origin_status=origin_status,
172
+ unified_status=unified_status,
173
+ )
174
+ return {
175
+ TradeType.Securities.name.lower(): sec_result,
176
+ }
177
+
178
+ async def market_status(self) -> dict[str, dict[str, MarketStatus]]:
179
+ return await self.call_sync(lambda: self._market_status())
180
+
181
+ def _quote(self, contract: Contract):
182
+ import pandas
183
+ self._grab_quote()
184
+ symbol = self.contract_to_symbol(contract)
185
+ currency = self.contract_to_currency(contract)
186
+ with self._quote_bucket:
187
+ pd = self._quote_client.get_stock_briefs(symbols=[symbol, ])
188
+ pd['us_date'] = pandas \
189
+ .to_datetime(pd['latest_time'], unit='ms') \
190
+ .dt.tz_localize('UTC') \
191
+ .dt.tz_convert('US/Eastern')
192
+ for index, row in pd.iterrows():
193
+ us_date, pre_close, open_price, latest_price, status, low_price, high_price = (
194
+ row['us_date'],
195
+ row['pre_close'],
196
+ row['open'],
197
+ row['latest_price'],
198
+ row['status'],
199
+ row['low'],
200
+ row['high'],
201
+ )
202
+ is_tradable = status == 'NORMAL'
203
+ return Quote(
204
+ contract=contract,
205
+ currency=currency,
206
+ is_tradable=is_tradable,
207
+ latest=latest_price,
208
+ pre_close=pre_close,
209
+ open_price=open_price,
210
+ high_price=high_price,
211
+ low_price=low_price,
212
+ time=us_date,
213
+ )
214
+ raise Exception(f'没有找到{contract}的快照行情')
215
+
216
+ async def quote(self, contract: Contract):
217
+ return await self.call_sync(lambda: self._quote(contract))
218
+
219
+ def _place_order(
220
+ self,
221
+ contract: Contract,
222
+ order_type: OrderType,
223
+ time_in_force: TimeInForce,
224
+ lifecycle: Lifecycle,
225
+ direction: str,
226
+ qty: int,
227
+ price: float = None,
228
+ **kwargs
229
+ ) -> str:
230
+ if contract.trade_type != TradeType.Securities:
231
+ raise Exception(f'不支持的下单品种: {contract.trade_type}')
232
+ if contract.region == 'US' and order_type == OrderType.Market and lifecycle != Lifecycle.RTH:
233
+ raise Exception(f'交易时段不支持市价单')
234
+ symbol = self.contract_to_symbol(contract)
235
+ currency = self.contract_to_currency(contract)
236
+ from tigeropen.common.util.contract_utils import stock_contract
237
+ from tigeropen.tiger_open_config import TigerOpenClientConfig
238
+ from tigeropen.common.util.order_utils import market_order, limit_order
239
+ cfg: TigerOpenClientConfig = self._config
240
+ client = self._trade_client
241
+ tiger_contract = stock_contract(symbol=symbol, currency=currency)
242
+
243
+ def _map_trade_side():
244
+ match direction:
245
+ case 'BUY':
246
+ return direction
247
+ case 'SELL':
248
+ return direction
249
+ case _:
250
+ raise Exception(f'不支持的买卖方向: {direction}')
251
+
252
+ def _map_time_in_force():
253
+ match time_in_force:
254
+ case TimeInForce.DAY:
255
+ return 'DAY'
256
+ case TimeInForce.GTC:
257
+ return 'GTC'
258
+ case _:
259
+ raise Exception(f'不支持的订单有效期: {time_in_force}')
260
+
261
+ def _map_lifecycle():
262
+ match lifecycle:
263
+ case Lifecycle.RTH:
264
+ return False
265
+ case Lifecycle.ETH:
266
+ return True
267
+ case _:
268
+ raise Exception(f'不支持的交易时段: {lifecycle}')
269
+
270
+ def _map_order():
271
+ match order_type:
272
+ case OrderType.Limit:
273
+ return limit_order(
274
+ account=cfg.account,
275
+ contract=tiger_contract,
276
+ action=_map_trade_side(),
277
+ quantity=qty,
278
+ limit_price=price,
279
+ )
280
+ case OrderType.Market:
281
+ return market_order(
282
+ account=cfg.account,
283
+ contract=tiger_contract,
284
+ action=_map_trade_side(),
285
+ quantity=qty,
286
+ )
287
+ case _:
288
+ raise Exception(f'不支持的订单类型: {order_type}')
289
+ tiger_order = _map_order()
290
+ tiger_order.time_in_force = time_in_force=_map_time_in_force()
291
+ tiger_order.outside_rth = _map_lifecycle()
292
+ with self._order_bucket:
293
+ client.place_order(tiger_order)
294
+ order_id = str(tiger_order.id)
295
+ assert order_id
296
+ return order_id
297
+
298
+ async def place_order(
299
+ self,
300
+ contract: Contract,
301
+ order_type: OrderType,
302
+ time_in_force: TimeInForce,
303
+ lifecycle: Lifecycle,
304
+ direction: str,
305
+ qty: int,
306
+ price: float = None,
307
+ **kwargs
308
+ ) -> str:
309
+ return await self.call_sync(lambda: self._place_order(
310
+ contract=contract,
311
+ order_type=order_type,
312
+ time_in_force=time_in_force,
313
+ lifecycle=lifecycle,
314
+ direction=direction,
315
+ qty=qty,
316
+ price=price,
317
+ **kwargs
318
+ ))
319
+
320
+ def _order(self, order_id: str) -> Order:
321
+ from tigeropen.trade.domain.order import Order as TigerOrder, OrderStatus
322
+ with self._order_bucket:
323
+ tiger_order: TigerOrder = self._trade_client.get_order(id=int(order_id))
324
+ if tiger_order is None:
325
+ raise Exception(f'查询不到订单{order_id}')
326
+ return Order(
327
+ order_id=order_id,
328
+ currency=tiger_order.contract.currency,
329
+ qty=tiger_order.quantity or 0,
330
+ filled_qty=tiger_order.filled or 0,
331
+ avg_price=tiger_order.avg_fill_price or 0.0,
332
+ error_reason=tiger_order.reason,
333
+ is_canceled=tiger_order.status == OrderStatus.CANCELLED,
334
+ )
335
+
336
+ async def order(self, order_id: str) -> Order:
337
+ return await self.call_sync(lambda: self._order(order_id=order_id))
338
+
339
+ def _cancel_order(self, order_id: str):
340
+ with self._order_bucket:
341
+ self._trade_client.cancel_order(id=int(order_id))
342
+
343
+ async def cancel_order(self, order_id: str):
344
+ await self.call_sync(lambda: self._cancel_order(order_id=order_id))
345
+
346
+
347
+ __all__ = ['Tiger', ]
@@ -0,0 +1,295 @@
1
+ import json
2
+ from datetime import datetime, UTC
3
+ from aiohttp import web
4
+ from httptrading.broker.base import *
5
+ from httptrading.model import *
6
+
7
+
8
+ class HttpTradingView(web.View):
9
+ __BROKERS: list[BaseBroker] = list()
10
+
11
+ @classmethod
12
+ def set_brokers(cls, brokers: list[BaseBroker]):
13
+ HttpTradingView.__BROKERS = brokers
14
+
15
+ @classmethod
16
+ def brokers(cls):
17
+ return HttpTradingView.__BROKERS.copy()
18
+
19
+ def instance_id(self) -> str:
20
+ return self.request.match_info.get('instance_id', '')
21
+
22
+ def current_broker(self):
23
+ broker = getattr(self.request, '__current_broker__', None)
24
+ if broker is None:
25
+ raise web.HTTPNotFound()
26
+ return broker
27
+
28
+ async def get_contract(self, from_json=False):
29
+ params: dict = await self.request.json() if from_json else self.request.query
30
+ trade_type = TradeType[params.get('tradeType', '--')]
31
+ region = params.get('region', '--')
32
+ ticker = params.get('ticker', '--')
33
+ contract = Contract(
34
+ trade_type=trade_type,
35
+ region=region,
36
+ ticker=ticker,
37
+ )
38
+ return contract
39
+
40
+ @classmethod
41
+ def json_default(cls, obj):
42
+ if isinstance(obj, Position):
43
+ return {
44
+ 'type': 'position',
45
+ 'broker': obj.broker,
46
+ 'brokerDisplay': obj.broker_display,
47
+ 'contract': cls.json_default(obj.contract),
48
+ 'unit': obj.unit.name,
49
+ 'currency': obj.currency,
50
+ 'qty': obj.qty,
51
+ }
52
+ if isinstance(obj, Contract):
53
+ return {
54
+ 'type': 'contract',
55
+ 'tradeType': obj.trade_type.name,
56
+ 'region': obj.region,
57
+ 'ticker': obj.ticker,
58
+ }
59
+ if isinstance(obj, Cash):
60
+ return {
61
+ 'type': 'cash',
62
+ 'currency': obj.currency,
63
+ 'amount': obj.amount,
64
+ }
65
+ if isinstance(obj, MarketStatus):
66
+ return {
67
+ 'type': 'marketStatus',
68
+ 'region': obj.region,
69
+ 'originStatus': obj.origin_status,
70
+ 'unifiedStatus': obj.unified_status.name,
71
+ }
72
+ if isinstance(obj, Quote):
73
+ return {
74
+ 'type': 'quote',
75
+ 'contract': cls.json_default(obj.contract),
76
+ 'currency': obj.currency,
77
+ 'isTradable': obj.is_tradable,
78
+ 'latest': obj.latest,
79
+ 'preClose': obj.pre_close,
80
+ 'highPrice': obj.high_price,
81
+ 'lowPrice': obj.low_price,
82
+ 'openPrice': obj.open_price,
83
+ 'timestamp': int(obj.time.timestamp() * 1000),
84
+ }
85
+ if isinstance(obj, Order):
86
+ return {
87
+ 'type': 'order',
88
+ 'orderId': obj.order_id,
89
+ 'currency': obj.currency,
90
+ 'qty': obj.qty,
91
+ 'filledQty': obj.filled_qty,
92
+ 'avgPrice': obj.avg_price,
93
+ 'errorReason': obj.error_reason,
94
+ 'isCanceled': obj.is_canceled,
95
+ 'isFilled': obj.is_filled,
96
+ 'isCompleted': obj.is_completed,
97
+ }
98
+ raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
99
+
100
+ @classmethod
101
+ def dumps(cls, obj):
102
+ return json.dumps(obj, default=cls.json_default)
103
+
104
+ @classmethod
105
+ def response_obj(cls, obj):
106
+ return web.Response(text=cls.dumps(obj), content_type='application/json')
107
+
108
+ @classmethod
109
+ def response_api(cls, broker: BaseBroker, args: dict = None, ex: Exception = None):
110
+ resp = {
111
+ 'type': 'apiResponse',
112
+ 'instanceId': broker.instance_id if broker else None,
113
+ 'broker': broker.broker_name if broker else None,
114
+ 'brokerDisplay': broker.broker_display if broker else None,
115
+ 'time': datetime.now(UTC).isoformat(),
116
+ 'ex': ex.__str__() if ex else None,
117
+ }
118
+ if args:
119
+ resp.update(args)
120
+ return cls.response_obj(resp)
121
+
122
+
123
+ class PlaceOrderView(HttpTradingView):
124
+ async def post(self):
125
+ broker = self.current_broker()
126
+ contract = await self.get_contract(from_json=True)
127
+ body_d: dict = await self.request.json()
128
+ price = body_d.get('price', 0)
129
+ qty = body_d.get('qty', 0)
130
+ order_type = OrderType[body_d.get('orderType', '')]
131
+ time_in_force = TimeInForce[body_d.get('timeInForce', '')]
132
+ lifecycle = Lifecycle[body_d.get('lifecycle', '')]
133
+ direction = body_d.get('direction', '')
134
+ if price:
135
+ price = float(price)
136
+ order_id: str = await broker.place_order(
137
+ contract=contract,
138
+ order_type=order_type,
139
+ time_in_force=time_in_force,
140
+ lifecycle=lifecycle,
141
+ direction=direction,
142
+ qty=qty,
143
+ price=price,
144
+ json=body_d,
145
+ )
146
+ return self.response_api(broker, {
147
+ 'orderId': order_id,
148
+ 'args': body_d,
149
+ })
150
+
151
+
152
+ class OrderStateView(HttpTradingView):
153
+ async def get(self):
154
+ broker = self.current_broker()
155
+ order_id = self.request.query.get('orderId', '')
156
+ order: Order = await broker.order(order_id=order_id)
157
+ return self.response_api(broker, {
158
+ 'order': order,
159
+ })
160
+
161
+
162
+ class CancelOrderView(HttpTradingView):
163
+ async def post(self):
164
+ broker = self.current_broker()
165
+ body_d: dict = await self.request.json()
166
+ order_id = body_d.get('orderId', '')
167
+ assert order_id
168
+ await broker.cancel_order(order_id=order_id)
169
+ return self.response_api(broker, {
170
+ 'canceled': True,
171
+ })
172
+
173
+
174
+ class CashView(HttpTradingView):
175
+ async def get(self):
176
+ broker = self.current_broker()
177
+ cash: Cash = await broker.cash()
178
+ return self.response_api(broker, {
179
+ 'cash': cash,
180
+ })
181
+
182
+
183
+ class PositionView(HttpTradingView):
184
+ async def get(self):
185
+ broker = self.current_broker()
186
+ positions: list[Position] = await broker.positions()
187
+ return self.response_api(broker, {
188
+ 'positions': positions,
189
+ })
190
+
191
+
192
+ class PlugInView(HttpTradingView):
193
+ async def get(self):
194
+ broker = self.current_broker()
195
+ pong = await broker.ping()
196
+ return self.response_api(broker, {
197
+ 'pong': pong,
198
+ })
199
+
200
+
201
+ class QuoteView(HttpTradingView):
202
+ async def get(self):
203
+ contract = await self.get_contract()
204
+ broker = self.current_broker()
205
+ quote: Quote = await broker.quote(contract)
206
+ return self.response_api(broker, {
207
+ 'quote': quote,
208
+ })
209
+
210
+
211
+ class MarketStatusView(HttpTradingView):
212
+ async def get(self):
213
+ broker = self.current_broker()
214
+ ms_dict = await broker.market_status()
215
+ return self.response_api(broker, {
216
+ 'marketStatus': ms_dict,
217
+ })
218
+
219
+
220
+ @web.middleware
221
+ async def auth_middleware(request: web.Request, handler):
222
+ instance_id = request.match_info.get('instance_id', '')
223
+ token = request.headers.get('HT-TOKEN', '')
224
+ if not instance_id:
225
+ raise web.HTTPNotFound
226
+ if not token:
227
+ raise web.HTTPNotFound
228
+ if len(token) < 16 or len(token) > 64:
229
+ raise web.HTTPNotFound
230
+ for broker in HttpTradingView.brokers():
231
+ if broker.instance_id != instance_id:
232
+ continue
233
+ if token not in broker.tokens:
234
+ raise web.HTTPNotFound
235
+ setattr(request, '__current_broker__', broker)
236
+ break
237
+ else:
238
+ raise web.HTTPNotFound
239
+ response: web.Response = await handler(request)
240
+ delattr(request, '__current_broker__')
241
+ return response
242
+
243
+
244
+ @web.middleware
245
+ async def exception_middleware(request: web.Request, handler):
246
+ try:
247
+ response: web.Response = await handler(request)
248
+ return response
249
+ except BrokerError as ex:
250
+ return HttpTradingView.response_api(broker=ex.broker, ex=ex)
251
+ except Exception as ex:
252
+ broker = getattr(request, '__current_broker__', None)
253
+ return HttpTradingView.response_api(broker=broker, ex=ex)
254
+
255
+
256
+ def run(
257
+ host: str,
258
+ port: int,
259
+ brokers: list[BaseBroker],
260
+ ) -> None:
261
+ app = web.Application(
262
+ middlewares=[
263
+ auth_middleware,
264
+ exception_middleware,
265
+ ],
266
+ )
267
+ app.add_routes(
268
+ [
269
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/order/place', PlaceOrderView),
270
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/order/state', OrderStateView),
271
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/order/cancel', CancelOrderView),
272
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/cash/state', CashView),
273
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/position/state', PositionView),
274
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/ping/state', PlugInView),
275
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/market/state', MarketStatusView),
276
+ web.view(r'/httptrading/api/{instance_id:\w{16,32}}/market/quote', QuoteView),
277
+ ]
278
+ )
279
+
280
+ async def _on_startup(app):
281
+ HttpTradingView.set_brokers(brokers)
282
+ for broker in brokers:
283
+ await broker.start()
284
+
285
+ async def _on_shutdown(app):
286
+ for broker in brokers:
287
+ await broker.shutdown()
288
+
289
+ app.on_startup.append(_on_startup)
290
+ app.on_shutdown.append(_on_shutdown)
291
+ web.run_app(
292
+ app,
293
+ host=host,
294
+ port=port,
295
+ )