├── .gitignore ├── setup.py ├── README.md ├── LICENSE └── crypto_facilities ├── test.py └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.key 2 | strategy.txt 3 | .cache/ 4 | __pycache__/ 5 | build/ 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup(name='crypto-facilities', 6 | version='0.1', 7 | description='Client for Crypto Facilities REST API', 8 | author='Max Bolingbroke', 9 | author_email='batterseapower@hotmail.com', 10 | url='https://github.com/batterseapower/crypto-facilities', 11 | packages=['crypto_facilities'], 12 | ) 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crypto-facilities 2 | 3 | Crypto Facilities is a cryptcurrency derivatives exchange. This package is a Python client for the REST API of 4 | the exchange. This is similar to the official client (https://github.com/CryptoFacilities/REST-v2-Python) except 5 | that it implements V3 of the API rather than V2. 6 | 7 | Disclaimer: I haven't used this too seriously yet "in production", but it does have 8 | extensive tests. 9 | 10 | ## Basic usage 11 | 12 | ```python 13 | from crypto_facilities import APIKey, get_instruments, get_positions 14 | 15 | # Unauthenticated access: 16 | print(get_instruments()) 17 | 18 | # Authenticated methods require a key argument. You can generate one at 19 | # https://www.cryptofacilities.com/derivatives/account#apiTab 20 | key = APIKey('public', 'private') 21 | print(get_positions(key)) 22 | ``` 23 | 24 | ## Rate limits 25 | 26 | API calls are limited to 1 call every 0.1 seconds per IP address. If this is exceeded 27 | you will start getting exceptions from the API, so if you are going to use the API 28 | heavily you might to implement your own rate limiting to keep below this threshold. 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Maximilian Bolingbroke 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /crypto_facilities/test.py: -------------------------------------------------------------------------------- 1 | from hamcrest import * 2 | from datetime import datetime, timedelta 3 | from numbers import Number 4 | import contextlib 5 | import functools 6 | 7 | import crypto_facilities 8 | 9 | with open('read_write.key', 'r') as f: 10 | public, private = [x.strip() for x in f] 11 | key = crypto_facilities.APIKey(public, private) 12 | 13 | def test_get_instruments(): 14 | instruments = crypto_facilities.get_instruments() 15 | assert len(instruments) > 3 16 | 17 | i = [i for i in instruments if i['symbol'].startswith('fi_xbtusd_')][0] 18 | assert_that(i, has_entries({ 19 | 'contractSize': instance_of(int), 20 | 'tradeable': instance_of(bool), 21 | 'lastTradingTime': instance_of(datetime), 22 | 'type': instance_of(str), 23 | 'tickSize': instance_of(Number), 24 | 'underlying': instance_of(str), 25 | })) 26 | 27 | def test_get_tickers(): 28 | tickers = crypto_facilities.get_tickers() 29 | assert len(tickers) > 3 30 | 31 | t = [t for t in tickers if t['symbol'].startswith('fi_xbtusd_')][0] 32 | assert_that(t, has_entries({ 33 | 'symbol': instance_of(str), 34 | 'suspended': instance_of(bool), 35 | 36 | 'last': instance_of(Number), 37 | 'lastTime': instance_of(datetime), 38 | 'lastSize': instance_of(int), 39 | 40 | 'open24h': instance_of(Number), 41 | #'high24h': instance_of(Number), 42 | #'low24h': instance_of(Number), 43 | 'vol24h': instance_of(int), 44 | 45 | 'bid': instance_of(Number), 46 | 'bidSize': instance_of(int), 47 | 48 | 'ask': instance_of(Number), 49 | 'askSize': instance_of(int), 50 | 51 | 'markPrice': instance_of(Number), 52 | })) 53 | 54 | ZERO_PRICE = 0 55 | EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE = 0.0001 56 | EXAMPLE_SYMBOL_IMPOSSIBLY_HIGH_PRICE = 1e6 57 | @functools.lru_cache() 58 | def get_example_symbol(): 59 | tickers = crypto_facilities.get_tickers() 60 | t = [t for t in tickers if t['symbol'].startswith('fi_xrpusd_') and not t['suspended']][0] 61 | 62 | for c in ('last', 'open24h', 'high24h', 'low24h', 'bid', 'ask', 'markPrice'): 63 | if c in t: 64 | assert EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE * 10 < t[c] < EXAMPLE_SYMBOL_IMPOSSIBLY_HIGH_PRICE / 10 65 | 66 | return t['symbol'] 67 | 68 | def test_get_order_book(): 69 | ob = crypto_facilities.get_order_book(get_example_symbol()) 70 | 71 | bids = ob.bids 72 | assert len(bids) > 2 73 | 74 | for bid in bids: 75 | assert len(bid) == 2 76 | price, size = bid 77 | 78 | assert_that(price, instance_of(Number)) 79 | assert_that(size, instance_of(int)) 80 | 81 | prices = [price for price, _size in bids] 82 | assert sorted(prices, reverse=True) == prices 83 | 84 | def test_get_trade_history(): 85 | ts = crypto_facilities.get_trade_history(get_example_symbol()) 86 | 87 | for t in ts: 88 | assert_that(t, instance_of(crypto_facilities.Trade)) 89 | assert_that(t.time, instance_of(datetime)) 90 | assert_that(t.trade_id, instance_of(int)) 91 | assert_that(t.price, instance_of(Number)) 92 | assert_that(t.size, instance_of(int)) 93 | 94 | times = [t.time for t in ts] 95 | assert sorted(times, reverse=True) == times 96 | 97 | if ts: 98 | earlier = times[0] - timedelta(seconds=1) 99 | ts_earlier = crypto_facilities.get_trade_history(get_example_symbol(), last_time=earlier) 100 | 101 | if not ts_earlier: 102 | assert len(ts) == 1 103 | else: 104 | assert 0 < len(ts_earlier) <= len(ts) 105 | assert ts_earlier[0].time <= earlier 106 | 107 | def test_get_accounts(): 108 | accts = crypto_facilities.get_accounts(key) 109 | 110 | assert 'cash' in accts 111 | assert_that(accts['cash'], has_entries({ 112 | 'type': instance_of(str), 113 | 'balances': has_entries({ 114 | 'xbt': instance_of(Number), 115 | 'xrp': instance_of(Number), 116 | }), 117 | })) 118 | 119 | # e.g. fi_xbtusd 120 | fi_account = [acct for acct in accts if acct.startswith('fi_')][0] 121 | assert_that(accts[fi_account], has_entries({ 122 | 'type': instance_of(str), 123 | 'currency': instance_of(str), 124 | 'balances': has_entries({ 125 | 'xbt': instance_of(Number), 126 | 'xrp': instance_of(Number), 127 | }), 128 | # All in units of 'currency': 129 | 'auxiliary': has_entries({ 130 | 'af': instance_of(Number), # Available Funds 131 | 'pnl': instance_of(Number), # P&L of open positions 132 | 'pv': instance_of(Number), # Portfolio value 133 | }), 134 | 'marginRequirements': has_entries({ 135 | 'im': instance_of(Number), # Initial Margin 136 | 'mm': instance_of(Number), # Maintenance Margin 137 | 'lt': instance_of(Number), # Liquidation Threshold 138 | 'tt': instance_of(Number), # Termination Threshold 139 | }), 140 | # Approximate underlying spot prices that will cause us to reach margin thresh: 141 | 'triggerEstimates': has_entries({ 142 | 'im': instance_of(Number), 143 | 'mm': instance_of(Number), 144 | 'lt': instance_of(Number), 145 | 'tt': instance_of(Number), 146 | }), 147 | })) 148 | 149 | @contextlib.contextmanager 150 | def ensure_cancelled(order_id): 151 | assert order_id is not None 152 | 153 | try: 154 | yield 155 | finally: 156 | cancel_status = crypto_facilities.cancel_order(key, order_id) 157 | assert cancel_status.status in {'cancelled', 'notFound'} 158 | 159 | def assert_can_place_order(spec): 160 | size = 1 161 | status = crypto_facilities.send_order(key, spec, size) 162 | 163 | with ensure_cancelled(status.order_id): 164 | assert isinstance(status, crypto_facilities.OrderStatus) 165 | assert status.status == 'placed' 166 | 167 | def test_send_limit_order(): 168 | spec = crypto_facilities.LimitOrderSpec(get_example_symbol(), 'buy', EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE) 169 | assert_can_place_order(spec) 170 | 171 | def test_send_stop_order(): 172 | spec = crypto_facilities.StopOrderSpec(get_example_symbol(), 'buy', EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE * 2, EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE) 173 | assert_can_place_order(spec) 174 | 175 | def test_can_send_bogus_order(): 176 | status = crypto_facilities.send_limit_order(key, get_example_symbol(), 'buy', ZERO_PRICE, 1) 177 | assert status == crypto_facilities.OrderStatus(received_time=None, status='invalidPrice', order_id=None) 178 | 179 | def test_can_batch_modify_orders(): 180 | spec0 = crypto_facilities.LimitOrderSpec(get_example_symbol(), 'buy', EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE) 181 | spec1 = crypto_facilities.LimitOrderSpec(get_example_symbol(), 'sell', EXAMPLE_SYMBOL_IMPOSSIBLY_HIGH_PRICE) 182 | 183 | size = 1 184 | status0 = crypto_facilities.send_order(key, spec0, size) 185 | with ensure_cancelled(status0.order_id): 186 | statuses = crypto_facilities.send_or_cancel_orders(key, [ 187 | status0.order_id, 188 | (spec1, size) 189 | ]) 190 | try: 191 | assert len(statuses) == 2 192 | assert statuses[0].status == 'cancelled' 193 | assert statuses[1].status == 'placed' 194 | finally: 195 | cancel_status = [crypto_facilities.cancel_order(key, status.order_id).status for status in statuses if status.order_id is not None] 196 | assert_that(cancel_status, only_contains(is_in({'cancelled', 'notFound'}))) 197 | 198 | def test_get_open_orders(): 199 | spec = crypto_facilities.LimitOrderSpec(get_example_symbol(), 'buy', EXAMPLE_SYMBOL_IMPOSSIBLY_LOW_PRICE) 200 | size = 1 201 | status = crypto_facilities.send_order(key, spec, size) 202 | 203 | with ensure_cancelled(status.order_id): 204 | open_orders = crypto_facilities.get_open_orders(key) 205 | assert len(open_orders) == 1 206 | 207 | oo, = open_orders 208 | assert_that(oo, instance_of(crypto_facilities.OpenOrder)) 209 | assert oo.spec == spec 210 | assert oo.status.status == 'untouched' 211 | assert oo.filled_size == 0 212 | assert oo.unfilled_size == 1 213 | 214 | def test_get_fill_history(): 215 | fills = crypto_facilities.get_fill_history(key) 216 | for fill in fills: 217 | assert_that(fill, has_entries({ 218 | 'fillTime': instance_of(datetime), 219 | 'order_id': instance_of(str), 220 | 'fill_id': instance_of(str), 221 | 'symbol': instance_of(str), 222 | 'side': is_in({'sell', 'buy'}), 223 | 'size': instance_of(int), 224 | 'price': instance_of(Number), 225 | })) 226 | 227 | times = [fill['fillTime'] for fill in fills] 228 | assert sorted(times, reverse=True) == times 229 | 230 | if fills: 231 | earlier = times[0] - timedelta(seconds=1) 232 | earlier_fills = crypto_facilities.get_fill_history(key, last_time=earlier) 233 | 234 | if not earlier_fills: 235 | assert len(fills) == 1 236 | else: 237 | assert 0 < len(earlier_fills) <= len(fills) 238 | assert earlier_fills[0]['fillTime'] <= earlier 239 | 240 | def test_get_positions(): 241 | positions = crypto_facilities.get_positions(key) 242 | for pos in positions: 243 | assert_that(pos, has_entries({ 244 | 'fillTime': instance_of(datetime), 245 | 'symbol': instance_of(str), 246 | 'side': is_in({'sell', 'buy'}), 247 | 'size': instance_of(int), 248 | 'price': instance_of(Number), 249 | })) 250 | 251 | def test_withdraw(): 252 | # XXX: any way to test this without actually making a transaction..? 253 | # Crypto Facilities don't seem to operate a testnet or anything 254 | assert True 255 | 256 | def test_get_transfer_history(): 257 | history = crypto_facilities.get_transfer_history(key) 258 | for h in history: 259 | assert_that(h, instance_of(crypto_facilities.Transfer)) 260 | 261 | assert_that(h.money, instance_of(crypto_facilities.Money)) 262 | assert_that(h.money.currency, instance_of(str)) 263 | assert_that(h.money.amount, instance_of(Number)) 264 | assert_that(h.money.amount, greater_than(0)) 265 | 266 | assert_that(h.target_address, any_of(instance_of(str), none())) 267 | assert_that(h.status, instance_of(crypto_facilities.TransferStatus)) 268 | assert_that(h.status.received_time, instance_of(datetime)) 269 | assert_that(h.status.status, instance_of(str)) 270 | assert_that(h.status.transfer_id, instance_of(str)) 271 | 272 | assert_that(h.completed_time, instance_of(datetime)) 273 | assert_that(h.transaction_id, instance_of(str)) 274 | 275 | times = [h.status.received_time for h in history] 276 | assert sorted(times, reverse=True) == times 277 | 278 | if history: 279 | earlier = times[0] - timedelta(seconds=1) 280 | earlier_history = crypto_facilities.get_transfer_history(key, last_time=earlier) 281 | 282 | if not earlier_history: 283 | assert len(history) == 1 284 | else: 285 | assert 0 < len(earlier_history) <= len(history) 286 | assert earlier_history[0].time <= earlier 287 | -------------------------------------------------------------------------------- /crypto_facilities/__init__.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import datetime 3 | import time 4 | import requests 5 | import threading 6 | import base64 7 | import hashlib 8 | import hmac 9 | import json 10 | import pytz 11 | from typing import List, Tuple, Union 12 | 13 | # API calls are limited to 1 call every 0.1 seconds per IP address. If the API limit is 14 | # exceeded, the API will return error equal to apiLimitExeeded. 15 | 16 | APIKey = collections.namedtuple('APIKey', 'public private') 17 | BASE_URL = 'https://www.cryptofacilities.com/derivatives' 18 | API_VERSION = '/api/v3/' 19 | 20 | def parse_time(s: str) -> datetime.datetime: 21 | # e.g. 2016-02-25T09:45:53.818Z 22 | t = datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ') 23 | return pytz.UTC.localize(t) 24 | 25 | def format_time(t: datetime.datetime) -> str: 26 | t = t.astimezone(pytz.UTC) if t.tzinfo else t 27 | millisecond = t.microsecond // 1000 28 | return t.strftime('%Y-%m-%dT%H:%M:%S.') + '{0:3}'.format(millisecond) + 'Z' 29 | 30 | last_nonce_lock = threading.Lock() 31 | last_nonce = None 32 | def make_request(path, data=[], method='GET', key=None): 33 | global last_nonce 34 | 35 | if key is None: 36 | headers = {} 37 | else: 38 | post_data = '&'.join(k + '=' + v for k, v in data) 39 | 40 | # The only requirement on the nonce is that it continuously 41 | # increments. However, in order to try to support multiple processes 42 | # concurrently using this library, we base the nonce on some 43 | # shared state -- the current time. CryptoFacilities's system 44 | # "tolerates nonces that are out of order for a brief period of time" 45 | # so it doesn't matter if there is some slight mismatch between 46 | # the processes. 47 | with last_nonce_lock: 48 | proposed_nonce = int(time.time() * 1000000) 49 | if last_nonce is not None and last_nonce >= proposed_nonce: 50 | proposed_nonce = last_nonce + 1 51 | last_nonce = proposed_nonce 52 | 53 | nonce = str(proposed_nonce) 54 | headers = { 55 | 'APIKey': key.public, 56 | 'Nonce': nonce, 57 | 'Authent': get_auth_ent(post_data, nonce, API_VERSION + path, key.private), 58 | } 59 | 60 | url = BASE_URL + API_VERSION + path 61 | 62 | if method == 'GET': 63 | r = requests.get(url, headers=headers, params=collections.OrderedDict(data)) 64 | else: 65 | r = requests.post(url, headers=headers, data=collections.OrderedDict(data)) 66 | 67 | r.raise_for_status() 68 | 69 | r = r.json() 70 | result = r.pop('result') 71 | if result == 'success': 72 | return r 73 | else: 74 | assert result == 'error' 75 | raise ValueError(r.get('error', 'unspecifiedError')) 76 | 77 | 78 | def get_auth_ent(post_data, nonce, endpoint, private_key): 79 | message = post_data + nonce + endpoint 80 | 81 | sha256_hash = hashlib.sha256() 82 | sha256_hash.update(message.encode('utf8')) 83 | hash_digest = sha256_hash.digest() 84 | 85 | secret = base64.b64decode(private_key) 86 | 87 | hmac_digest = hmac.new(secret, hash_digest, hashlib.sha512).digest() 88 | return base64.b64encode(hmac_digest) 89 | 90 | def parse_time_fields(fields, xs): 91 | result = [] 92 | for x in xs: 93 | x = x.copy() 94 | result.append(x) 95 | 96 | for field in fields: 97 | if field in x: 98 | x[field] = parse_time(x[field]) 99 | 100 | return result 101 | 102 | # { 103 | # "symbol": "fi_xbtusd_180615", 104 | # "type”: “futures_inverse”, 105 | # “tradeable”: “true”, 106 | # “underlying”: “rr_xbtusd”, 107 | # “lastTradingTime”: “2018-06-15T16:00:00.000Z", 108 | # "tickSize": 1, 109 | # "contractSize”: 1, 110 | # }, 111 | def get_instruments(): 112 | result = make_request('instruments')['instruments'] 113 | return parse_time_fields(['lastTradingTime'], result) 114 | 115 | # { 116 | # "symbol": "fi_xbtusd_180615", 117 | # "suspended": false, 118 | # "last": 4232, 119 | # "lastTime": "2016-02-25T10:56:10.364Z", 120 | # "lastSize": 5000, 121 | # "open24h": 4418, 122 | # "high24h": 4265, 123 | # "low24h": 4169, 124 | # "vol24h": 112000, 125 | # "bid": 4232, 126 | # "bidSize": 5000, 127 | # "ask": 4236, 128 | # "askSize": 5000, 129 | # "markPrice": 4227, 130 | # }, 131 | def get_tickers(): 132 | result = make_request('tickers')['tickers'] 133 | return parse_time_fields(['lastTime'], result) 134 | 135 | OrderBook = collections.namedtuple('OrderBook', 'bids asks') 136 | 137 | # { 138 | # “bids”: [ 139 | # [4213, 2000], 140 | # [4210, 4000], 141 | # ..., 142 | # ], 143 | # “asks”: [ 144 | # [4218, 4000], 145 | # [4220, 5000], 146 | # ..., 147 | # ], 148 | # }, 149 | # 150 | # Arrays are [price, size]. Bids have descending price, asks have ascending price. 151 | def get_order_book(symbol: str) -> OrderBook: 152 | result = make_request('orderbook', data=[('symbol', symbol)])['orderBook'] 153 | return OrderBook( 154 | bids=result['bids'], 155 | asks=result['asks'], 156 | ) 157 | 158 | Trade = collections.namedtuple('Trade', 'time trade_id price size') 159 | 160 | # [ 161 | # { 162 | # “time”: “2016-02-23T10:10:01.000Z”, 163 | # “trade_id”: 865, 164 | # “price”: 4322, 165 | # “size”: 5000, 166 | # }, 167 | # { 168 | # “time”: “2016-02-23T10:05:12.000Z”, 169 | # “trade_id”: 864, 170 | # “price”: 4324, 171 | # “size”: 2000, 172 | # }, 173 | # ..., 174 | # ], 175 | # 176 | # Always returns <= 100 entries 177 | def get_trade_history(symbol: str, last_time: datetime.datetime = None): 178 | data = [('symbol', symbol)] 179 | if last_time is not None: 180 | data.append(('lastTime', format_time(last_time))) 181 | 182 | result = [] 183 | for struct in make_request('history', data=data)['history']: 184 | result.append(Trade( 185 | time=parse_time(struct['time']), 186 | trade_id=struct['trade_id'], 187 | price=struct['price'], 188 | size=struct['size'] 189 | )) 190 | 191 | return result 192 | 193 | # { 194 | # “cash”: { 195 | # “type”: “cashAccount”, 196 | # “balances”: { 197 | # “xbt”: 141.31756797, 198 | # “xrp”: 52465.1254, 199 | # }, 200 | # }, 201 | # “fi_xbtusd”: { 202 | # “type”: “marginAccount”, 203 | # “currency”: “xbt”, 204 | # “balances”: { 205 | # “fi_xbtusd_171215”: 50000, 206 | # “fi_xbtusd_180615”: -15000, 207 | # ..., 208 | # “xbt”: 141.31756797, 209 | # “xrp”: 0, 210 | # }, 211 | # “auxiliary”: { 212 | # “af”: 100.73891563, 213 | # “pnl”: 12.42134766, 214 | # “pv”: 153.73891563, 215 | # }, 216 | # “marginRequirements”:{ 217 | # “im”: 52.8, 218 | # “mm”: 23.76, 219 | # “lt”: 39.6, 220 | # “tt”: 15.84, 221 | # }, 222 | # “triggerEstimates”:{ 223 | # “im”: 3110, 224 | # “mm”: 3000, 225 | # “lt”: 2890, 226 | # “tt”: 2830, 227 | # }, 228 | # }, 229 | # ... 230 | # }, 231 | def get_accounts(key: APIKey): 232 | return make_request('accounts', key=key)['accounts'] 233 | 234 | LimitOrderSpec = collections.namedtuple('LimitOrderSpec', 'symbol side price') 235 | StopOrderSpec = collections.namedtuple('StopOrderSpec', 'symbol side limit_price stop_price') 236 | OrderSpec = Union[LimitOrderSpec, StopOrderSpec] 237 | 238 | def _get_order_entry_data(order: OrderSpec, size: int): 239 | data = [('symbol', order.symbol), ('side', order.side), ('size', str(size))] 240 | if isinstance(order, LimitOrderSpec): 241 | data = [('orderType', 'lmt')] + data + [('limitPrice', str(order.price))] 242 | elif isinstance(order, StopOrderSpec): 243 | data = [('orderType', 'stp')] + data + [('limitPrice', str(order.limit_price)), ('stopPrice', str(order.stop_price))] 244 | else: 245 | raise ValueError(str(type(order))) 246 | 247 | return data 248 | 249 | def _get_order_spec(struct: dict) -> OrderSpec: 250 | symbol = struct['symbol'] 251 | 252 | side = struct['side'] 253 | assert side in ('buy', 'sell') 254 | 255 | limit_price = float(struct['limitPrice']) 256 | 257 | typ = struct['orderType'] 258 | if typ == 'lmt': 259 | assert struct.get('stopPrice') is None 260 | return LimitOrderSpec(symbol, side, limit_price) 261 | elif typ == 'stp': 262 | stop_price = float(struct['stopPrice']) 263 | return StopOrderSpec(symbol, side, limit_price, stop_price) 264 | else: 265 | raise ValueError('Unknown order type ' + typ) 266 | 267 | OrderStatus = collections.namedtuple('OrderStatus', 'received_time status order_id') 268 | 269 | def _get_order_status(struct: dict, order_id: str = None) -> OrderStatus: 270 | if order_id is None: 271 | # Can be missing if placing the order failed 272 | order_id = struct.get('order_id') 273 | else: 274 | assert 'order_id' not in struct or struct['order_id'] == order_id 275 | 276 | return OrderStatus( 277 | # Time can be missing if e.g. the order fully filled immediately 278 | received_time=None if 'receivedTime' not in struct else parse_time(struct['receivedTime']), 279 | status=struct['status'], 280 | order_id=order_id 281 | ) 282 | 283 | def send_order(key: APIKey, order: OrderSpec, size: int) -> OrderStatus: 284 | data = _get_order_entry_data(order, size) 285 | result = make_request('sendorder', data=data, method='POST', key=key)['sendStatus'] 286 | return _get_order_status(result) 287 | 288 | # { 289 | # “receivedTime”: “2016-02-25T09:45:53.601Z”, 290 | # “status”: “placed”, 291 | # “order_id”: “c18f0c17-9971-40e6-8e5b-10df05d422f0”, 292 | # } 293 | def send_limit_order(key: APIKey, symbol: str, side: Union['buy', 'sell'], price: float, size: int) -> OrderStatus: 294 | return send_order(key, LimitOrderSpec(symbol, side, price), size) 295 | 296 | def send_stop_order(key: APIKey, symbol: str, side: Union['buy', 'sell'], limit_price: float, stop_price: float, size: int) -> OrderStatus: 297 | return send_order(key, StopOrderSpec(symbol, side, limit_price, stop_price), size, key=key) 298 | 299 | # { 300 | # “receivedTime”: “2016-02-25T09:45:53.601Z”, 301 | # “status”: “cancelled”, 302 | # } 303 | def cancel_order(key: APIKey, order_id: str) -> OrderStatus: 304 | result = make_request('cancelorder', data=[('order_id', order_id)], method='POST', key=key)['cancelStatus'] 305 | return _get_order_status(result, order_id=order_id) 306 | 307 | # Strings supplied here will be interpreted as requests to cancellation the corresponding 308 | # order ID. Orders will be intepreted as requests to place that order. 309 | def send_or_cancel_orders(key: APIKey, instructions: List[Union[str, Tuple[OrderSpec, int]]]) -> List[OrderStatus]: 310 | instruction_structs = [] 311 | order_id_to_ixs = {} 312 | for i, instruction in enumerate(instructions): 313 | if isinstance(instruction, str): 314 | instruction_struct = { 315 | 'order': 'cancel', 316 | 'order_id': instruction 317 | } 318 | order_id_to_ixs.setdefault(instruction, []).append(i) 319 | else: 320 | spec, size = instruction 321 | instruction_struct = dict(_get_order_entry_data(spec, size)) 322 | instruction_struct['order'] = 'send' 323 | instruction_struct['order_tag'] = str(i) 324 | instruction_structs.append(instruction_struct) 325 | 326 | result = make_request('batchorder', data=[('json', json.dumps({'batchOrder': instruction_structs}))], method='POST', key=key)['batchStatus'] 327 | 328 | statuses = [None] * len(instruction_structs) 329 | for result_struct in result: 330 | if 'order_tag' in result_struct: 331 | ixs = [int(result_struct['order_tag'])] 332 | status = _get_order_status(result_struct) 333 | assert not any(isinstance(instructions[i], str) for i in ixs) 334 | else: 335 | order_id = result_struct['order_id'] 336 | ixs = order_id_to_ixs[order_id] 337 | status = _get_order_status(result_struct, order_id=order_id) 338 | 339 | for i in ixs: 340 | assert statuses[i] is None 341 | statuses[i] = status 342 | 343 | assert [x for x in statuses if x is None] == [] 344 | return statuses 345 | 346 | OpenOrder = collections.namedtuple('OpenOrder', 'spec status filled_size unfilled_size') 347 | 348 | def get_open_orders(key: APIKey) -> List[OpenOrder]: 349 | orders = [] 350 | for record in make_request('openorders', key=key)['openOrders']: 351 | spec = _get_order_spec(record) 352 | status = _get_order_status(record) 353 | unfilled_size = int(record['unfilledSize']) 354 | filled_size = int(record['filledSize']) 355 | 356 | orders.append(OpenOrder(spec, status, filled_size, unfilled_size)) 357 | 358 | return orders 359 | 360 | # { 361 | # “result”: “success”, 362 | # “serverTime”: “2016-02-25T09:45:53.818Z”, 363 | # “fills”: [ 364 | # { 365 | # “fillTime”: “2016-02-25T09:47:01.000Z”, 366 | # “order_id”: “c18f0c17-9971-40e6-8e5b-10df05d422f0”, 367 | # “fill_id”: “522d4e08-96e7-4b44-9694-bfaea8fe215e”, 368 | # “symbol”: “fi_xbtusd_180615”, 369 | # “side”: ”buy”, 370 | # “size”: 2000, 371 | # “price”: 4255, 372 | # }, 373 | # ... 374 | # } 375 | # } 376 | # 377 | # Always returns <= 100 entries 378 | def get_fill_history(key: APIKey, last_time: datetime.datetime = None): 379 | data = [] 380 | if last_time is not None: 381 | data.append(('lastFillTime', format_time(last_time))) 382 | 383 | result = make_request('fills', data=data, key=key)['fills'] 384 | return parse_time_fields(['fillTime'], result) # FIXME: structured type (LimitOrderSpec, size, order_id, fill_time, fill_id) 385 | 386 | # [ 387 | # { 388 | # “fillTime”: “2016-02-25T09:47:01.000Z”, 389 | # “symbol”: “fi_xbtusd_180615”, 390 | # “side”: ”long”, 391 | # “size”: 1000, 392 | # “price”: 4255, 393 | # }, 394 | # { 395 | # “fillTime”: “2016-02-25T09:47:01.000Z”, 396 | # “symbol”: “fi_xbtusd_180615”, 397 | # “side”: ”buy”, 398 | # “size”: 1000, 399 | # “price”: 4255, 400 | # }, 401 | # ..., 402 | # ] 403 | def get_positions(key: APIKey): 404 | result = make_request('openpositions', key=key)['openPositions'] 405 | return parse_time_fields(['fillTime'], result) # FIXME: structured type 406 | 407 | Money = collections.namedtuple('Money', 'currency amount') 408 | TransferStatus = collections.namedtuple('TransferStatus', 'received_time status transfer_id') 409 | 410 | def _get_transfer_data(money: Money, target_address: str): 411 | return [ 412 | ('targetAddress', target_address), 413 | ('currency', money.currency), 414 | ('amount', money.amount) 415 | ] 416 | 417 | def _get_money(struct: dict) -> Money: 418 | return Money( 419 | currency=struct['currency'], 420 | amount=struct['amount'], 421 | ) 422 | 423 | def _get_transfer_status(struct: dict) -> TransferStatus: 424 | return TransferStatus( 425 | received_time=parse_time(struct['receivedTime']), 426 | status=struct['status'], 427 | transfer_id=struct.get('transfer_id'), 428 | ) 429 | 430 | # { 431 | # “receivedTime”: “2016-02-25T09:47:01.000Z”, 432 | # “status”: “accepted”, 433 | # “transfer_id”: “b243cf7a-657d-488e-ab1c-cfb0f95362ba”, 434 | # } 435 | def withdraw(key: APIKey, money: Money, target_address: str) -> TransferStatus: 436 | data = _get_transfer_data(money, target_address) 437 | result = make_request('withdrawal', data=data, method='POST', key=key) 438 | return _get_transfer_status(result) 439 | 440 | Transfer = collections.namedtuple('Transfer', 'money status target_address completed_time transaction_id') 441 | 442 | # [ 443 | # { 444 | # “receivedTime”: “2016-01-28T07:09:42.000Z”, 445 | # “completedTime”: “2016-01-28T08:26:46.000Z”, 446 | # “status”: “processed”, 447 | # “transfer_id”: “b243cf7a-657d-488e-ab1c-cfb0f95362ba”, 448 | # “transaction_id”: “4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b”, 449 | # “targetAddress”: “1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa”, 450 | # “transferType”: “deposit” 451 | # “currency”: “xbt”, 452 | # “amount”: 2.58, 453 | # }, 454 | # ... 455 | # ] 456 | def get_transfer_history(key: APIKey, last_time: datetime.datetime = None) -> List[Transfer]: 457 | data = [] 458 | if last_time is not None: 459 | data.append(('lastTransferTime', format_time(last_time))) 460 | 461 | transfers = [] 462 | for record in make_request('transfers', data=data, key=key)['transfers']: 463 | if record['transferType'] == 'deposit': 464 | assert 'targetAddress' not in record 465 | target_address = None 466 | elif record['transferType'] == 'withdrawal': 467 | target_address = record['targetAddress'] 468 | else: 469 | raise ValueError('Unknown type ' + record['transferType']) 470 | 471 | transfers.append(Transfer( 472 | money=_get_money(record), 473 | status=_get_transfer_status(record), 474 | target_address=target_address, 475 | completed_time=parse_time(record['completedTime']), 476 | transaction_id=record['transaction_id'], 477 | )) 478 | 479 | return transfers 480 | --------------------------------------------------------------------------------