├── .gitignore ├── .gitlab-ci.yml ├── Makefile ├── README.md ├── onetoken ├── __init__.py ├── account.py ├── autil.py ├── autil_test.py ├── config.py ├── config_test.py ├── conftest.py ├── log_test.py ├── logger.py ├── model.py ├── quote.py ├── quote_test.py ├── rpcutil.py ├── sign_test.py ├── util.py ├── util_test.py └── websocket_trade_test.py ├── setup.py └── tasks.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Example user template template 3 | ### Example user template 4 | 5 | # IntelliJ project files 6 | .idea 7 | *.iml 8 | out 9 | gen### Python template 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | env/ 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *,cover 56 | .hypothesis/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # dotenv 92 | .env 93 | 94 | # virtualenv 95 | .venv 96 | venv/ 97 | ENV/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | .pytest_cache 106 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | upload: 2 | before_script: 3 | - pip install -U invoke setuptools wheel 4 | script: 5 | - pwd 6 | - inv upload || true 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | make clean 3 | python3 setup.py sdist bdist_wheel && cd dist && pip3 install onetoken*whl --upgrade 4 | 5 | clean: 6 | rm dist build onetoken.egg-info -rf 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # onetoken-py-sdk 2 | 3 | https://1token.trade 4 | 5 | ## Upload a new version to pypi 6 | 7 | ``` 8 | inv upload 9 | ``` 10 | -------------------------------------------------------------------------------- /onetoken/__init__.py: -------------------------------------------------------------------------------- 1 | from . import autil 2 | from . import quote 3 | from . import util 4 | from .account import Account, Info 5 | from .config import Config 6 | from .logger import log, log_level 7 | from .model import * 8 | from .rpcutil import ServiceError, HTTPError, Code, Const 9 | 10 | __version__ = '0.2.210201.1' 11 | -------------------------------------------------------------------------------- /onetoken/account.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | import hmac 4 | import json 5 | import time 6 | import urllib.parse 7 | from datetime import datetime 8 | from typing import Union, Tuple 9 | 10 | import aiohttp 11 | import jwt 12 | 13 | from . import autil 14 | from . import util 15 | from .config import Config 16 | from .logger import log 17 | from .model import Info, Order 18 | 19 | 20 | def get_trans_host(exg): 21 | return '{}/{}'.format(Config.TRADE_HOST, exg) 22 | 23 | 24 | def get_ws_host(exg, name): 25 | return '{}/{}/{}'.format(Config.TRADE_HOST_WS, exg, name) 26 | 27 | 28 | def get_name_exchange(symbol): 29 | sp = symbol.split('/', 1) 30 | return sp[1], sp[0] 31 | 32 | 33 | def gen_jwt(secret, uid): 34 | payload = { 35 | 'user': uid, 36 | # 'nonce': nonce 37 | } 38 | c = jwt.encode(payload, secret, algorithm='RS256', headers={'iss': 'qb-trade', 'alg': 'RS256', 'typ': 'JWT'}) 39 | return c.decode('ascii') 40 | 41 | 42 | def gen_nonce(): 43 | return str(int(time.time() * 1000000)) 44 | 45 | 46 | def gen_sign(secret, verb, url, nonce, data_str): 47 | """Generate a request signature compatible with BitMEX.""" 48 | # Parse the url so we can remove the base and extract just the path. 49 | 50 | if data_str is None: 51 | data_str = '' 52 | 53 | parsed_url = urllib.parse.urlparse(url) 54 | path = parsed_url.path 55 | 56 | # print "Computing HMAC: %s" % verb + path + str(nonce) + data 57 | message = verb + path + str(nonce) + data_str 58 | # print(message) 59 | 60 | signature = hmac.new(bytes(secret, 'utf8'), bytes(message, 'utf8'), digestmod=hashlib.sha256).hexdigest() 61 | return signature 62 | 63 | 64 | IDLE = 'idle' 65 | GOING_TO_CONNECT = 'going-to-connect' 66 | CONNECTING = 'connecting' 67 | READY = 'ready' 68 | GOING_TO_DICCONNECT = 'going-to-disconnect' 69 | 70 | 71 | class Account: 72 | def __init__(self, symbol: str, api_key=None, api_secret=None, session=None, loop=None): 73 | """ 74 | 75 | :param symbol: account symbol, binance/test_user1 76 | :param api_key: ot-key in 1token 77 | :param api_secret: ot-secret in 1token 78 | :param session: support specified http session 79 | :param loop: 80 | """ 81 | self.symbol = symbol 82 | if api_key is None and api_secret is None: 83 | self.api_key, self.api_secret = self.load_ot_from_config_file() 84 | else: 85 | self.api_key = api_key 86 | self.api_secret = api_secret 87 | log.debug('async account init {}'.format(symbol)) 88 | self.name, self.exchange = get_name_exchange(symbol) 89 | if '/' in self.name: 90 | self.name, margin_contract = self.name.split('/', 1) 91 | self.margin_contract = f'{self.exchange}/{margin_contract}' 92 | else: 93 | self.margin_contract = None 94 | self.host = get_trans_host(self.exchange) 95 | self.host_ws = get_ws_host(self.exchange, self.name) 96 | if session is None: 97 | self.session = aiohttp.ClientSession(loop=loop) 98 | else: 99 | self.session = session 100 | self.ws = None 101 | self.ws_state = IDLE 102 | self.ws_sub_order = False # ws is subscribing order or not, true after sub-order is sent 103 | self.ws_support = True 104 | self.last_pong = 0 105 | self.closed = False 106 | 107 | self.sub_queue = {} 108 | self.tasks_keep_connection = asyncio.Task(self.keep_connection()) 109 | asyncio.ensure_future(self.tasks_keep_connection) 110 | 111 | async def start_subscribe_orders(self): 112 | log.info('start subscribe orders') 113 | await self.subscribe_orders() 114 | while not self.ws_sub_order: 115 | await asyncio.sleep(0.1) 116 | 117 | def close(self): 118 | if self.ws and not self.ws.closed: 119 | asyncio.ensure_future(self.ws.close()) 120 | if self.session and not self.session.closed: 121 | asyncio.ensure_future(self.session.close()) 122 | self.closed = True 123 | self.tasks_keep_connection.cancel() 124 | 125 | def __del__(self): 126 | self.close() 127 | 128 | def __str__(self): 129 | return '<{}>'.format(self.symbol) 130 | 131 | def __repr__(self): 132 | return '<{}:{}>'.format(self.__class__.__name__, self.symbol) 133 | 134 | @property 135 | def trans_path(self): 136 | return '{}/{}'.format(self.host, self.name) 137 | 138 | @property 139 | def ws_path(self): 140 | return self.host_ws 141 | 142 | async def get_pending_list(self, contract=None): 143 | return await self.get_order_list(contract) 144 | 145 | async def get_order_list(self, contract=None, state=None, source=None): 146 | data = {} 147 | if contract: 148 | data['contract'] = contract 149 | if state: 150 | data['state'] = state 151 | if source is not None: 152 | data['helper'] = source 153 | t = await self.api_call('get', '/orders', params=data) 154 | return t 155 | 156 | async def get_order_list_from_db(self, contract=None, state=None): 157 | return await self.get_order_list(contract, state, source='db') 158 | 159 | # TODO can be simplified @liuzk oid can be removed 160 | async def cancel_use_client_oid(self, oid, *oids): 161 | """ 162 | cancel order use client oid, support batch 163 | :param oid: 164 | :param oids: 165 | :return: 166 | """ 167 | if oids: 168 | oid = f'{oid},{",".join(oids)}' 169 | log.debug('Cancel use client oid', oid) 170 | 171 | data = {'client_oid': oid} 172 | t = await self.api_call('delete', '/orders', params=data) 173 | return t 174 | 175 | async def cancel_use_exchange_oid(self, oid, *oids): 176 | """ 177 | cancel order use exchange oid, support batch 178 | :param oid: 179 | :param oids: 180 | :return: 181 | """ 182 | if oids: 183 | oid = f'{oid},{",".join(oids)}' 184 | log.debug('Cancel use exchange oid', oid) 185 | data = {'exchange_oid': oid} 186 | t = await self.api_call('delete', '/orders', params=data) 187 | return t 188 | 189 | async def cancel_all(self, contract=None): 190 | log.debug('Cancel all') 191 | if contract: 192 | data = {'contract': contract} 193 | else: 194 | data = {} 195 | t = await self.api_call('delete', '/orders/all', params=data) 196 | return t 197 | 198 | async def get_info(self, timeout=15) -> Tuple[Union[Info, None], Union[Exception, None]]: 199 | y, err = await self.api_call('get', '/info', timeout=timeout) 200 | if err: 201 | return None, err 202 | if not isinstance(y, dict): 203 | return None, ValueError(f'{y} not dict') 204 | acc_info = Info(y) 205 | if self.margin_contract is not None: 206 | pos_symbol = self.margin_contract.split('/', 1)[-1] 207 | return acc_info.get_margin_acc_info(pos_symbol), None 208 | return acc_info, None 209 | 210 | async def place_and_cancel(self, con, price, bs, amount, sleep, options=None): 211 | k = util.rand_client_oid(con) 212 | res1, err1 = await self.place_order(con, price, bs, amount, client_oid=k, options=options) 213 | if err1: 214 | return (res1, None), (err1, None) 215 | await asyncio.sleep(sleep) 216 | if res1 and 'exchange_oid' in res1: 217 | exg_oid = res1['exchange_oid'] 218 | res2, err2 = await self.cancel_use_exchange_oid(exg_oid) 219 | else: 220 | res2, err2 = await self.cancel_use_client_oid(k) 221 | if err1 or err2: 222 | return (res1, res2), (err1, err2) 223 | return [res1, res2], None 224 | 225 | async def get_status(self): 226 | return await self.api_call('get', '/status') 227 | 228 | async def get_order_use_client_oid(self, oid, *oids): 229 | """ 230 | :param oid: 231 | :param oids: 232 | :return: 233 | """ 234 | if oids: 235 | oid = f'{oid},{",".join(oids)}' 236 | res = await self.api_call('get', '/orders', params={'client_oid': oid}) 237 | log.debug(res) 238 | return res 239 | 240 | async def get_order_use_exchange_oid(self, oid, *oids): 241 | """ 242 | :param oid: 243 | :param oids: 244 | :return: 245 | """ 246 | if oids: 247 | oid = f'{oid},{",".join(oids)}' 248 | res = await self.api_call('get', '/orders', params={'exchange_oid': oid}) 249 | log.debug(res) 250 | return res 251 | 252 | async def amend_order_use_client_oid(self, client_oid, price, amount): 253 | """ 254 | :param price: 255 | :param amount: 256 | :param client_oid: 257 | :return: 258 | """ 259 | log.debug('Amend order use client oid', client_oid, price, amount) 260 | 261 | data = {'price': price, 262 | 'amount': amount} 263 | params = {'client_oid': client_oid} 264 | res = await self.api_call('patch', '/orders', data=data, params=params) 265 | log.debug(res) 266 | return res 267 | 268 | async def amend_order_use_exchange_oid(self, exchange_oid, price, amount): 269 | """ 270 | :param price: 271 | :param amount: 272 | :param exchange_oid: 273 | :return: 274 | """ 275 | log.debug('Amend order use exchange oid', exchange_oid, price, amount) 276 | 277 | data = {'price': price, 278 | 'amount': amount} 279 | params = {'exchange_oid': exchange_oid} 280 | res = await self.api_call('patch', '/orders', data=data, params=params) 281 | log.debug(res) 282 | return res 283 | 284 | async def place_order(self, con, price, bs, amount, client_oid=None, tags=None, options=None, on_update=None): 285 | """ 286 | just pass request, and handle order update --> fire callback and ref_key 287 | :param options: 288 | :param con: 289 | :param price: 290 | :param bs: 291 | :param amount: 292 | :param client_oid: 293 | :param tags: a key value dict 294 | :param on_update: 295 | :return: 296 | """ 297 | if on_update and self.ws_state == IDLE: 298 | await self.start_subscribe_orders() 299 | log.debug('place order', con=con, price=price, bs=bs, amount=amount, client_oid=client_oid) 300 | 301 | data = {'contract': con, 302 | 'price': price, 303 | 'bs': bs, 304 | 'amount': amount} 305 | if client_oid: 306 | data['client_oid'] = client_oid 307 | if tags: 308 | data['tags'] = tags 309 | if options: 310 | data['options'] = options 311 | res = await self.api_call('post', '/orders', data=data) 312 | log.debug(res) 313 | if on_update: 314 | if not self.ws_support: 315 | log.warning('ws push not supported for this exchange {}'.format(self.exchange)) 316 | else: 317 | if self.ws_state != READY: 318 | log.warning(f'ws connection is {self.ws_state}/{READY}, on_update may failed.') 319 | if 'order' not in self.sub_queue: 320 | await self.subscribe_orders() 321 | ex, err = res 322 | if ex and 'exchange_oid' in ex: 323 | exg_oid = ex['exchange_oid'] 324 | if exg_oid not in self.sub_queue['order']: 325 | self.sub_queue['order'][exg_oid] = asyncio.Queue() 326 | asyncio.ensure_future(self.handle_order_q(exg_oid, on_update)) 327 | return res 328 | 329 | async def handle_order_q(self, exg_oid, on_update): 330 | if 'order' not in self.sub_queue: 331 | log.warning('order was not subscribed, on_update will not be handled.') 332 | return 333 | q = self.sub_queue['order'].get(exg_oid, None) 334 | if not q: 335 | log.warning('order queue for {} is not init yet.'.format(exg_oid)) 336 | return 337 | while self.is_running: 338 | try: 339 | order = await q.get() 340 | log.debug('on update order {}'.format(order)) 341 | if on_update: 342 | assert callable(on_update), 'on_update is not callable' 343 | 344 | try: 345 | if asyncio.iscoroutinefunction(on_update): 346 | await on_update(order) 347 | else: 348 | on_update(order) 349 | except: 350 | log.exception('handle info error') 351 | if order['status'] in Order.END_STATUSES: 352 | log.debug('{} finished with status {}'.format(order['exchange_oid'], order['status'])) 353 | break 354 | except: 355 | log.exception('handle q failed.') 356 | 357 | del self.sub_queue['order'][exg_oid] 358 | 359 | async def get_dealt_trans(self, con=None, source=None): 360 | """ 361 | get recent dealt transactions 362 | :param source: 363 | :param con: 364 | :return: 365 | """ 366 | # log.debug('Get dealt trans', con=con) 367 | data = {} 368 | if con is not None: 369 | data['contract'] = con 370 | if source is not None: 371 | data['helper'] = source 372 | res = await self.api_call('get', '/trans', params=data) 373 | # log.debug(res) 374 | return res 375 | 376 | async def get_dealt_trans_from_db(self, con=None): 377 | """ 378 | get recent dealt transactions 379 | :param con: 380 | :return: 381 | """ 382 | return await self.get_dealt_trans(con, source='db') 383 | 384 | async def post_withdraw(self, currency, amount, address, fee=None, client_wid=None, options=None): 385 | log.debug('Post withdraw', currency=currency, amount=amount, address=address, fee=fee, client_wid=client_wid) 386 | if client_wid is None: 387 | client_wid = util.rand_client_wid(self.exchange, currency) 388 | data = { 389 | 'currency': currency, 390 | 'amount': amount, 391 | 'address': address 392 | } 393 | if fee is not None: 394 | data['fee'] = fee 395 | if client_wid: 396 | data['client_wid'] = client_wid 397 | if options: 398 | data['options'] = json.dumps(options) 399 | res = await self.api_call('post', '/withdraws', data=data) 400 | log.debug(res) 401 | return res 402 | 403 | async def cancel_withdraw_use_exchange_wid(self, exchange_wid): 404 | log.debug('Cancel withdraw use exchange_wid', exchange_wid) 405 | data = {'exchange_wid': exchange_wid} 406 | return await self.api_call('delete', '/withdraws', params=data) 407 | 408 | async def cancel_withdraw_use_client_wid(self, client_wid): 409 | log.debug('Cancel withdraw use client_wid', client_wid) 410 | data = {'client_wid': client_wid} 411 | return await self.api_call('delete', '/withdraws', params=data) 412 | 413 | async def get_withdraw_use_exchange_wid(self, exchange_wid): 414 | log.debug('Cancel withdraw use exchange_wid', exchange_wid) 415 | data = {'exchange_wid': exchange_wid} 416 | return await self.api_call('get', '/withdraws', params=data) 417 | 418 | async def get_withdraw_use_client_wid(self, client_wid): 419 | log.debug('Cancel withdraw use client_wid', client_wid) 420 | data = {'client_wid': client_wid} 421 | return await self.api_call('get', '/withdraws', params=data) 422 | 423 | async def get_deposit_list(self, currency): 424 | log.debug('Get deposit list', currency) 425 | data = {'currency': currency} 426 | return await self.api_call('get', '/deposits', params=data) 427 | 428 | async def get_deposit_addr_list(self, currency): 429 | log.debug('Get deposit address list', currency) 430 | data = {'currency': currency} 431 | return await self.api_call('get', '/deposits/addresses', params=data) 432 | 433 | async def get_loan_records(self, contract=None): 434 | if contract is None: 435 | contract = self.margin_contract 436 | log.debug('Get loan orders', contract) 437 | data = {'contract': contract} 438 | return await self.api_call('get', '/loan-records', params=data) 439 | 440 | async def borrow(self, currency, amount, contract=None): 441 | if contract is None: 442 | contract = self.margin_contract 443 | log.debug('Borrow', contract, currency, amount) 444 | data = {'contract': contract, 'currency': currency, 'amount': amount} 445 | return await self.api_call('post', '/borrow', data=data) 446 | 447 | async def repay(self, exchange_loan_id, currency, amount): 448 | log.debug('Repay', exchange_loan_id, currency, amount) 449 | data = {'exchange_loan_id': exchange_loan_id, 'currency': currency, 'amount': amount} 450 | return await self.api_call('post', '/return', data=data) 451 | 452 | async def margin_transfer_in(self, currency, amount, contract=None): 453 | if contract is None: 454 | contract = self.margin_contract 455 | log.debug('Margin transfer in', contract, currency, amount) 456 | data = {'contract': contract, 'currency': currency, 'amount': amount, 'target': 'margin'} 457 | return await self.api_call('post', '/assets-internal', data=data) 458 | 459 | async def margin_transfer_out(self, currency, amount, contract=None): 460 | if contract is None: 461 | contract = self.margin_contract 462 | log.debug('Margin transfer out', contract, currency, amount) 463 | data = {'contract': contract, 'currency': currency, 'amount': amount, 'target': 'spot'} 464 | return await self.api_call('post', '/assets-internal', data=data) 465 | 466 | @property 467 | def is_running(self): 468 | return not self.closed 469 | 470 | async def api_call(self, method, endpoint, params=None, data=None, timeout=15): 471 | method = method.upper() 472 | if method == 'GET': 473 | func = self.session.get 474 | elif method == 'POST': 475 | func = self.session.post 476 | elif method == 'PATCH': 477 | func = self.session.patch 478 | elif method == 'DELETE': 479 | func = self.session.delete 480 | else: 481 | raise Exception('Invalid http method:{}'.format(method)) 482 | 483 | nonce = gen_nonce() 484 | # headers = {'jwt': gen_jwt(self.secret, self.user_name)} 485 | 486 | url = self.trans_path + endpoint 487 | 488 | # print(self.api_secret, method, url, nonce, data) 489 | json_str = json.dumps(data) if data else '' 490 | sign = gen_sign(self.api_secret, method, '/{}/{}{}'.format(self.exchange, self.name, endpoint), nonce, json_str) 491 | headers = {'Api-Nonce': str(nonce), 'Api-Key': self.api_key, 'Api-Signature': sign, 492 | 'Content-Type': 'application/json'} 493 | res, err = await autil.http_go(func, url=url, data=json_str, params=params, headers=headers, timeout=timeout) 494 | if err: 495 | return None, err 496 | return res, None 497 | 498 | def set_ws_state(self, new, reason=''): 499 | log.info(f'set ws state from {self.ws_state} to {new}', reason) 500 | self.ws_state = new 501 | 502 | async def keep_connection(self): 503 | while self.is_running: 504 | if not self.ws_support: 505 | break 506 | if self.ws_state == GOING_TO_CONNECT: 507 | await self.ws_connect() 508 | elif self.ws_state == READY: 509 | try: 510 | while not self.ws.closed: 511 | ping = datetime.now().timestamp() 512 | await self.ws.send_json({'uri': 'ping', 'uuid': ping}) 513 | await asyncio.sleep(10) 514 | if self.last_pong < ping: 515 | log.warning('ws connection heartbeat lost') 516 | break 517 | except: 518 | log.exception('ws connection ping failed') 519 | finally: 520 | self.set_ws_state(GOING_TO_CONNECT, 'heartbeat lost') 521 | elif self.ws_state == GOING_TO_DICCONNECT: 522 | await self.ws.close() 523 | await asyncio.sleep(1) 524 | log.info('keep connection end') 525 | 526 | async def ws_connect(self): 527 | self.set_ws_state(CONNECTING) 528 | nonce = gen_nonce() 529 | sign = gen_sign(self.api_secret, 'GET', f'/ws/{self.name}', nonce, None) 530 | headers = {'Api-Nonce': str(nonce), 'Api-Key': self.api_key, 'Api-Signature': sign} 531 | url = self.ws_path 532 | try: 533 | log.info('connect websocket', url) 534 | self.ws = await self.session.ws_connect(url, autoping=False, headers=headers, timeout=30) 535 | except: 536 | self.set_ws_state(GOING_TO_CONNECT, 'ws connect failed') 537 | log.exception('ws connect failed') 538 | await asyncio.sleep(5) 539 | else: 540 | log.info('ws connected.') 541 | asyncio.ensure_future(self.on_msg()) 542 | 543 | async def on_msg(self): 544 | while not self.ws.closed: 545 | msg = await self.ws.receive() 546 | try: 547 | if msg.type == aiohttp.WSMsgType.TEXT: 548 | await self.handle_message(msg.data) 549 | elif msg.type == aiohttp.WSMsgType.CLOSED: 550 | log.info('websocket closed') 551 | break 552 | elif msg.type == aiohttp.WSMsgType.ERROR: 553 | log.warning('error', msg) 554 | break 555 | except Exception as e: 556 | log.warning('msg error...', e) 557 | self.set_ws_state(GOING_TO_CONNECT, 'ws was disconnected...') 558 | 559 | async def handle_message(self, msg): 560 | try: 561 | data = json.loads(msg) 562 | log.debug(data) 563 | if 'uri' not in data: 564 | if 'code' in data: 565 | code = data['code'] 566 | if code == 'no-router-found': 567 | log.warning('ws push not supported for this exchange {}'.format(self.exchange)) 568 | self.ws_support = False 569 | return 570 | log.warning('unexpected msg get', data) 571 | return 572 | action = data['uri'] 573 | if action == 'pong': 574 | self.last_pong = datetime.now().timestamp() 575 | return 576 | if action in ['connection', 'status']: 577 | if data.get('code', data.get('status', None)) in ['ok', 'connected']: 578 | self.set_ws_state(READY, 'Connected and auth passed.') 579 | for key in self.sub_queue.keys(): 580 | await self.ws.send_json({'uri': 'sub-{}'.format(key)}) 581 | if key == 'order': 582 | self.ws_sub_order = True 583 | else: 584 | self.set_ws_state(GOING_TO_CONNECT, data['message']) 585 | elif action == 'info': 586 | if data.get('status', 'ok') == 'ok': 587 | if 'info' not in self.sub_queue: 588 | return 589 | info = data['data'] 590 | info = Info(info) 591 | for handler in self.sub_queue['info'].values(): 592 | try: 593 | await handler(info) 594 | if asyncio.iscoroutinefunction(handler): 595 | await handler(info) 596 | except: 597 | log.exception('handle info error') 598 | elif action == 'order' and 'order' in self.sub_queue: 599 | if data.get('status', 'ok') == 'ok': 600 | for order in data['data']: 601 | exg_oid = order['exchange_oid'] 602 | log.debug('order info updating', exg_oid, status=order['status']) 603 | if exg_oid not in self.sub_queue['order']: 604 | q = asyncio.Queue() 605 | self.sub_queue['order'][exg_oid] = q 606 | asyncio.ensure_future(self.ensure_order_dequeued(exg_oid)) 607 | self.sub_queue['order'][exg_oid].put_nowait(order) 608 | if '*' in self.sub_queue['order']: 609 | h = self.sub_queue['order']['*'] 610 | if asyncio.iscoroutinefunction(h): 611 | await h(order) 612 | else: 613 | # todo 这里处理order 拿到 error 的情况 614 | log.warning('order update error message', data) 615 | else: 616 | log.info(f'receive message {data}') 617 | except Exception as e: 618 | log.exception('handle msg exception', msg) 619 | 620 | async def ensure_order_dequeued(self, exg_oid): 621 | timeout = 10 622 | bg = datetime.now() 623 | while 'order' in self.sub_queue and exg_oid in self.sub_queue['order'] \ 624 | and not self.sub_queue['order'][exg_oid].empty(): 625 | if (datetime.now() - bg).total_seconds() > timeout: 626 | del self.sub_queue['order'][exg_oid] 627 | break 628 | await asyncio.sleep(2) 629 | 630 | async def subscribe_info(self, handler, handler_name=None): 631 | if not self.ws_support: 632 | log.warning('ws push not supported for this exchange {}'.format(self.exchange)) 633 | return 634 | if 'info' not in self.sub_queue: 635 | self.sub_queue['info'] = {} 636 | if handler_name is None: 637 | handler_name = 'default' 638 | if handler is not None: 639 | self.sub_queue['info'][handler_name] = handler 640 | if self.ws_state == READY: 641 | await self.ws.send_json({'uri': 'sub-info'}) 642 | elif self.ws_state == IDLE: 643 | self.set_ws_state(GOING_TO_CONNECT, 'user sub info') 644 | 645 | async def unsubscribe_info(self, handler_name=None): 646 | if handler_name is None: 647 | handler_name = 'default' 648 | if 'info' in self.sub_queue: 649 | del self.sub_queue['info'][handler_name] 650 | if len(self.sub_queue['info']) == 0 and self.ws_state == READY: 651 | await self.ws.send_json({'uri': 'unsub-info'}) 652 | del self.sub_queue['info'] 653 | if not self.sub_queue and self.ws_state != IDLE: 654 | self.set_ws_state(GOING_TO_DICCONNECT, 'subscribe nothing') 655 | 656 | async def subscribe_orders(self, handler=None): 657 | if 'order' not in self.sub_queue: 658 | self.sub_queue['order'] = {} 659 | if handler is not None: 660 | self.sub_queue['order']['*'] = handler 661 | if self.ws_state == READY: 662 | await self.ws.send_json({'uri': 'sub-order'}) 663 | self.ws_sub_order = True 664 | elif self.ws_state == IDLE: 665 | self.set_ws_state(GOING_TO_CONNECT, 'user sub order') 666 | 667 | async def unsubcribe_orders(self): 668 | if 'order' in self.sub_queue: 669 | del self.sub_queue['order'] 670 | if self.ws_state == READY: 671 | await self.ws.send_json({'uri': 'unsub-order'}) 672 | self.ws_sub_order = False 673 | if not self.sub_queue and self.ws_state != IDLE: 674 | self.set_ws_state(GOING_TO_DICCONNECT, 'subscribe nothing') 675 | 676 | @staticmethod 677 | def load_ot_from_config_file(): 678 | import os 679 | config = os.path.expanduser('~/.onetoken/config.yml') 680 | if os.path.isfile(config): 681 | log.info(f'load ot_key and ot_secret from {config}') 682 | import yaml 683 | js = yaml.safe_load(open(config).read()) 684 | ot_key, ot_secret = js.get('ot_key'), js.get('ot_secret') 685 | if ot_key is None: 686 | ot_key = js.get('api_key') 687 | if ot_secret is None: 688 | ot_secret = js.get('api_secret') 689 | return ot_key, ot_secret 690 | else: 691 | log.warning(f'load {config} fail') 692 | return None, None 693 | -------------------------------------------------------------------------------- /onetoken/autil.py: -------------------------------------------------------------------------------- 1 | """ 2 | async util 3 | """ 4 | import asyncio 5 | import json 6 | from datetime import datetime 7 | 8 | import aiohttp 9 | import arrow 10 | 11 | 12 | def dumper(obj): 13 | if isinstance(obj, arrow.Arrow): 14 | return obj.isoformat() 15 | if isinstance(obj, datetime): 16 | return obj.isoformat() 17 | return obj 18 | 19 | 20 | _aiohttp_sess = None 21 | 22 | 23 | def get_aiohttp_session(): 24 | global _aiohttp_sess 25 | if _aiohttp_sess is None: 26 | _aiohttp_sess = aiohttp.ClientSession() 27 | return _aiohttp_sess 28 | 29 | 30 | async def http_go(func, url, timeout=15, method='json', accept_4xx=False, *args, **kwargs): 31 | """ 32 | 33 | :param func: 34 | :param url: 35 | :param timeout: 36 | :param method: 37 | json -> return json dict 38 | raw -> return raw object 39 | text -> return string 40 | 41 | :param accept_4xx: 42 | :param args: 43 | :param kwargs: 44 | :return: 45 | """ 46 | from . import HTTPError 47 | assert not accept_4xx 48 | assert method in ['json', 'text', 'raw'] 49 | try: 50 | if 'params' not in kwargs or kwargs['params'] is None: 51 | kwargs['params'] = {} 52 | params = kwargs['params'] 53 | params['source'] = 'onetoken-py-sdk' 54 | kwargs['timeout'] = timeout 55 | resp = await asyncio.wait_for(func(url, *args, **kwargs), timeout) 56 | txt = await resp.text() 57 | if resp.status >= 500: 58 | return None, HTTPError(HTTPError.RESPONSE_5XX, txt) 59 | 60 | if 400 <= resp.status < 500: 61 | return None, HTTPError(HTTPError.RESPONSE_4XX, txt) 62 | 63 | if method == 'raw': 64 | return resp, None 65 | elif method == 'text': 66 | return txt, None 67 | elif method == 'json': 68 | try: 69 | return json.loads(txt), None 70 | except: 71 | return None, HTTPError(HTTPError.NOT_JSON, txt) 72 | except asyncio.TimeoutError: 73 | return None, HTTPError(HTTPError.TIMEOUT, "") 74 | except aiohttp.ClientError as e: 75 | return None, HTTPError(HTTPError.HTTP_ERROR, str(e)) 76 | except Exception as e: 77 | return None, HTTPError(HTTPError.HTTP_ERROR, str(e)) 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /onetoken/autil_test.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import pytest 3 | 4 | 5 | @pytest.mark.asyncio 6 | async def test_http(): 7 | from . import autil 8 | async with aiohttp.ClientSession() as sess: 9 | # res, err = await autil.http_go(sess.get, url='https://httpbin.org/delay/3', timeout=2) 10 | # print(res) 11 | # print(err) 12 | res, err = await autil.http_go(sess.get, url='http://localhost:3000/stream-html', timeout=20) 13 | print(res) 14 | print(err) 15 | 16 | res, err = await autil.http_go(sess.get, url='http://localhost:3000/stream-html', timeout=5) 17 | print(res) 18 | print(err) 19 | -------------------------------------------------------------------------------- /onetoken/config.py: -------------------------------------------------------------------------------- 1 | class Config: 2 | HOST_REST = 'https://1token.trade/api/v1' 3 | TRADE_HOST = 'https://1token.trade/api/v1/trade' 4 | TRADE_HOST_WS = 'wss://1token.trade/api/v1/ws/trade' 5 | TICK_HOST_WS = 'wss://1token.trade/api/v1/ws/tick?gzip=true' 6 | TICK_V3_HOST_WS = 'wss://1token.trade/api/v1/ws/tick-v3?gzip=true' 7 | CANDLE_HOST_WS = 'wss://1token.trade/api/v1/ws/candle?gzip=true' 8 | 9 | @classmethod 10 | def change_host(cls, target='1token.trade/', match='1token.trade/', nossl=False): 11 | for item in ['TRADE_HOST', 'TRADE_HOST_WS', 'TICK_HOST_WS', 'TICK_V3_HOST_WS', 'HOST_REST', 'CANDLE_HOST_WS']: 12 | new = getattr(cls, item).replace(match, target) 13 | if nossl: 14 | new = new.replace('https://', 'http://') 15 | new = new.replace('wss://', 'ws://') 16 | setattr(cls, item, new) 17 | -------------------------------------------------------------------------------- /onetoken/config_test.py: -------------------------------------------------------------------------------- 1 | from . import Config 2 | 3 | 4 | def test_config(): 5 | Config.change_host() 6 | print(Config) 7 | for key, value in Config.__dict__.items(): 8 | if isinstance(value, str) and '1token.trade' in value: 9 | assert '//1token.trade/api' in value 10 | print(key, value) 11 | -------------------------------------------------------------------------------- /onetoken/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | 6 | def pytest_configure(config): 7 | import sys 8 | sys._pytest = True 9 | 10 | 11 | @pytest.fixture(scope='session') 12 | def event_loop(): 13 | """Create an instance of the default event loop for each test case.""" 14 | loop = asyncio.get_event_loop() 15 | print('return global loop', id(loop)) 16 | yield loop 17 | print('loop close', id(loop)) 18 | loop.close() 19 | -------------------------------------------------------------------------------- /onetoken/log_test.py: -------------------------------------------------------------------------------- 1 | from . import log 2 | 3 | 4 | def test_log(): 5 | log.info('hello world') 6 | log.debug('hello world') 7 | log.warning('hello world') 8 | -------------------------------------------------------------------------------- /onetoken/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import functools 5 | from pathlib import Path 6 | 7 | log = logging.getLogger('ot') 8 | 9 | 10 | def set_log(): 11 | # syslog.basicConfig() 12 | # import logging as syslog 13 | # syslog.basicConfig() 14 | ch = logging.StreamHandler(sys.stdout) 15 | ch.setLevel(logging.DEBUG) 16 | ch.setFormatter( 17 | logging.Formatter('%(levelname)-.7s [%(asctime)s][1token]%(message)s', '%H:%M:%S')) 18 | log.addHandler(ch) 19 | log.setLevel(logging.INFO) 20 | 21 | def wrap(level, orig): 22 | @functools.wraps(orig) 23 | def new_func(*args, **kwargs): 24 | try: 25 | if level < log.level: 26 | return 27 | left = ' '.join(str(x) for x in args) 28 | right = ' '.join('{}={}'.format(k, v) for k, v in kwargs.items()) 29 | new = ' '.join(filter(None, [left, right])) 30 | import inspect 31 | r = inspect.stack()[1] 32 | new = f'[{Path(r.filename).name}:{r.lineno}] {new}' 33 | orig(new) 34 | except Exception as e: 35 | print('onetoken log fail', e, type(e)) 36 | import traceback 37 | traceback.print_exc() 38 | 39 | return new_func 40 | 41 | log.debug = wrap(logging.DEBUG, log.debug) 42 | log.info = wrap(logging.INFO, log.info) 43 | log.warning = wrap(logging.WARNING, log.warning) 44 | log.exception = wrap(logging.WARNING, log.exception) 45 | 46 | 47 | set_log() 48 | 49 | 50 | def log_level(level): 51 | print('set log level to {}'.format(level)) 52 | log.setLevel(level) 53 | 54 | 55 | def main(): 56 | log_level(logging.DEBUG) 57 | log.debug('debug log test') 58 | log.info('info log test') 59 | log.warning('warning log test') 60 | log.exception('exception log test') 61 | 62 | 63 | if __name__ == '__main__': 64 | main() 65 | -------------------------------------------------------------------------------- /onetoken/model.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import arrow 5 | import dateutil 6 | import dateutil.parser 7 | 8 | 9 | class Tick: 10 | def copy(self): 11 | return Tick(time=self.time, 12 | price=self.price, 13 | volume=self.volume, 14 | bids=json.loads(json.dumps(self.bids)), 15 | asks=json.loads(json.dumps(self.asks)), 16 | contract=self.contract, 17 | source=self.source, 18 | exchange_time=self.exchange_time, 19 | amount=self.amount, 20 | ) 21 | 22 | def __init__(self, time, price, volume=0, bids=None, asks=None, contract=None, 23 | source=None, 24 | exchange_time=None, 25 | amount=None, 26 | **kwargs): 27 | 28 | # internally use python3's datetime 29 | if isinstance(time, arrow.Arrow): 30 | time = time.datetime 31 | assert time.tzinfo 32 | self.contract = contract 33 | self.source = source 34 | self.time = time 35 | self.price = price 36 | self.volume = volume 37 | self.amount = amount 38 | self.bids = [] 39 | self.asks = [] 40 | if isinstance(exchange_time, arrow.Arrow): 41 | exchange_time = exchange_time.datetime 42 | if exchange_time: 43 | assert exchange_time.tzinfo 44 | self.exchange_time = exchange_time 45 | if bids: 46 | self.bids = sorted(bids, key=lambda x: -x['price']) 47 | if asks: 48 | self.asks = sorted(asks, key=lambda x: x['price']) 49 | for item in self.bids: 50 | assert 'price' in item and 'volume' in item 51 | for item in self.asks: 52 | assert 'price' in item and 'volume' in item 53 | # self.asks = asks 54 | 55 | # last as an candidate of last 56 | @property 57 | def last(self): 58 | return self.price 59 | 60 | @last.setter 61 | def last(self, value): 62 | self.price = value 63 | 64 | @property 65 | def bid1(self): 66 | if self.bids: 67 | return self.bids[0]['price'] 68 | return None 69 | 70 | @property 71 | def ask1(self): 72 | if self.asks: 73 | return self.asks[0]['price'] 74 | return None 75 | 76 | @property 77 | def weighted_middle(self): 78 | a = self.bids[0]['price'] * self.asks[0]['volume'] 79 | b = self.asks[0]['price'] * self.bids[0]['volume'] 80 | return (a + b) / (self.asks[0]['volume'] + self.bids[0]['volume']) 81 | 82 | @property 83 | def middle(self): 84 | return (self.ask1 + self.bid1) / 2 85 | 86 | def get_interest_side(self, bs): 87 | if bs == 's': 88 | return self.bids 89 | if bs == 'b': 90 | return self.asks 91 | 92 | def __str__(self): 93 | return '<{} {}.{:03d} {}/{} {} {}>'.format(self.contract, 94 | self.time.strftime('%H:%M:%S'), 95 | self.time.microsecond // 1000, 96 | self.bid1, 97 | self.ask1, 98 | self.last, 99 | self.volume) 100 | 101 | def __repr__(self): 102 | return str(self) 103 | 104 | @staticmethod 105 | def init_with_dict(dct): 106 | return Tick(dct['time'], dct['price'], dct['volume'], dct['bids'], dct['asks']) 107 | 108 | def to_dict(self): 109 | dct = {'time': self.time.isoformat(), 'price': self.price, 'volume': self.volume, 'asks': self.asks, 110 | 'bids': self.bids} 111 | if self.exchange_time: 112 | dct['exchange_time'] = self.exchange_time.isoformat() 113 | if self.contract: 114 | dct['symbol'] = self.contract 115 | return dct 116 | 117 | # @staticmethod 118 | # def from_dct(dct): 119 | # # con = ContractApi.get_by_symbol(dct['symbol']) 120 | # con = dct['symbol'] 121 | # return Tick(time=dateutil.parser.parse(dct['time']), price=dct['price'], bids=dct['bids'], asks=dct['asks'], 122 | # contract=con, volume=dct['volume']) 123 | 124 | def to_mongo_dict(self): 125 | dct = {'time': self.time, 'price': self.price, 'volume': self.volume, 'asks': self.asks, 'bids': self.bids} 126 | if self.contract: 127 | dct['contract'] = self.contract 128 | return dct 129 | 130 | def to_short_list(self): 131 | b = ','.join(['{},{}'.format(x['price'], x['volume']) for x in self.bids]) 132 | a = ','.join(['{},{}'.format(x['price'], x['volume']) for x in self.asks]) 133 | lst = [self.contract, self.time.timestamp(), self.price, self.volume, b, a] 134 | return lst 135 | 136 | @staticmethod 137 | def from_short_list(lst): 138 | if isinstance(lst[0], str): 139 | # convert string to contract 140 | # lst[0] = ContractApi.get_by_symbol(lst[0]) 141 | lst[0] = lst[0] 142 | bids, asks = lst[4], lst[5] 143 | bids = [{'price': float(p), 'volume': float(v)} for p, v in zip(bids.split(',')[::2], bids.split(',')[1::2])] 144 | asks = [{'price': float(p), 'volume': float(v)} for p, v in zip(asks.split(',')[::2], asks.split(',')[1::2])] 145 | 146 | time = arrow.Arrow.fromtimestamp(lst[1]).datetime 147 | return Tick(contract=lst[0], time=time, price=lst[2], volume=lst[3], bids=bids, asks=asks) 148 | 149 | def to_ws_str(self): 150 | lst = self.to_short_list() 151 | return json.dumps(lst) 152 | 153 | @classmethod 154 | def from_dict(cls, dict_or_str): 155 | if isinstance(dict_or_str, str): 156 | return cls.from_dict(json.loads(dict_or_str)) 157 | d = dict_or_str 158 | exg_tm = d.get('exchange_time', None) 159 | if exg_tm is not None: 160 | exg_tm = arrow.get(exg_tm) 161 | t = Tick(time=arrow.get(d['time']), 162 | exchange_time=exg_tm, 163 | # contract=ContractApi.get_by_symbol(d['contract']), 164 | contract=d['contract'], 165 | volume=d['volume'], 166 | asks=d['asks'], 167 | bids=d['bids'], 168 | price=d['last'], 169 | source=d.get('source', None), 170 | ) 171 | return t 172 | 173 | def bs1(self, bs): 174 | if bs == 'b': 175 | return self.bid1 176 | else: 177 | return self.ask1 178 | 179 | 180 | class Contract: 181 | 182 | def __init__(self, exchange: str, name: str, min_change=0.001, alias="", category='XTC', first_day=None, 183 | last_day=None, exec_price=None, currency=None, uid=None, 184 | min_amount=1, unit_amount=1, **kwargs): 185 | assert isinstance(min_change, float) or isinstance(min_change, int) 186 | self.name = name 187 | self.exchange = exchange 188 | self.category = category 189 | self.min_change = min_change 190 | self.alias = alias 191 | self.exec_price = exec_price 192 | self.first_day = first_day # the listing date of the contract 193 | self.last_day = last_day # the last date that this contract could be executed 194 | self.currency = currency 195 | self.min_amount = min_amount 196 | self.unit_amount = unit_amount 197 | self.uid = uid # the id use in database 198 | 199 | def __hash__(self): 200 | return hash(self.symbol) 201 | 202 | def __eq__(self, other): 203 | return self.symbol == other.symbol 204 | 205 | def __ne__(self, other): 206 | return not self.__eq__(other) 207 | 208 | @property 209 | def symbol(self): 210 | return self.exchange + '/' + self.name 211 | 212 | def __str__(self): 213 | return ''.format(self.exchange, self.name) 214 | 215 | def __repr__(self): 216 | return '<{}:{}>'.format(self.__class__.__name__, self.symbol) 217 | 218 | @classmethod 219 | def from_dict(cls, data): 220 | if 'exchange' in data: 221 | exchange = data['exchange'] 222 | else: 223 | sym = data['symbol'] 224 | exchange = sym.split('/')[0] 225 | return cls(exchange, data['name'], data['min_change'], data['alias'], data['category'], 226 | data['first_day'], data['last_day'], data['exec_price'], data['currency'], 227 | data['id'], data['min_amount'], data['unit_amount']) 228 | 229 | 230 | class Candle: 231 | def __init__(self, time, open, high, low, close, volume, contract, duration, amount=None): 232 | self.contract = contract 233 | self.time = time 234 | self.open = open 235 | self.high = high 236 | self.low = low 237 | self.close = close 238 | self.volume = volume 239 | self.amount = amount 240 | self.duration = duration 241 | 242 | def __str__(self): 243 | return ''.format(self.duration, self.contract, 244 | self.time.strftime('%H:%M:%S'), 245 | self.open, self.high, self.low, self.close, self.volume, 246 | self.amount) 247 | 248 | def __repr__(self): 249 | return self.__str__() 250 | 251 | @classmethod 252 | def from_dict(cls, data): 253 | return cls(arrow.get(data['time']), data['open'], data['high'], data['low'], 254 | data['close'], data['volume'], data['contract'], data['duration'], data.get('amount', None)) 255 | 256 | 257 | class Zhubi: 258 | def __init__(self, time, exchange_time, contract, price, amount, bs): 259 | self.contract = contract 260 | self.time = time 261 | self.exchange_time = exchange_time 262 | self.price = price 263 | self.amount = amount 264 | self.bs = bs 265 | 266 | def __str__(self): 267 | return f'' 268 | 269 | def __repr__(self): 270 | return self.__str__() 271 | 272 | @classmethod 273 | def from_dict(cls, data): 274 | return cls(arrow.get(data['time']).datetime, arrow.get(data['exchange_time']).datetime, data['contract'], data['price'], 275 | data['amount'], data['bs']) 276 | 277 | 278 | class Info: 279 | def __init__(self, data): 280 | assert isinstance(data, dict) 281 | # if 'position' not in y: 282 | # log.warning('failed', self.symbol, str(y)) 283 | # return None, Exception('ACC_GET_INFO_FAILED') 284 | self.data = data 285 | # ['position_dict'] 286 | self.position_dict = {item['contract']: item for item in data.get('position', [])} 287 | 288 | @property 289 | def balance(self): 290 | return self.data['balance'] 291 | 292 | def get_total_amount(self, pos_symbol): 293 | if pos_symbol in self.position_dict: 294 | return float(self.position_dict[pos_symbol]['total_amount']) 295 | else: 296 | return 0.0 297 | 298 | def get_margin_acc_info(self, pos_symbol): 299 | if pos_symbol not in self.position_dict: 300 | return None 301 | pos = self.position_dict[pos_symbol] 302 | coin, base = pos_symbol.split('.') 303 | data_dict = { 304 | 'balance': pos['value_cny'], 305 | 'cash': pos['value_cny_base'] if base == 'usdt' else 0, 306 | 'market_value': pos['market_value'], 307 | 'market_value_detail': { 308 | coin: pos['market_value_coin'], 309 | base: pos['market_value_base'] 310 | }, 311 | 'risk_rate': pos['risk_rate'], 312 | 'position': [ 313 | { 314 | 'contract': coin, 315 | 'total_amount': pos['amount_coin'], 316 | 'available': pos['available_coin'], 317 | 'frozen': pos['frozen_coin'], 318 | 'loan': pos['loan_coin'], 319 | 'market_value': pos['market_value_coin'], 320 | 'value_cny': pos['value_cny_coin'] 321 | }, 322 | { 323 | 'contract': base, 324 | 'total_amount': pos['amount_base'], 325 | 'available': pos['available_base'], 326 | 'frozen': pos['frozen_base'], 327 | 'loan': pos['loan_base'], 328 | 'market_value': pos['market_value_base'], 329 | 'value_cny': pos['value_cny_base'] 330 | } 331 | ] 332 | } 333 | return Info(data_dict) 334 | 335 | def __repr__(self): 336 | return json.dumps(self.data) 337 | 338 | 339 | class Order: 340 | BUY = 'b' 341 | SELL = 's' 342 | 343 | def __init__(self, contract_symbol, entrust_price, bs, entrust_amount, account_symbol=None, entrust_time=None, 344 | client_oid=None, exg_oid=None, average_dealt_price=None, dealt_amount=None, comment="", status=None, 345 | last_update=None, version=0, last_dealt_amount=None, tags=None, options=None, commission=0): 346 | assert bs == self.BUY or bs == self.SELL 347 | self.bs = bs 348 | self.entrust_price = entrust_price 349 | self.entrust_amount = entrust_amount 350 | self.contract_symbol = contract_symbol 351 | self.account = account_symbol 352 | self.exchange_oid = exg_oid 353 | self.client_oid = client_oid 354 | 355 | if entrust_time: 356 | self.entrust_time = entrust_time 357 | else: 358 | self.entrust_time = arrow.now().datetime 359 | if last_update: 360 | self.last_update = last_update 361 | else: 362 | self.last_update = self.entrust_time 363 | 364 | self.comment = comment 365 | self.status = status 366 | # version will increase 1 once the order status changed 367 | self.version = version 368 | # last change means the different deal amount compare with the last status of order 369 | self.last_dealt_amount = last_dealt_amount 370 | self.avg_dealt_price = average_dealt_price 371 | self.dealt_amount = dealt_amount 372 | self.commission = commission 373 | self.tags = tags if tags else {} 374 | self.options = options if options else {} 375 | 376 | @staticmethod 377 | def from_dict(dct) -> 'Order': 378 | o = Order(contract_symbol=dct['contract'], 379 | entrust_price=dct['entrust_price'], 380 | average_dealt_price=dct.get('average_dealt_price', 0), 381 | bs=dct['bs'], 382 | entrust_amount=dct['entrust_amount'], 383 | entrust_time=dateutil.parser.parse(dct['entrust_time']), 384 | account_symbol=dct['account'], 385 | last_update=dateutil.parser.parse(dct['last_update']), 386 | exg_oid=dct['exchange_oid'], 387 | client_oid=dct['client_oid'], 388 | status=dct['status'], 389 | version=dct['version'], 390 | dealt_amount=dct.get('dealt_amount', 0), 391 | last_dealt_amount=dct.get('last_dealt_amount', 0), 392 | commission=dct.get('commission', 0), 393 | tags=dct.get('tags', {}), 394 | options=dct.get('options', {}), 395 | comment=dct.get('comment', '') 396 | ) 397 | return o 398 | 399 | def __str__(self): 400 | if self.entrust_time: 401 | lst = (self.client_oid[-6:] if self.client_oid else None, self.entrust_time.strftime('%H:%M:%S'), 402 | self.contract_symbol, 403 | self.avg_dealt_price, self.entrust_price, self.bs, self.dealt_amount, self.entrust_amount, 404 | self.status) 405 | return '<{} {} {} {}/{} {} {}/{} {}>'.format(*lst) 406 | else: 407 | return '(%s, %s,%s,%s,%s,%s)' % ( 408 | self.client_oid, self.contract_symbol, self.entrust_price, self.bs, '---', self.entrust_amount) 409 | 410 | def __repr__(self): 411 | return str(self) 412 | 413 | ERROR_ORDER = 'error-order' 414 | 415 | WAITING = 'waiting' # received from strategy 416 | PENDING = 'pending' # already send to broker, and received status update from broker, waiting for deal 417 | PART_DEAL_PENDING = 'part-deal-pending' 418 | WITHDRAWING = 'withdrawing' # withdraw request send, wait for action 419 | PART_DEAL_WITHDRAWING = 'part-deal-withdrawing' # similar with above, but when withdraw send, some already dealt 420 | 421 | DEALT = 'dealt' 422 | WITHDRAWN = 'withdrawn' # STOP status 423 | PART_DEAL_WITHDRAWN = 'part-deal-withdrawn' # STOP status 424 | 425 | ACTIVE = 'active' 426 | END = 'end' 427 | ALL = 'all' 428 | 429 | ACTIVE_STATUS = [WAITING, PENDING, PART_DEAL_PENDING, PART_DEAL_WITHDRAWING, WITHDRAWING, ACTIVE] 430 | 431 | END_STATUSES = [ERROR_ORDER, DEALT, WITHDRAWN, PART_DEAL_WITHDRAWN, END] 432 | 433 | ALL_STATUSES = [] 434 | ALL_STATUSES.extend(ACTIVE_STATUS) 435 | ALL_STATUSES.extend(END_STATUSES) 436 | 437 | 438 | class DealtTrans: 439 | BUY = 'b' 440 | SELL = 's' 441 | MAKER = 'maker' 442 | TAKER = 'taker' 443 | 444 | def __init__(self, exchange_tid=None, exchange_oid=None, client_oid=None, dealt_price=None, bs=None, 445 | dealt_amount=None, commission=None, commission_currency=None, dealt_type=None, exchange_update=None, 446 | tags=None, account=None, contract=None): 447 | self.client_oid = client_oid 448 | self.dealt_price = dealt_price 449 | self.bs = bs 450 | self.dealt_amount = dealt_amount 451 | self.exchange_oid = exchange_oid 452 | self.exchange_tid = exchange_tid 453 | self.commission = commission 454 | self.commission_currency = commission_currency 455 | self.dealt_type = dealt_type 456 | self.exchange_update = exchange_update 457 | self.tags = tags 458 | self.account = account 459 | self.contract = contract 460 | 461 | def to_dict(self): 462 | return { 463 | 'client_oid': self.client_oid, 464 | 'dealt_pr:ce': self.dealt_price, 465 | 'bs': self.bs, 466 | 'dealt_amount': self.dealt_amount, 467 | 'exchange_oid': self.exchange_oid, 468 | 'exchange_tid': self.exchange_tid, 469 | 'commission': self.commission, 470 | 'commission_currency': self.commission_currency, 471 | 'dealt_type': self.dealt_type, 472 | 'exchange_update': self.exchange_update, 473 | 'tags': self.tags, 474 | 'account': self.account, 475 | 'contract': self.contract 476 | } 477 | 478 | @classmethod 479 | def from_dict(cls, dct): 480 | client_oid = dct['client_oid'] 481 | dealt_price = dct['dealt_price'] 482 | bs = dct['bs'] 483 | dealt_amount = dct['dealt_amount'] 484 | exchange_oid = dct['exchange_oid'] 485 | exchange_tid = dct['exchange_tid'] 486 | commission = dct['commission'] 487 | commission_currency = dct['commission_currency'] 488 | dealt_type = dct['dealt_type'] 489 | exchange_update = dct['exchange_update'] 490 | tags = dct['tags'] 491 | account = dct['account'] 492 | contract = dct['contract'] 493 | return DealtTrans(exchange_tid=exchange_tid, exchange_oid=exchange_oid, client_oid=client_oid, 494 | dealt_price=dealt_price, bs=bs, 495 | dealt_amount=dealt_amount, commission=commission, commission_currency=commission_currency, 496 | dealt_type=dealt_type, exchange_update=exchange_update, 497 | tags=tags, account=account, contract=contract) 498 | 499 | 500 | class Error: 501 | 502 | def __init__(self, code, message='', status=400, data=None): 503 | self.code = code 504 | self.message = message 505 | self.status = status 506 | self.data = data 507 | 508 | def to_dict(self): 509 | return { 510 | 'code': self.code, 511 | 'message': self.message, 512 | 'status': self.status, 513 | 'data': self.data 514 | } 515 | 516 | @classmethod 517 | def from_dict(cls, dct): 518 | code = dct['code'] 519 | message = dct['message'] 520 | status = dct['status'] 521 | data = dct.get('data') 522 | return Error(code, message, status, data) 523 | 524 | @classmethod 525 | def from_http_error(cls, http_error): 526 | """ 527 | 528 | :param http_error: 529 | :return: 530 | """ 531 | code = http_error.code 532 | message = http_error.message 533 | data = None 534 | try: 535 | if message: 536 | message = json.loads(message) 537 | code = message['code'] 538 | message = message['message'] 539 | data = message.get('date') 540 | except Exception as e: 541 | logging.exception('parse err message exception', e) 542 | raise e 543 | return Error(code=code, message=message, data=data) 544 | -------------------------------------------------------------------------------- /onetoken/quote.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from collections import defaultdict 4 | 5 | import aiohttp 6 | import arrow 7 | 8 | from .config import Config 9 | from .logger import log 10 | from .model import Tick, Contract, Candle, Zhubi 11 | 12 | 13 | class Quote: 14 | def __init__(self, key, ws_url, data_parser): 15 | self.key = key 16 | self.ws_url = ws_url 17 | self.data_parser = data_parser 18 | self.sess = None 19 | self.ws = None 20 | self.queue_handlers = defaultdict(list) 21 | self.data_queue = {} 22 | self.connected = False 23 | self.authorized = False 24 | self.lock = asyncio.Lock() 25 | self.ensure_connection = True 26 | self.pong = 0 27 | self.task_list = [] 28 | self.task_list.append(asyncio.ensure_future(self.ensure_connected())) 29 | self.task_list.append(asyncio.ensure_future(self.heart_beat_loop())) 30 | 31 | async def ensure_connected(self): 32 | log.debug('Connecting to {}'.format(self.ws_url)) 33 | sleep_seconds = 2 34 | while self.ensure_connection: 35 | if not self.connected: 36 | try: 37 | if self.sess and not self.sess.closed: 38 | await self.sess.close() 39 | self.sess = aiohttp.ClientSession() 40 | self.ws = await self.sess.ws_connect(self.ws_url, autoping=False, timeout=30) 41 | await self.ws.send_json({'uri': 'auth'}) 42 | except Exception as e: 43 | try: 44 | await self.sess.close() 45 | except: 46 | log.exception('close session fail') 47 | self.sess = None 48 | self.ws = None 49 | log.warning(f'try connect to {self.ws_url} failed, sleep for {sleep_seconds} seconds...', e) 50 | await asyncio.sleep(sleep_seconds) 51 | sleep_seconds = min(sleep_seconds * 2, 64) 52 | else: 53 | log.debug('Connected to WS') 54 | self.connected = True 55 | sleep_seconds = 2 56 | self.pong = arrow.now().timestamp 57 | asyncio.ensure_future(self.on_msg()) 58 | wait_for_auth = 0 59 | while not self.authorized and wait_for_auth < 5: 60 | await asyncio.sleep(0.1) 61 | wait_for_auth += 0.1 62 | if wait_for_auth >= 5: 63 | log.warning('wait for auth success timeout') 64 | await self.ws.close() 65 | async with self.lock: 66 | q_keys = list(self.queue_handlers.keys()) 67 | if q_keys: 68 | log.info('recover subscriptions', q_keys) 69 | for q_key in q_keys: 70 | sub_data = json.loads(q_key) 71 | asyncio.ensure_future(self.subscribe_data(**sub_data)) 72 | else: 73 | await asyncio.sleep(1) 74 | 75 | async def heart_beat_loop(self): 76 | while True: 77 | try: 78 | if self.ws and not self.ws.closed: 79 | if arrow.now().timestamp - self.pong > 20: 80 | log.warning('connection heart beat lost') 81 | await self.ws.close() 82 | else: 83 | await self.ws.send_json({'uri': 'ping'}) 84 | finally: 85 | await asyncio.sleep(5) 86 | 87 | async def on_msg(self): 88 | while not self.ws.closed: 89 | msg = await self.ws.receive() 90 | try: 91 | if msg.type == aiohttp.WSMsgType.BINARY or msg.type == aiohttp.WSMsgType.TEXT: 92 | import gzip 93 | if msg.type == aiohttp.WSMsgType.TEXT: 94 | data = json.loads(msg.data) 95 | else: 96 | data = json.loads(gzip.decompress(msg.data).decode()) 97 | uri = data.get('uri', 'data') 98 | if uri == 'pong': 99 | self.pong = arrow.now().timestamp 100 | elif uri == 'auth': 101 | log.info(data) 102 | self.authorized = True 103 | elif uri == 'subscribe-single-tick-verbose': 104 | log.info(data) 105 | elif uri == 'subscribe-single-zhubi-verbose': 106 | log.info(data) 107 | elif uri == 'subscribe-single-candle': 108 | log.info(data) 109 | else: 110 | q_key, parsed_data = self.data_parser(data) 111 | if q_key is None: 112 | log.warning('unknown message', data) 113 | continue 114 | if q_key in self.data_queue: 115 | self.data_queue[q_key].put_nowait(parsed_data) 116 | elif msg.type == aiohttp.WSMsgType.CLOSED: 117 | log.warning('closed', msg) 118 | break 119 | elif msg.type == aiohttp.WSMsgType.ERROR: 120 | log.warning('error', msg) 121 | break 122 | except Exception as e: 123 | log.warning('msg error...', e) 124 | try: 125 | await self.ws.close() 126 | except: 127 | pass 128 | 129 | self.connected = False 130 | self.authorized = False 131 | log.warning('ws was disconnected...') 132 | 133 | async def subscribe_data(self, uri, on_update=None, **kwargs): 134 | log.info('subscribe', uri, **kwargs) 135 | while not self.connected or not self.authorized: 136 | await asyncio.sleep(1) 137 | sub_data = {'uri': uri} 138 | sub_data.update(kwargs) 139 | q_key = json.dumps(sub_data, sort_keys=True) 140 | 141 | async with self.lock: 142 | try: 143 | await self.ws.send_json(sub_data) 144 | log.info('sub data', sub_data) 145 | if q_key not in self.data_queue: 146 | self.data_queue[q_key] = asyncio.Queue() 147 | if on_update: 148 | if not self.queue_handlers[q_key]: 149 | asyncio.ensure_future(self.handle_q(q_key)) 150 | except Exception as e: 151 | log.warning('subscribe {} failed...'.format(kwargs), e) 152 | else: 153 | if on_update: 154 | self.queue_handlers[q_key].append(on_update) 155 | 156 | async def handle_q(self, q_key): 157 | while q_key in self.data_queue: 158 | q = self.data_queue[q_key] 159 | try: 160 | tk = await q.get() 161 | except: 162 | log.warning('get data from queue failed') 163 | continue 164 | for callback in self.queue_handlers[q_key]: 165 | if asyncio.iscoroutinefunction(callback): 166 | try: 167 | await callback(tk) 168 | except: 169 | log.exception('quote callback fail') 170 | else: 171 | try: 172 | callback(tk) 173 | except: 174 | log.exception('quote callback fail') 175 | 176 | async def close(self): 177 | self.ensure_connection = False 178 | for task in self.task_list: 179 | task.cancel() 180 | if self.sess: 181 | await self.sess.close() 182 | 183 | 184 | class TickQuote(Quote): 185 | def __init__(self, key): 186 | super().__init__(key, Config.TICK_HOST_WS, self.parse_tick) 187 | self.channel = 'subscribe-single-tick-verbose' 188 | 189 | def parse_tick(self, data): 190 | try: 191 | tick = Tick.from_dict(data['data']) 192 | q_key = json.dumps({'contract': tick.contract, 'uri': self.channel}, sort_keys=True) 193 | return q_key, tick 194 | except Exception as e: 195 | log.warning('parse error', e) 196 | return None, None 197 | 198 | async def subscribe_tick(self, contract, on_update): 199 | await self.subscribe_data(self.channel, on_update=on_update, contract=contract) 200 | 201 | 202 | class TickV3Quote(Quote): 203 | def __init__(self): 204 | super().__init__('tick.v3', Config.TICK_V3_HOST_WS, self.parse_tick) 205 | self.channel = 'subscribe-single-tick-verbose' 206 | print(Config.TICK_V3_HOST_WS) 207 | self.ticks = {} 208 | 209 | def parse_tick(self, data): 210 | try: 211 | c = data['c'] 212 | tm = arrow.get(data['tm']) 213 | et = arrow.get(data['et']) if 'et' in data else None 214 | tp = data['tp'] 215 | q_key = json.dumps({'contract': c, 'uri': self.channel}, sort_keys=True) 216 | if tp == 's': 217 | bids = [{'price': p, 'volume': v} for p, v in data['b']] 218 | asks = [{'price': p, 'volume': v} for p, v in data['a']] 219 | tick = Tick(tm, data['l'], data['v'], bids, asks, c, 'tick.v3', et, data['vc']) 220 | self.ticks[tick.contract] = tick 221 | return q_key, tick 222 | elif tp == 'd': 223 | if c not in self.ticks: 224 | log.warning('update arriving before snapshot', self.channel, data) 225 | return None, None 226 | tick = self.ticks[c].copy() 227 | 228 | tick.time = tm.datetime 229 | tick.exchange_time = et.datetime 230 | tick.price = data['l'] 231 | tick.volume = data['v'] 232 | tick.amount = data['vc'] 233 | bids = {p: v for p, v in data['b']} 234 | old_bids = {item['price']: item['volume'] for item in tick.bids} 235 | old_bids.update(bids) 236 | bids = [{'price': p, 'volume': v} for p, v in old_bids.items() if v > 0] 237 | bids = sorted(bids, key=lambda x: x['price'], reverse=True) 238 | 239 | asks = {p: v for p, v in data['a']} 240 | old_asks = {item['price']: item['volume'] for item in tick.asks} 241 | old_asks.update(asks) 242 | asks = [{'price': p, 'volume': v} for p, v in old_asks.items() if v > 0] 243 | asks = sorted(asks, key=lambda x: x['price']) 244 | 245 | tick.bids = bids 246 | tick.asks = asks 247 | self.ticks[c] = tick 248 | return q_key, tick 249 | except Exception as e: 250 | log.warning('parse error', e, data) 251 | return None, None 252 | 253 | async def subscribe_tick_v3(self, contract, on_update): 254 | await self.subscribe_data(self.channel, on_update=on_update, contract=contract) 255 | 256 | 257 | class CandleQuote(Quote): 258 | def __init__(self, key): 259 | super().__init__(key, Config.CANDLE_HOST_WS, self.parse_candle) 260 | self.channel = 'subscribe-single-candle' 261 | self.authorized = True 262 | 263 | def parse_candle(self, data): 264 | try: 265 | if 'data' in data: 266 | data = data['data'] 267 | candle = Candle.from_dict(data) 268 | q_key = json.dumps({'contract': candle.contract, 'duration': candle.duration, 'uri': self.channel}, 269 | sort_keys=True) 270 | return q_key, candle 271 | except Exception as e: 272 | log.warning('parse error', e) 273 | return None, None 274 | 275 | async def subscribe_candle(self, contract, duration, on_update): 276 | await self.subscribe_data(self.channel, on_update=on_update, contract=contract, duration=duration) 277 | 278 | 279 | class ZhubiQuote(Quote): 280 | def __init__(self, key): 281 | super().__init__(key, Config.TICK_HOST_WS, self.parse_zhubi) 282 | self.channel = 'subscribe-single-zhubi-verbose' 283 | 284 | def parse_zhubi(self, data): 285 | try: 286 | zhubi = [Zhubi.from_dict(data) for data in data['data']] 287 | q_key = json.dumps({'contract': zhubi[0].contract, 'uri': self.channel}, sort_keys=True) 288 | return q_key, zhubi 289 | except Exception as e: 290 | log.warning('parse error', e) 291 | return None, None 292 | 293 | async def subscribe_zhubi(self, contract, on_update): 294 | await self.subscribe_data(self.channel, on_update=on_update, contract=contract) 295 | 296 | 297 | _client_pool = {} 298 | 299 | 300 | async def get_client(key='defalut'): 301 | if key in _client_pool: 302 | return _client_pool[key] 303 | else: 304 | c = TickQuote(key) 305 | _client_pool[key] = c 306 | return c 307 | 308 | 309 | async def subscribe_tick(contract, on_update): 310 | c = await get_client() 311 | return await c.subscribe_tick(contract, on_update) 312 | 313 | 314 | _tick_v3_client = None 315 | 316 | 317 | async def get_v3_client(): 318 | global _tick_v3_client 319 | if _tick_v3_client is None: 320 | _tick_v3_client = TickV3Quote() 321 | return _tick_v3_client 322 | 323 | 324 | async def subscribe_tick_v3(contract, on_update): 325 | c = await get_v3_client() 326 | return await c.subscribe_tick_v3(contract, on_update) 327 | 328 | 329 | _candle_client_pool = {} 330 | 331 | 332 | async def get_candle_client(key='defalut'): 333 | if key in _candle_client_pool: 334 | return _candle_client_pool[key] 335 | else: 336 | c = CandleQuote(key) 337 | _candle_client_pool[key] = c 338 | return c 339 | 340 | 341 | async def subscribe_candle(contract, duration, on_update): 342 | c = await get_candle_client() 343 | return await c.subscribe_candle(contract, duration, on_update) 344 | 345 | 346 | _zhubi_quote_pool = {} 347 | 348 | 349 | async def get_zhubi_client(key='defalut'): 350 | if key in _zhubi_quote_pool: 351 | return _zhubi_quote_pool[key] 352 | else: 353 | c = ZhubiQuote(key) 354 | _zhubi_quote_pool[key] = c 355 | return c 356 | 357 | 358 | async def subscribe_zhubi(contract, on_update): 359 | c = await get_zhubi_client() 360 | return await c.subscribe_zhubi(contract, on_update) 361 | 362 | 363 | async def get_last_tick(contract): 364 | from . import autil 365 | sess = autil.get_aiohttp_session() 366 | res, err = await autil.http_go(sess.get, f'{Config.HOST_REST}/quote/single-tick/{contract}') 367 | if not err: 368 | res = Tick.from_dict(res) 369 | return res, err 370 | 371 | 372 | async def get_contracts(exchange): 373 | from . import autil 374 | sess = autil.get_aiohttp_session() 375 | res, err = await autil.http_go(sess.get, f'{Config.HOST_REST}/basic/contracts?exchange={exchange}') 376 | if not err: 377 | cons = [] 378 | for x in res: 379 | con = Contract.from_dict(x) 380 | cons.append(con) 381 | return cons, err 382 | return res, err 383 | 384 | 385 | async def get_contract(symbol): 386 | exchange, name = symbol.split('/') 387 | from . import autil 388 | sess = autil.get_aiohttp_session() 389 | res, err = await autil.http_go(sess.get, f'{Config.HOST_REST}/basic/contracts?exchange={exchange}&name={name}') 390 | if not err: 391 | if not res: 392 | return None, 'contract-not-exist' 393 | con = Contract.from_dict(res[0]) 394 | return con, err 395 | return res, err 396 | -------------------------------------------------------------------------------- /onetoken/quote_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | 4 | import arrow 5 | import pytest 6 | 7 | import onetoken 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_tick_quote(): 12 | q = await onetoken.quote.get_client() 13 | 14 | happen = False 15 | 16 | async def update(tk): 17 | nonlocal happen 18 | happen = True 19 | assert tk.contract == 'kucoin/eos.usdt' 20 | print('tick updated') 21 | 22 | await q.subscribe_tick('kucoin/eos.usdt', on_update=update) 23 | for _ in range(3): 24 | await asyncio.sleep(1) 25 | await q.close() 26 | assert happen 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_tick_v3_quote(): 31 | happen = False 32 | last = None 33 | 34 | async def update(tk: onetoken.Tick): 35 | nonlocal happen, last 36 | assert tk.contract == 'okef/btc.usd.q' 37 | if last == tk.time: 38 | print('same time', tk, hashlib.md5(str(tk.bids).encode()), hashlib.md5(str(tk.asks).encode())) 39 | last = tk.time 40 | if not happen: 41 | print('tick updated', arrow.get(tk.time).to('PRC').time(), 42 | arrow.get(tk.exchange_time).to('PRC').time(), tk.asks[0], 43 | tk.bids[0], len(tk.asks), len(tk.bids)) 44 | happen = True 45 | 46 | await onetoken.quote.subscribe_tick_v3('okef/btc.usd.q', on_update=update) 47 | for _ in range(3): 48 | await asyncio.sleep(1) 49 | assert happen 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_candle_quote(): 54 | happen = False 55 | 56 | async def update(candle): 57 | nonlocal happen 58 | happen = True 59 | assert candle.contract == 'okef/btc.usd.q' 60 | assert candle.duration == '1m' 61 | print('candle updated') 62 | 63 | await onetoken.quote.subscribe_candle('okef/btc.usd.q', '1m', on_update=update) 64 | await onetoken.quote.subscribe_candle('okef/btc.usd.q', '1m', on_update=update) 65 | for _ in range(3): 66 | await asyncio.sleep(1) 67 | assert happen 68 | 69 | 70 | if __name__ == "__main__": 71 | asyncio.ensure_future(test_tick_v3_quote()) 72 | # asyncio.ensure_future(test_candle_quote()) 73 | asyncio.get_event_loop().run_forever() 74 | -------------------------------------------------------------------------------- /onetoken/rpcutil.py: -------------------------------------------------------------------------------- 1 | class Const: 2 | SUCCESS = 'SUCCESS' 3 | EXCHNAGE_ERROR = 'EXCHANGE_ERROR' # 访问远程的服务器返回500 或者另外一些意想不到的错误 4 | EXCHANGE_TIMEOUT = 'EXCHANGE_TIMEOUT' # 访问远程服务器 timeout 5 | LOGIC_ERROR = 'LOGIC_ERROR' # 下单价格不对。 下单钱不够 订单号不存在等 6 | 7 | 8 | class ServiceError(Exception): 9 | def __init__(self, code, message=''): 10 | self.code = code 11 | self.message = message 12 | 13 | def __str__(self): 14 | return 'ServiceError<{},{}>'.format(self.code, self.message) 15 | 16 | 17 | class HTTPError(ServiceError): 18 | TIMEOUT = 'TIMEOUT' # timeout 19 | RESPONSE_5XX = 'RESPONSE_5XX' # 服务器返回5xx错误 20 | RESPONSE_4XX = 'RESPONSE_4XX' # 服务器返回4xx错误 21 | NOT_200 = 'NOT_200' # 服务器返回4xx错误 22 | NOT_JSON = 'NOT_JSON' # 不是 json 格式 23 | HTTP_ERROR = 'HTTP_ERROR' # client 出错 24 | 25 | def __init__(self, code, message=''): 26 | self.code = code 27 | self.message = message 28 | 29 | def __str__(self): 30 | return 'HTTPError<{},{}>'.format(self.code, self.message) 31 | 32 | 33 | class Code: 34 | CLIENT_OID = None 35 | EXCHANGE_OID = None 36 | CLIENT_CANCEL = None 37 | CONTRACT_NOT_EXIST = None 38 | NOT_200 = None 39 | NOT_JSON = None 40 | TIMEOUT = None 41 | WEBSOCKET_UNHEALTH = None 42 | ACCOUNT_WAITTING_CREATE = None 43 | ACCOUNT_NOT_EXIST = None 44 | SUCCESS = None 45 | WEBSOCKET_CONNECTING = None 46 | ACCOUNT_TOO_FREQUENT = None 47 | SOME_ERROR = None 48 | UNEXPECT_ERROR = None 49 | UNKNOW_METHOD = None 50 | NO_PERMISSION = None 51 | 52 | 53 | def set_code(): 54 | for k in Code.__dict__: 55 | import re 56 | if re.match('^[A-Z_]*$', k): 57 | setattr(Code, k, k) 58 | 59 | 60 | set_code() 61 | -------------------------------------------------------------------------------- /onetoken/sign_test.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from pathlib import Path 3 | 4 | from onetoken import account 5 | 6 | 7 | def test_sign_no_body(): 8 | r = account.gen_sign(secret='this-is-long-secret', verb='GET', url='/okex/demo/info', nonce='this-is-nonce', 9 | data_str=None) 10 | assert r == 'bf676b208d1b90e2763b0206f8426fc66583b07281a0368c97a9ee71e098e33e' 11 | 12 | 13 | def test_sign_with_body(): 14 | r = account.gen_sign(secret='this-is-long-secret', verb='POST', url='/okex/demo/info', nonce='this-is-nonce', 15 | data_str='{"price": 0.1, "amount": 0.2}') 16 | assert r == 'd75535f8f5e2d21dd5e5a0e8609ef56e3177d55f661dfc51b458b9d7ada711dc' 17 | 18 | 19 | def test_ws_sign(): 20 | r = Path('~/.onetoken/demo-vnpy.yml').expanduser() 21 | if not r.exists(): 22 | return 23 | r = r.read_text() 24 | r = yaml.load(r) 25 | 26 | r = account.gen_sign(secret=r['ot_secret'], verb='GET', url='/ws/mock-vnpy', nonce='1555471107536351', 27 | data_str=None) 28 | print(r) 29 | assert r == 'e5eadcb5d34e7d05465015ba35fd96b0424fdcfedd1fde2313cf9434d23c4c67' 30 | -------------------------------------------------------------------------------- /onetoken/util.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | import arrow 5 | 6 | 7 | def rand_id(length=10): 8 | assert length >= 1 9 | 10 | first = random.choice(string.ascii_lowercase + string.ascii_uppercase) 11 | after = ''.join(random.choice(string.ascii_lowercase + string.ascii_uppercase + string.digits) 12 | for _ in range(length - 1)) 13 | 14 | r = first + after 15 | return r 16 | 17 | 18 | def rand_digi(length=10): 19 | assert length >= 1 20 | r = ''.join(random.choice(string.digits) for _ in range(length)) 21 | return r 22 | 23 | 24 | def rand_client_oid(contract_symbol): 25 | """ 26 | binance/btc.usdt-20190816152332asdfqwer123450 27 | :param contract_symbol: 28 | :return: 29 | """ 30 | now = arrow.now().format('YYYYMMDDHHmmss') 31 | if contract_symbol.startswith('huobif') or contract_symbol.startswith('huobiuswap') : 32 | now = arrow.now().timestamp 33 | coid = f'{random.randint(now << 32, (now+1) << 32)}' 34 | elif contract_symbol.startswith('gate'): 35 | rand = rand_id(2) 36 | coid = f'{now}{rand}' 37 | else: 38 | rand = rand_id(14) 39 | coid = f'{now}{rand}' 40 | oid = f'{contract_symbol}-{coid}' 41 | return oid 42 | 43 | 44 | def rand_client_wid(exchange, currency): 45 | """ 46 | binance/xxx-yearmonthday-hourminuteseconds-random 47 | :param exchange: 48 | :param currency: 49 | :return: 50 | """ 51 | now = arrow.now().format('YYYYMMDD-HHmmss') 52 | rand = rand_id(5) 53 | cwid = f'{exchange}/{currency}-{now}-{rand}' 54 | return cwid 55 | -------------------------------------------------------------------------------- /onetoken/util_test.py: -------------------------------------------------------------------------------- 1 | def test_id(): 2 | from . import util 3 | c = util.rand_client_oid('xxx') 4 | print(c) 5 | assert len(c) == 4 + 28 6 | -------------------------------------------------------------------------------- /onetoken/websocket_trade_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import pytest 5 | import yaml 6 | from pathlib import Path 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_trade_subscribe_success(): 11 | import onetoken as ot 12 | ot.log.info('hello') 13 | r = Path('~/.onetoken/demo-vnpy.yml').expanduser().read_text() 14 | r = yaml.load(r) 15 | 16 | o = ot.Account('okex/mock-vnpy', api_key=r['ot_key'], api_secret=r['ot_secret']) 17 | 18 | async def h(*args, **kwargs): 19 | print(args, kwargs) 20 | 21 | await o.subscribe_info(h) 22 | await asyncio.sleep(5) 23 | o.close() 24 | await asyncio.sleep(2) 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_trade_subscribe_fail(): 29 | import onetoken as ot 30 | ot.log.info('hello') 31 | r = Path('~/.onetoken/demo-vnpy.yml').expanduser().read_text() 32 | r = yaml.load(r) 33 | 34 | o = ot.Account('okex/mock-vnpy', api_key=r['ot_key'], api_secret=r['ot_secret'] + 'fail') 35 | 36 | async def h(*args, **kwargs): 37 | print(args, kwargs) 38 | 39 | await o.subscribe_info(h) 40 | await asyncio.sleep(3) 41 | o.close() 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_trade_noheader(): 46 | import onetoken as ot 47 | ot.log.info('hello') 48 | r = Path('~/.onetoken/demo-vnpy.yml').expanduser().read_text() 49 | r = yaml.load(r) 50 | 51 | o = ot.Account('okex/mock-vnpy', api_key=r['ot_key'], api_secret=r['ot_secret'] + 'fail') 52 | 53 | url = o.ws_path 54 | ws = await o.session.ws_connect(url, autoping=False, headers=None, timeout=30) 55 | 56 | async def h(): 57 | while True: 58 | msg = await ws.receive() 59 | if msg.type == aiohttp.WSMsgType.CLOSED: 60 | return 61 | print(msg) 62 | 63 | asyncio.ensure_future(h()) 64 | await asyncio.sleep(3) 65 | await ws.close() 66 | o.close() 67 | await asyncio.sleep(0.1) 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup, find_packages 3 | from codecs import open 4 | 5 | 6 | with open('onetoken/__init__.py', 'r', encoding='utf8') as fd: 7 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE).group(1) 8 | print('regex find version', version) 9 | if not version: 10 | raise RuntimeError('Cannot find version information') 11 | 12 | setup(name='onetoken', 13 | author='OneToken', 14 | url='https://github.com/1token-trade/onetoken-py-sdk', 15 | author_email='admin@1token.trade', 16 | packages=find_packages(), 17 | version=version, 18 | python_requires=">=3.6", 19 | description='OneToken Trade System Python SDK', 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Intended Audience :: Developers', 23 | 'Intended Audience :: Financial and Insurance Industry', 24 | 'Intended Audience :: Information Technology', 25 | 'Topic :: Software Development :: Build Tools', 26 | 'Topic :: Office/Business :: Financial :: Investment', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Operating System :: OS Independent', 32 | 'Environment :: Console' 33 | ], 34 | install_requires=[ 35 | 'arrow>=0.12', 36 | 'docopt>=0.6', 37 | 'PyJWT>=1.6', 38 | 'PyYAML>=3', 39 | 'aiohttp>=3.1', 40 | ], 41 | zip_safe=False, 42 | ) 43 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import invoke 4 | 5 | 6 | @invoke.task 7 | def upload(ctx): 8 | ctx.run('python setup.py sdist bdist_wheel upload') 9 | ctx.run('rm -rf build dist onetoken_trade.egg-info') 10 | ctx.run('git add . ') 11 | ctx.run('git commit -a -m "update version" ') 12 | ctx.run('git push') 13 | 14 | 15 | @invoke.task 16 | def clean(ctx): 17 | ctx.run('rm -rf build dist onetoken_trade.egg-info') 18 | 19 | 20 | @invoke.task 21 | def build(ctx): 22 | ctx.run('pip install pip -U', warn=True) 23 | ctx.run('python setup.py sdist bdist_wheel && cd dist && pip uninstall onetoken -y') 24 | for item in os.listdir('dist'): 25 | if item.endswith('.whl'): 26 | cmd = f'cd dist && pip install {item}' 27 | print(cmd) 28 | ctx.run(cmd) 29 | --------------------------------------------------------------------------------