├── MANIFEST.in ├── setup.cfg ├── bitstamp ├── __init__.py └── bitstamp.py ├── tests ├── __init__.py └── tests.py ├── examples ├── config.py ├── config.ini └── config.json ├── example.py ├── .gitignore ├── setup.py ├── README.rst └── DESCRIPTION.rst /MANIFEST.in: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bitstamp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /examples/config.py: -------------------------------------------------------------------------------- 1 | api_key = 'py api key' 2 | secret = 'py secret' 3 | customer_id = 'py customer id' -------------------------------------------------------------------------------- /examples/config.ini: -------------------------------------------------------------------------------- 1 | [CONFIG] 2 | apiKey=ini api key 3 | secret=ini secret key 4 | customerId=ini customer id -------------------------------------------------------------------------------- /examples/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiKey": "json api key", 3 | "secret": "json secret", 4 | "customerId": "json customer id" 5 | } -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | from bitstamp import bitstamp 5 | 6 | 7 | def test_ticker(): 8 | api = bitstamp.Bitstamp('examples/config.py') 9 | 10 | while True: 11 | print(api.ticker()) 12 | # Change this to less at your own risk - Bitstamp has a harsh policy about exceeding allowed number of calls 13 | time.sleep(1) 14 | 15 | 16 | def test_order_book_ws(): 17 | def handle_message(message): 18 | print(message) 19 | 20 | api = bitstamp.Bitstamp('examples/config.py') 21 | api.attach_ws(bitstamp.WS_CHANNEL_ORDER_BOOK, handle_message) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | *.pyc 10 | 11 | # Packages # 12 | ############ 13 | # it's better to unpack these files and commit the raw source 14 | # git has its own built in compression methods 15 | *.7z 16 | *.dmg 17 | *.gz 18 | *.iso 19 | *.jar 20 | *.rar 21 | *.tar 22 | *.zip 23 | 24 | # Logs and databases # 25 | ###################### 26 | *.db 27 | *.log 28 | *.sql 29 | *.sqlite 30 | 31 | # OS generated files # 32 | ###################### 33 | .DS_Store* 34 | ehthumbs.db 35 | Icon? 36 | Thumbs.db 37 | 38 | # virtualenv generated files # 39 | ###################### 40 | Scripts 41 | Lib 42 | Include 43 | 44 | # IDEs and Code Quality tools # 45 | ############################## 46 | .idea 47 | .sonar 48 | 49 | # Windows stuff # 50 | ################# 51 | *.bat 52 | 53 | # Sandbox # 54 | ################# 55 | test.py 56 | testconfig.py 57 | test.json 58 | test.ini 59 | 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | with open(path.join(here, 'DESCRIPTION.rst'), encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | setup( 11 | name='bitstamp', 12 | 13 | version='0.6.1', 14 | 15 | description='Bitstamp Python API client', 16 | long_description=long_description, 17 | 18 | # The project's main homepage. 19 | # url='https://github.com/pypa/sampleproject', 20 | 21 | author='Danijel Pančić', 22 | author_email='danijel.pancic@bitstamp.net', 23 | 24 | license='MIT', 25 | 26 | classifiers=[ 27 | 'Development Status :: 3 - Alpha', 28 | 29 | 'Intended Audience :: Developers', 30 | 'Topic :: Software Development :: Bitcoin', 31 | 32 | 'License :: OSI Approved :: MIT License', 33 | 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.2', 36 | 'Programming Language :: Python :: 3.3', 37 | 'Programming Language :: Python :: 3.4', 38 | ], 39 | 40 | keywords='bitcoin bitstamp api', 41 | 42 | packages=find_packages(exclude=['examples', 'tests']), 43 | 44 | install_requires=['requests', 'websocket-client'], 45 | ) -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Bistamp Python API 2 | ================== 3 | 4 | This is an unofficial API library for accessing Bitstamp's REST API. 5 | 6 | This version requires Python3. 7 | 8 | Sample Usage 9 | ------------ 10 | 11 | Since the project lives only on GitHub, you can install it with:: 12 | 13 | pip install git+git://github.com/Pancho/bitstamp.git 14 | 15 | In the root folder of this package you can find the file example.py which contains two 16 | examples. 17 | 18 | * First example shows how to instantiate the class (with python file format for config) 19 | and how to call the ticker method. 20 | 21 | * The second shows how to instantiate the class (with python file format for config) and 22 | how to attach to a web socket 23 | 24 | 25 | Configuration 26 | ------------- 27 | 28 | To configure your API key, secret and customer id this library requires for you to use an 29 | external file where you store those two values. To make your life easier, we've made it 30 | possible for you to use one of three file types: json, ini or plain python. To see examples, 31 | browse the examples folder. 32 | Worth mentioning: python config format will work up until Python 3.4 as the importing 33 | procedure is not defined for later versions (expect na update for that case). 34 | 35 | Tests 36 | ----- 37 | 38 | There are 7 TestSuites in the test.py file. 39 | 40 | Run with *python -m unittest tests/tests.py* 41 | 42 | * TestInstantiation - This suite tests many attempts at instantiating the API client, most of which will fail (and should) 43 | * TestSignature - This suite only tests whether the client class generates a correct signature 44 | * TestUnsignedCalls - This suite actually calls the API and tests whether the client receives the correct responses, but only resources that don't require signatures 45 | * TestSignedValidatedCalls - This suite will test the validations for the signed resource calls and will never arrive to the actual call, as all the tests expect exceptions 46 | -------------------------------------------------------------------------------- /DESCRIPTION.rst: -------------------------------------------------------------------------------- 1 | Bistamp Python API 2 | ================== 3 | 4 | This is an unofficial API library for accessing Bitstamp's REST API. 5 | 6 | This version requires Python3. 7 | 8 | Sample Usage 9 | ------------ 10 | 11 | Since the project lives only on GitHub, you can install it with:: 12 | 13 | pip install git+git://github.com/Pancho/bitstamp.git 14 | 15 | In the root folder of this package you can find the file example.py which contains two 16 | examples. 17 | 18 | * First example shows how to instantiate the class (with python file format for config) 19 | and how to call the ticker method. 20 | 21 | * The second shows how to instantiate the class (with python file format for config) and 22 | how to attach to a web socket 23 | 24 | 25 | Configuration 26 | ------------- 27 | 28 | To configure your API key, secret and customer id this library requires for you to use an 29 | external file where you store those two values. To make your life easier, we've made it 30 | possible for you to use one of three file types: json, ini or plain python. To see examples, 31 | browse the examples folder. 32 | Worth mentioning: python config format will work up until Python 3.4 as the importing 33 | procedure is not defined for later versions (expect na update for that case). 34 | 35 | Tests 36 | ----- 37 | 38 | There are 7 TestSuites in the test.py file. 39 | 40 | Run with *python -m unittest tests/tests.py* 41 | 42 | * TestInstantiation - This suite tests many attempts at instantiating the API client, most of which will fail (and should) 43 | * TestSignature - This suite only tests whether the client class generates a correct signature 44 | * TestUnsignedCalls - This suite actually calls the API and tests whether the client receives the correct responses, but only resources that don't require signatures 45 | * TestSignedValidatedCalls - This suite will test the validations for the signed resource calls and will never arrive to the actual call, as all the tests expect exceptions 46 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import hashlib 3 | import hmac 4 | import os 5 | 6 | 7 | from bitstamp import bitstamp 8 | 9 | 10 | class TestInstantiation(unittest.TestCase): 11 | def setUp(self): 12 | self.api_key = 'some api key' 13 | self.secret = 'some secret' 14 | self.customer_id = 'some customer id' 15 | self.files = [ 16 | 'config.json', 17 | 'config_reversed.json', 18 | 'config_missing_api_key.json', 19 | 'config_missing_secret.json', 20 | 'config_misconfigured.json', 21 | 'config.py', 22 | 'config_reversed.py', 23 | 'config_missing_api_key.py', 24 | 'config_missing_secret.py', 25 | 'config_misconfigured.py', 26 | 'config_syntax_error.py', 27 | 'config.ini', 28 | 'config_reversed.ini', 29 | 'config_missing_api_key.ini', 30 | 'config_missing_secret.ini', 31 | 'config_misconfigured.ini', 32 | ] 33 | 34 | file = open('config.ini', 'wb') 35 | file.write(bytes('[CONFIG]\napikey={}\nsecret={}\ncustomerId={}'.format(self.api_key, self.secret, self.customer_id), encoding='utf8')) 36 | self.ini_path = os.path.abspath(file.name) 37 | file.close() 38 | 39 | file = open('config_reversed.ini', 'wb') 40 | file.write(bytes('[CONFIG]\nsecret={}\napikey={}\ncustomerId={}'.format(self.secret, self.api_key, self.customer_id), encoding='utf8')) 41 | self.ini_config_reversed_path = os.path.abspath(file.name) 42 | file.close() 43 | 44 | file = open('config_missing_api_key.ini', 'wb') 45 | file.write(bytes('[CONFIG]\nsecret={}\ncustomerId={}'.format(self.secret, self.customer_id), encoding='utf8')) 46 | self.ini_config_missing_api_key_path = os.path.abspath(file.name) 47 | file.close() 48 | 49 | file = open('config_missing_secret.ini', 'wb') 50 | file.write(bytes('[CONFIG]\napikey={}\ncustomerId={}'.format(self.api_key, self.customer_id), encoding='utf8')) 51 | self.ini_config_missing_secret_path = os.path.abspath(file.name) 52 | file.close() 53 | 54 | file = open('config_misconfigured.ini', 'wb') 55 | file.write(bytes('secret={}\napikey={}\ncustomerId={}'.format(self.secret, self.api_key, self.customer_id), encoding='utf8')) 56 | self.ini_config_misconfigured_path = os.path.abspath(file.name) 57 | file.close() 58 | 59 | file = open('config.json', 'wb') 60 | file.write(bytes('{{"apiKey": "{}", "secret": "{}", "customerId": "{}"}}'.format(self.api_key, self.secret, self.customer_id), encoding='utf8')) 61 | self.json_path = os.path.abspath(file.name) 62 | file.close() 63 | 64 | file = open('config_reversed.json', 'wb') 65 | file.write(bytes('{{"secret": "{}", "apiKey": "{}", "customerId": "{}"}}'.format(self.secret, self.api_key, self.customer_id), encoding='utf8')) 66 | self.json_config_reversed_path = os.path.abspath(file.name) 67 | file.close() 68 | 69 | file = open('config_missing_api_key.json', 'wb') 70 | file.write(bytes('{{"secret": "{}", "customerId": "{}"}}'.format(self.secret, self.customer_id), encoding='utf8')) 71 | self.json_config_missing_api_key_path = os.path.abspath(file.name) 72 | file.close() 73 | 74 | file = open('config_missing_secret.json', 'wb') 75 | file.write(bytes('{{"apiKey": "{}", "customerId": "{}"}}'.format(self.api_key, self.customer_id), encoding='utf8')) 76 | self.json_config_missing_secret_path = os.path.abspath(file.name) 77 | file.close() 78 | 79 | file = open('config_misconfigured.json', 'wb') 80 | file.write(bytes('{{\'apiKey\': \'{}\', \'secret\': \'{}\', \'customerId\': \'{}\'}}'.format(self.secret, self.api_key, self.customer_id), encoding='utf8')) 81 | self.json_config_misconfigured_path = os.path.abspath(file.name) 82 | file.close() 83 | 84 | file = open('config.py', 'wb') 85 | file.write(bytes('api_key = \'{}\'\nsecret = \'{}\'\ncustomer_id = \'{}\''.format(self.api_key, self.secret, self.customer_id), encoding='utf8')) 86 | self.py_path = os.path.abspath(file.name) 87 | file.close() 88 | 89 | file = open('config_reversed.py', 'wb') 90 | file.write(bytes('secret = \'{}\'\napi_key = \'{}\'\ncustomer_id = \'{}\''.format(self.secret, self.api_key, self.customer_id), encoding='utf8')) 91 | self.py_config_reversed_path = os.path.abspath(file.name) 92 | file.close() 93 | 94 | file = open('config_missing_api_key.py', 'wb') 95 | file.write(bytes('secret = \'{}\'\ncustomer_id = \'{}\''.format(self.secret, self.customer_id), encoding='utf8')) 96 | self.py_config_missing_api_key_path = os.path.abspath(file.name) 97 | file.close() 98 | 99 | file = open('config_missing_secret.py', 'wb') 100 | file.write(bytes('api_key = \'{}\'\ncustomer_id = \'{}\''.format(self.api_key, self.customer_id), encoding='utf8')) 101 | self.py_config_missing_secret_path = os.path.abspath(file.name) 102 | file.close() 103 | 104 | file = open('config_misconfigured.py', 'wb') 105 | file.write(bytes('api_key = {}\nsecret = {}\ncustomer_id = {}'.format(self.secret, self.api_key, self.customer_id), encoding='utf8')) 106 | self.py_config_misconfigured_path = os.path.abspath(file.name) 107 | file.close() 108 | 109 | self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 110 | 111 | def test_instantiate_no_params(self): 112 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(), msg='Not pasing any arguments to the constructor should raise an exception') 113 | 114 | def test_instantiate_only_api_key(self): 115 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(None, self.api_key, None, self.customer_id), msg='Not passing secret, expecting exception') 116 | 117 | def test_instantiate_only_secret(self): 118 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(None, None, self.secret, self.customer_id), msg='Not passing api key, expecting exception') 119 | 120 | def test_instantiate_only_customer_id(self): 121 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(None, None, None, self.customer_id), msg='Not passing customer, expecting exception') 122 | 123 | def test_instantiate_ini(self): 124 | self.assertEqual(self.working_api, bitstamp.Bitstamp(self.ini_path)) 125 | 126 | def test_instantiate_ini_reversed(self): 127 | self.assertEqual(self.working_api, bitstamp.Bitstamp(self.ini_config_reversed_path)) 128 | 129 | def test_instantiate_ini_missing_api_key(self): 130 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.ini_config_missing_api_key_path), msg='Not passing api key, expecting exception') 131 | 132 | def test_instantiate_ini_missing_secret(self): 133 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.ini_config_missing_secret_path), msg='Not passing secret, expecting exception') 134 | 135 | def test_instantiate_ini_misconfigured(self): 136 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.ini_config_misconfigured_path), msg='Passing a config that\'s misconfigured should raise an exception') 137 | 138 | def test_instantiate_json(self): 139 | self.assertEqual(self.working_api, bitstamp.Bitstamp(self.json_path)) 140 | 141 | def test_instantiate_json_reversed(self): 142 | self.assertEqual(self.working_api, bitstamp.Bitstamp(self.json_config_reversed_path)) 143 | 144 | def test_instantiate_json_missing_api_key(self): 145 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.json_config_missing_api_key_path), msg='Not passing api key, expecting exception') 146 | 147 | def test_instantiate_json_missing_secret(self): 148 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.json_config_missing_secret_path), msg='Not passing secret, expecting exception') 149 | 150 | def test_instantiate_json_misconfigured(self): 151 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.json_config_misconfigured_path), msg='Passing a config that\'s misconfigured should raise an exception') 152 | 153 | def test_instantiate_py(self): 154 | self.assertEqual(self.working_api, bitstamp.Bitstamp(self.py_path)) 155 | 156 | def test_instantiate_py_reversed(self): 157 | self.assertEqual(self.working_api, bitstamp.Bitstamp(self.py_config_reversed_path)) 158 | 159 | def test_instantiate_py_missing_api_key(self): 160 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.py_config_missing_api_key_path), msg='Not passing api key, expecting exception') 161 | 162 | def test_instantiate_py_missing_secret(self): 163 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.py_config_missing_secret_path), msg='Not passing secret, expecting exception') 164 | 165 | def test_instantiate_py_misconfigured(self): 166 | self.assertRaises(Exception, lambda: bitstamp.Bitstamp(self.py_config_misconfigured_path), msg='Passing a config that\'s misconfigured should raise an exception') 167 | 168 | def tearDown(self): 169 | for file_path in self.files: 170 | if os.path.isfile(file_path): 171 | os.remove(file_path) 172 | 173 | 174 | class TestSignature(unittest.TestCase): 175 | def setUp(self): 176 | self.api_key = 'some api key' 177 | self.secret = 'some secret' 178 | self.customer_id = 'some customer id' 179 | self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 180 | 181 | def test_signature(self): 182 | nonce, signature = self.working_api._Bitstamp__get_signature() 183 | 184 | # Construct the signature per instructions from the official API page 185 | signature_raw = '{}{}{}'.format(nonce, self.customer_id, self.api_key) 186 | new_signature = hmac.new(self.secret.encode('utf8'), msg=signature_raw.encode('utf8'), digestmod=hashlib.sha256).hexdigest().upper() 187 | 188 | self.assertEqual(new_signature, signature, msg='Signatures should match (from client: {} VS from test: {})'.format(signature, new_signature)) 189 | 190 | def tearDown(self): 191 | pass 192 | 193 | 194 | # One should not run this test suite too many times, as they still use regular API calls and can still 195 | # cause a ban for 15 minutes. 196 | class TestUnsignedCalls(unittest.TestCase): 197 | def setUp(self): 198 | self.api_key = 'some api key' 199 | self.secret = 'some secret' 200 | self.customer_id = 'some customer id' 201 | self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 202 | 203 | def test_ticker(self): 204 | # No real need to sort these, but to be sure we use the same ordering, we should use the same order function 205 | expected_keys = sorted([ 206 | 'timestamp', 207 | 'high', 208 | 'ask', 209 | 'last', 210 | 'low', 211 | 'open', 212 | 'bid', 213 | 'volume', 214 | 'vwap', 215 | ]) 216 | 217 | for pair in bitstamp.ALL_PAIRS: 218 | ticker_blob = self.working_api.ticker(currency=pair) 219 | ticker_blob_parsed = self.working_api.ticker(parsed=True) 220 | 221 | self.assertIsNotNone(ticker_blob, msg='Result should not be none') 222 | self.assertTrue(isinstance(ticker_blob, dict), msg='Result from this api call has to be a python dictionary (even if there\' an error on the SPI server)') 223 | 224 | keys = sorted(list(ticker_blob.keys())) 225 | self.assertEqual(keys, expected_keys, msg='Expected keys don\'t match with the received ones') 226 | 227 | # If we just return the parsed JSON from the API, all the values should be strings 228 | # ADDENDUM: I have commented out this test in particular, because open parameter (open) is not of type string 229 | # (for some reason) 230 | # for key, value in ticker_blob.items(): 231 | # self.assertTrue(isinstance(value, str), msg='{} is not of type string, but should be'.format(key)) 232 | 233 | # If we parse the results' values, none of them should be strings 234 | for key, value in ticker_blob_parsed.items(): 235 | self.assertFalse(isinstance(value, str), msg='{} is of type string, but should not be be'.format(key)) 236 | 237 | 238 | def test_order_book(self): 239 | # No real need to sort these, but to be sure we use the same ordering, we should use the same order function 240 | expected_keys = sorted([ 241 | 'timestamp', 242 | 'asks', 243 | 'bids', 244 | ]) 245 | 246 | for pair in bitstamp.ALL_PAIRS: 247 | order_book = self.working_api.order_book(currency=pair) 248 | 249 | self.assertIsNotNone(order_book, msg='Result should not be none') 250 | self.assertTrue(isinstance(order_book, dict), msg='Result from this api call has to be a python dictionary (even if there\' an error on the SPI server)') 251 | 252 | keys = sorted(list(order_book.keys())) 253 | self.assertEqual(keys, expected_keys, msg='Expected keys don\'t match with the received ones') 254 | 255 | def test_transactions(self): 256 | # We don't need hour's worth of transactions to perform tests 257 | for pair in bitstamp.ALL_PAIRS: 258 | transactions = self.working_api.transactions(currency=pair, timespan='minute') 259 | 260 | self.assertIsNotNone(transactions, msg='Result should not be none') 261 | self.assertTrue(isinstance(transactions, list), msg='Result from this api call has to be a python list (even if there\' an error on the SPI server)') 262 | 263 | def tearDown(self): 264 | pass 265 | 266 | 267 | class TestSignedValidatedCalls(unittest.TestCase): 268 | ''' 269 | These calls have to be signed and will return exceptions from the API, however there are some validations 270 | performed before that happens and we should test for those. API exceptions will not break the runtime, but 271 | validations in this API client will try to. 272 | 273 | Methods that are validated are: 274 | * buy_limit_order 275 | * sell_limit_order 276 | * user_transactions 277 | * cancel_order 278 | * bitcoin_withdrawal 279 | ''' 280 | def setUp(self): 281 | self.api_key = 'some api key' 282 | self.secret = 'some secret' 283 | self.customer_id = 'some customer id' 284 | self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 285 | 286 | def test_buy_limit_order_validations(self): 287 | self.assertRaises(Exception, lambda: self.working_api.buy_limit_order(-1, 1, None), msg='Amount should be capped at min=0') 288 | self.assertRaises(Exception, lambda: self.working_api.buy_limit_order(1, -1, None), msg='Price should be capped at min=0') 289 | self.assertRaises(Exception, lambda: self.working_api.buy_limit_order(5, 0.9999, None), msg='The volume of the order should be 5$ or more') 290 | self.assertRaises(Exception, lambda: self.working_api.buy_limit_order(5, 2, -1), msg='Limit price if bought should be capped at min=0') 291 | 292 | def test_sell_limit_order_validations(self): 293 | self.assertRaises(Exception, lambda: self.working_api.sell_limit_order(-1, 1, None), msg='Amount should be capped at min=0') 294 | self.assertRaises(Exception, lambda: self.working_api.sell_limit_order(1, -1, None), msg='Price should be capped at min=0') 295 | self.assertRaises(Exception, lambda: self.working_api.sell_limit_order(5, 0.9999, None), msg='The volume of the order should be 5$ or more') 296 | self.assertRaises(Exception, lambda: self.working_api.sell_limit_order(5, 2, -1), msg='Limit price if sold should be capped at min=0') 297 | 298 | def test_user_transactions_validations(self): 299 | self.assertRaises(Exception, lambda: self.working_api.user_transactions(-1, 1, 'desc'), msg='Offset has to be capped at min=0') 300 | self.assertRaises(Exception, lambda: self.working_api.user_transactions(0, 0, 'desc'), msg='Limit should be capped at min=1') 301 | self.assertRaises(Exception, lambda: self.working_api.user_transactions(0, 1001, 'desc'), msg='Limit should be capped at max=1000') 302 | self.assertRaises(Exception, lambda: self.working_api.user_transactions(0, 1, 'not-asc-or-desc'), msg='Ordering should either be "desc" or "asc"') 303 | 304 | def test_cancel_order_validations(self): 305 | self.assertRaises(Exception, lambda: self.working_api.cancel_order(None), msg='Order id must not be None') 306 | 307 | def test_bitcoin_withdrawal_validations(self): 308 | self.assertRaises(Exception, lambda: self.working_api.bitcoin_withdrawal(0, '123456789012345678901234567890'), msg='Amount should be capped at min>0') 309 | self.assertRaises(Exception, lambda: self.working_api.bitcoin_withdrawal(1, '1'), msg='Address string should be long at least 25 characters') 310 | self.assertRaises(Exception, lambda: self.working_api.bitcoin_withdrawal(1, '12345678901234567890123456789012345'), msg='Address string should be long less than 35 characters') 311 | 312 | def tearDown(self): 313 | pass 314 | 315 | 316 | # class TestWebSocketsLiveTrades(unittest.TestCase): 317 | # def setUp(self): 318 | # self.api_key = 'some api key' 319 | # self.secret = 'some secret' 320 | # self.customer_id = 'some customer id' 321 | # self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 322 | # 323 | # def message_closure(self): 324 | # def test_message(message): 325 | # expected_keys = sorted([ 326 | # 'amount', 327 | # 'price', 328 | # 'id', 329 | # ]) 330 | # keys = sorted(list(message.keys())) 331 | # self.assertEqual(keys, expected_keys, msg='Expected keys don\'t match with the received ones') 332 | # self.working_api.close_ws() 333 | # 334 | # return test_message 335 | # 336 | # def test_live_trades(self): 337 | # self.working_api.attach_ws(bitstamp.WS_CHANNEL_LIVE_TRADES, self.message_closure()) 338 | # 339 | # def tearDown(self): 340 | # pass 341 | # 342 | # 343 | # class TestWebSocketsOrderBook(unittest.TestCase): 344 | # def setUp(self): 345 | # self.api_key = 'some api key' 346 | # self.secret = 'some secret' 347 | # self.customer_id = 'some customer id' 348 | # self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 349 | # 350 | # def message_closure(self): 351 | # def test_message(message): 352 | # expected_keys = sorted([ 353 | # 'asks', 354 | # 'bids', 355 | # ]) 356 | # keys = sorted(list(message.keys())) 357 | # self.assertEqual(keys, expected_keys, msg='Expected keys don\'t match with the received ones') 358 | # self.working_api.close_ws() 359 | # 360 | # return test_message 361 | # 362 | # def test_live_trades(self): 363 | # self.working_api.attach_ws(bitstamp.WS_CHANNEL_ORDER_BOOK, self.message_closure()) 364 | # 365 | # def tearDown(self): 366 | # pass 367 | # 368 | # 369 | # class TestWebSocketsOrderBookDiff(unittest.TestCase): 370 | # def setUp(self): 371 | # self.api_key = 'some api key' 372 | # self.secret = 'some secret' 373 | # self.customer_id = 'some customer id' 374 | # self.working_api = bitstamp.Bitstamp(api_key=self.api_key, secret=self.secret, customer_id=self.customer_id) 375 | # 376 | # def message_closure(self): 377 | # def test_message(message): 378 | # expected_keys = sorted([ 379 | # 'asks', 380 | # 'bids', 381 | # 'timestamp', 382 | # ]) 383 | # keys = sorted(list(message.keys())) 384 | # self.assertEqual(keys, expected_keys, msg='Expected keys don\'t match with the received ones') 385 | # self.working_api.close_ws() 386 | # 387 | # return test_message 388 | # 389 | # def test_live_trades(self): 390 | # self.working_api.attach_ws(bitstamp.WS_CHANNEL_ORDER_BOOK_DIFF, self.message_closure()) 391 | # 392 | # def tearDown(self): 393 | # pass 394 | -------------------------------------------------------------------------------- /bitstamp/bitstamp.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from datetime import datetime 3 | import json 4 | import hmac 5 | import hashlib 6 | import time 7 | 8 | import websocket 9 | import requests 10 | 11 | EXAMPLES_URL = 'https://github.com/Pancho/bitstamp' 12 | BITSTAMP_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' 13 | WS_CHANNEL_LIVE_TRADES = 'live-trades' 14 | WS_CHANNEL_ORDER_BOOK = 'order-book' 15 | WS_CHANNEL_ORDER_BOOK_DIFF = 'diff-order-book' 16 | USER_TRANSACTION_ORDERING_DESC = 'desc' 17 | USER_TRANSACTION_ORDERING_ASC = 'asc' 18 | BTC_USD = 'btcusd' 19 | BTC_EUR = 'btceur' 20 | EUR_USD = 'eurusd' 21 | ALL_PAIRS = [ 22 | BTC_USD, 23 | BTC_EUR, 24 | BTC_EUR, 25 | ] 26 | 27 | 28 | class Bitstamp(object): 29 | def __init__(self, config_file_path=None, api_key=None, secret=None, customer_id=None, api_endpoint=None): 30 | ''' 31 | Constructor. You can instantiate this class with either file path or with all three values that would otherwise 32 | be found in the config file. 33 | :param config_file_path: path to the file with the config 34 | :param api_key: API key found on https://www.bitstamp.net/account/security/api/ 35 | :param secret: Secret found on https://www.bitstamp.net/account/security/api/ (disappears after some time) 36 | :param customer_id: Customer ID found on https://www.bitstamp.net/account/balance/ 37 | :return: The client object 38 | ''' 39 | # None of the parameters are necessary, but to work properly, we need at least one pair from one source 40 | if config_file_path is None and (api_key is None or secret is None or customer_id is None): 41 | raise Exception( 42 | 'You need to pass either config_file_path parameter or all of api_key, secret and customer_id') 43 | 44 | if config_file_path is not None: 45 | self.api_key, self.secret, self.customer_id = self.__get_credentials(config_file_path) 46 | else: 47 | self.api_key = api_key 48 | self.secret = secret 49 | self.customer_id = customer_id 50 | 51 | # If still not api key and no secret, raise 52 | if self.api_key is None or self.api_key.strip() == '' or self.secret is None or self.secret.strip() == '' or self.customer_id is None or self.customer_id.strip() == '': 53 | raise Exception('No credentials were found') 54 | 55 | if api_endpoint is None: 56 | self.api_endpoint = 'https://www.bitstamp.net/api/' 57 | else: 58 | self.api_endpoint = api_endpoint 59 | # Why didn't I use the pushed API? 60 | # 1. I wanted this client lib to be Python3 compatible - Pusher doesn't support that (clearly) yet 61 | # 2. Don't want all the ballast that comes along (a whole lib for three channels and supporting libs) 62 | self.websockets_endpoint = 'ws://ws.pusherapp.com/app/de504dc5763aeef9ff52?protocol=7' 63 | self.ws_channels = { 64 | # Pusher wants JSON objects stringified (which is kind of weird, but due to it's generic nature it might make some sense) 65 | WS_CHANNEL_LIVE_TRADES: '{"event":"pusher:subscribe","data":{"channel":"live_trades"}}', 66 | WS_CHANNEL_ORDER_BOOK: '{"event":"pusher:subscribe","data":{"channel":"order_book"}}', 67 | WS_CHANNEL_ORDER_BOOK_DIFF: '{"event":"pusher:subscribe","data":{"channel":"diff_order_book"}}', 68 | } 69 | self.ws_data_events = ['data', 'trade'] 70 | 71 | def __str__(self): 72 | ''' 73 | Two API clients are the same if they use the same credentials. They must behave equally in respect to all the calls. 74 | :return: None 75 | ''' 76 | return json.dumps([self.api_key, self.secret, self.customer_id]) 77 | 78 | def __eq__(self, other): 79 | return str(self) == str(other) 80 | 81 | def __ne__(self, other): 82 | return str(self) != str(other) 83 | 84 | def __get_credentials(self, config_file_path): 85 | ''' 86 | This method will try to interpret the file on the path in one of three ways: 87 | * first it will try to interpret it as an ini file (regardless of the file extension) 88 | * then it will try to interpret it as a JSON file (regardless of the file extension) 89 | * lastly it will try to interpret it as a Python file (file extension must be py) 90 | :param config_file_path: absolute path to the config file 91 | :return: api key, secret and customer id (tuple) 92 | ''' 93 | api_key = None 94 | secret = None 95 | customer_id = None 96 | 97 | # All of the following tries catch all exceptions that can occur (as there are plenty): missing file, 98 | # wrong type, misconfigured. 99 | try: 100 | config_parser = ConfigParser() 101 | config_parser.read(config_file_path) 102 | api_key = config_parser.get('CONFIG', 'apiKey') 103 | secret = config_parser.get('CONFIG', 'secret') 104 | customer_id = config_parser.get('CONFIG', 'customerId') 105 | return api_key, secret, customer_id 106 | except: 107 | pass 108 | 109 | try: 110 | with open(config_file_path, 'rb') as file: 111 | blob = json.loads(file.read().decode()) 112 | api_key = blob.get('apiKey') 113 | secret = blob.get('secret') 114 | customer_id = blob.get('customerId') 115 | return api_key, secret, customer_id 116 | except: 117 | pass 118 | 119 | # This is deprecated in python 3.4 (but it will work), so if working with later, try using ini or json approaches instead 120 | try: 121 | import importlib.machinery 122 | 123 | loader = importlib.machinery.SourceFileLoader('bitstamp.config', config_file_path) 124 | config = loader.load_module() 125 | 126 | api_key = config.api_key 127 | secret = config.secret 128 | customer_id = config.customer_id 129 | return api_key, secret, customer_id 130 | except: 131 | pass 132 | 133 | if api_key is None or secret is None: 134 | raise Exception( 135 | 'While the config file was found, it was not configured correctly. Check for examples here: {}'.format( 136 | EXAMPLES_URL)) 137 | 138 | return api_key, secret, customer_id 139 | 140 | def __get_signature(self): 141 | ''' 142 | Returns the signature for the next REST API call. nonce will be a timestamp (time.time()) multiplied by 1000, 143 | so we include some of the decimal part to reduce the chance of sending the same one more than once. 144 | :return: nonce, signature (tuple) 145 | ''' 146 | nonce = str(int(time.time() * 1000)) 147 | signature_raw = '{}{}{}'.format(nonce, self.customer_id, self.api_key) 148 | return nonce, hmac.new(self.secret.encode('utf8'), msg=signature_raw.encode('utf8'), 149 | digestmod=hashlib.sha256).hexdigest().upper() 150 | 151 | @staticmethod 152 | def __parse_ticker(blob): 153 | blob['timestamp'] = int(blob.get('timestamp')) 154 | blob['high'] = float(blob.get('high')) 155 | blob['ask'] = float(blob.get('ask')) 156 | blob['last'] = float(blob.get('last')) 157 | blob['low'] = float(blob.get('low')) 158 | blob['open'] = float(blob.get('open')) 159 | blob['bid'] = float(blob.get('bid')) 160 | blob['volume'] = float(blob.get('volume')) 161 | blob['vwap'] = float(blob.get('vwap')) 162 | 163 | return blob 164 | 165 | def ticker(self, currency=BTC_USD, parsed=False): 166 | ''' 167 | This method will call ticker resource and return the result. 168 | :param currency: one of the currency pairs 169 | :param parsed: if True, blob will be parsed 170 | :return: ticker blob (dict) 171 | ''' 172 | resource = 'v2/ticker/{}/'.format(currency) 173 | 174 | response = requests.get('{}{}'.format(self.api_endpoint, resource)) 175 | 176 | if parsed: 177 | return self.__parse_ticker(json.loads(response.text)) 178 | else: 179 | return json.loads(response.text) 180 | 181 | def order_book(self, currency=BTC_USD): 182 | ''' 183 | This method will call order_book resource and return the result. 184 | :param currency: one of the currency pairs 185 | :return: order book blob (dict) 186 | ''' 187 | resource = 'v2/order_book/{}/'.format(currency) 188 | 189 | response = requests.get('{}{}'.format(self.api_endpoint, resource)) 190 | 191 | return json.loads(response.text) 192 | 193 | def transactions(self, currency=BTC_USD, timespan='hour'): 194 | ''' 195 | This method will call transactions resource and return the result. 196 | :param currency: one of the currency pairs 197 | :param timespan: minute/hour string 198 | :return: list of transactions made in the past minute/hour 199 | ''' 200 | resource = 'v2/transactions/{}/'.format(currency) 201 | 202 | if timespan != 'hour' and timespan != 'minute': 203 | raise Exception('Parameter time can be only "hour" or "minute". Default is "hour"') 204 | 205 | response = requests.get('{}{}'.format(self.api_endpoint, resource), data={ 206 | 'time': timespan 207 | }) 208 | 209 | return json.loads(response.text) 210 | 211 | def eur_usd(self): 212 | ''' 213 | This method will call eur_usd resource and return the result. 214 | :return: dict with the exchange rates between USD and EUR currencies 215 | ''' 216 | resource = 'eur_usd/' 217 | 218 | response = requests.get('{}{}'.format(self.api_endpoint, resource)) 219 | 220 | return json.loads(response.text) 221 | 222 | def balance(self, currency=None): 223 | ''' 224 | This method will call balance resource and return the result. 225 | This is a resource that requires signature. 226 | :param currency: one of the currency pairs 227 | :return: a dict containing all the info about user account balance, BTC included 228 | ''' 229 | if currency is not None: # This is the case when user want all of their balances 230 | resource = 'v2/balance/' 231 | else: 232 | resource = 'v2/balance/{}/'.format(currency) 233 | 234 | nonce, signature = self.__get_signature() 235 | 236 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 237 | 'key': self.api_key, 238 | 'nonce': nonce, 239 | 'signature': signature, 240 | }) 241 | 242 | return json.loads(response.text) 243 | 244 | def user_transactions(self, currency=None, offset=0, limit=100, sort='desc'): 245 | ''' 246 | This method will call user_transactions resource and return the result. 247 | This is a resource that requires signature. 248 | :param currency: one of the currency pairs 249 | :param offset: offset, useful for pagination, that has to be positive number 250 | :param limit: limit of how many transactions you will receive, in range (0, 1000] 251 | :param sort: one of the values: 'desc' or 'asc' 252 | :return: a list of user's transactions 253 | ''' 254 | if currency is not None: # This is the case when user want all of their transactions 255 | resource = 'v2/user_transactions/' 256 | else: 257 | resource = 'v2/user_transactions/{}/'.format(currency) 258 | 259 | if offset < 0: 260 | raise Exception('Offset has to be a positive number') 261 | 262 | if limit < 1 or limit > 1000: 263 | raise Exception('Limit has to be a number from range [1, 1000]') 264 | 265 | if sort is not USER_TRANSACTION_ORDERING_ASC and sort is not USER_TRANSACTION_ORDERING_DESC: 266 | raise Exception('Sort parameter has to be one of {} or {}'.format(USER_TRANSACTION_ORDERING_DESC, 267 | USER_TRANSACTION_ORDERING_ASC)) 268 | 269 | nonce, signature = self.__get_signature() 270 | 271 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 272 | 'key': self.api_key, 273 | 'nonce': nonce, 274 | 'signature': signature, 275 | 'offset': offset, 276 | 'limit': limit, 277 | 'sort': sort, 278 | }) 279 | 280 | return json.loads(response.text) 281 | 282 | def open_orders(self, currency=None): 283 | ''' 284 | This method will call open_orders resource and return the result. 285 | This is a resource that requires signature. 286 | :param currency: one of the currency pairs 287 | :return: a list of dictionaries that represent orders that haven't been closed yet 288 | ''' 289 | if currency is not None: # This is the case when user want all of their transactions 290 | resource = 'v2/open_orders/' 291 | else: 292 | resource = 'v2/open_orders/{}/'.format(currency) 293 | 294 | nonce, signature = self.__get_signature() 295 | 296 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 297 | 'key': self.api_key, 298 | 'nonce': nonce, 299 | 'signature': signature, 300 | }) 301 | 302 | return json.loads(response.text) 303 | 304 | def order_status(self, order_id): 305 | ''' 306 | This method will call order_status resource and return the result. 307 | This is a resource that requires signature. 308 | :return: a dictionary that represent order current status and the transactions that have acted upon it 309 | ''' 310 | resource = 'order_status/' 311 | 312 | nonce, signature = self.__get_signature() 313 | 314 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 315 | 'key': self.api_key, 316 | 'nonce': nonce, 317 | 'signature': signature, 318 | 'id': order_id, 319 | }) 320 | 321 | return json.loads(response.text) 322 | 323 | def buy_limit_order(self, amount, price, currency=BTC_USD, limit_price=None): 324 | ''' 325 | This method will call buy resource and return the result. 326 | This is a resource that requires signature. 327 | This method will throw exception if amount * price yields a float that's less than 5 328 | :param amount: a float, the will be rounded to 8 decimal places, has to be positive 329 | :param price: a float that will be rounded to 2 decimal places, has to be positive 330 | :param currency: one of the currency pairs 331 | :param limit_price: a float that will be rounded to 2 decimal places, has to be positive 332 | :return: a boolean value, True if the order has been successfully opened, False if it failed 333 | ''' 334 | resource = 'v2/buy/{}/'.format(currency) 335 | 336 | if amount < 0: 337 | raise Exception('Amount has to be a positive float') 338 | 339 | if price < 0: 340 | raise Exception('Price has to be a positive float') 341 | 342 | if (price * amount) < 5: 343 | raise Exception('Order volume (price * amount) has to be at least 5$') 344 | 345 | nonce, signature = self.__get_signature() 346 | 347 | data = { 348 | 'key': self.api_key, 349 | 'nonce': nonce, 350 | 'signature': signature, 351 | 'price': '{:.2f}'.format(price), 352 | 'amount': '{:.8f}'.format(amount), 353 | } 354 | 355 | if limit_price is not None: 356 | if limit_price < 0: 357 | raise Exception('Limit price has to be a positive float') 358 | 359 | if (limit_price * amount) < 5: 360 | raise Exception('Order volume (limit_price * amount) has to be at least 5$') 361 | 362 | data['limit_price'] = limit_price 363 | 364 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data=data) 365 | 366 | return json.loads(response.text) 367 | 368 | def sell_limit_order(self, amount, price, currency=BTC_USD, limit_price=None): 369 | ''' 370 | This method will call sell resource and return the result. 371 | This is a resource that requires signature. 372 | This method will throw exception if amount * price yields a float that's less than 5 373 | :param amount: a float, the will be rounded to 8 decimal places, has to be positive 374 | :param price: a float that will be rounded to 2 decimal places, has to be positive 375 | :param currency: one of the currency pairs 376 | :param limit_price: a float that will be rounded to 2 decimal places, has to be positive 377 | :return: a boolean value, True if the order has been successfully opened, False if it failed 378 | ''' 379 | resource = 'v2/sell/{}/'.format(currency) 380 | 381 | if amount < 0: 382 | raise Exception('Amount has to be a positive float') 383 | 384 | if price < 0: 385 | raise Exception('Price has to be a positive float') 386 | 387 | if (price * amount) < 5: 388 | raise Exception('Order volume (price * amount) has to be at least 5$') 389 | 390 | nonce, signature = self.__get_signature() 391 | 392 | data = { 393 | 'key': self.api_key, 394 | 'nonce': nonce, 395 | 'signature': signature, 396 | 'price': '{:.2f}'.format(price), 397 | 'amount': '{:.8f}'.format(amount), 398 | } 399 | 400 | if limit_price is not None: 401 | if limit_price < 0: 402 | raise Exception('Limit price has to be a positive float') 403 | 404 | if (limit_price * amount) < 5: 405 | raise Exception('Order volume (limit_price * amount) has to be at least 5$') 406 | 407 | data['limit_price'] = limit_price 408 | 409 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data=data) 410 | 411 | return json.loads(response.text) 412 | 413 | def cancel_order(self, order_id): 414 | ''' 415 | This method will call buy cancel_order and return the result. 416 | This is a resource that requires signature. 417 | :param order_id: integer or string if the order's ID (can be found via open_orders method) 418 | :return: a boolean value, True if the order has been successfully closed, False if it failed 419 | ''' 420 | resource = 'cancel_order/' 421 | 422 | if order_id is None: 423 | raise Exception('You have to provide an order id (you can get the list of open orders with open_roders())') 424 | 425 | nonce, signature = self.__get_signature() 426 | 427 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 428 | 'key': self.api_key, 429 | 'nonce': nonce, 430 | 'signature': signature, 431 | 'id': order_id, 432 | }) 433 | 434 | return json.loads(response.text) 435 | 436 | def withdrawal_requests(self): 437 | ''' 438 | This method will call withdrawal_requests and return the result. 439 | This is a resource that requires signature. 440 | :return: a list of dictionaries, each representing one withdrawal request 441 | ''' 442 | resource = 'withdrawal_requests/' 443 | 444 | nonce, signature = self.__get_signature() 445 | 446 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 447 | 'key': self.api_key, 448 | 'nonce': nonce, 449 | 'signature': signature, 450 | }) 451 | 452 | return json.loads(response.text) 453 | 454 | def bitcoin_withdrawal(self, amount, address): 455 | ''' 456 | This method will call bitcoin_withdrawal and return the result. 457 | This is a resource that requires signature. 458 | :param amount: a positive number, BTC amount for withdrawal 459 | :param address: a wallet address, a string that's longer than 25 characters and shorter than 35 characters 460 | :return: a boolean if withdrawal was successful and false if it failed 461 | ''' 462 | resource = 'bitcoin_withdrawal/' 463 | 464 | if amount <= 0: 465 | raise Exception('Amount has to be a positive float') 466 | 467 | if address is None or address.strip() == '' or len(address) < 25 or len(address) > 34: 468 | raise Exception('You need to specify a valid address to which you want to send your BTC') 469 | 470 | nonce, signature = self.__get_signature() 471 | 472 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 473 | 'key': self.api_key, 474 | 'nonce': nonce, 475 | 'signature': signature, 476 | 'amount': '{:.8f}'.format(amount), 477 | 'address': address, 478 | }) 479 | 480 | return json.loads(response.text) 481 | 482 | def unconfirmed_bitcoin_deposits(self): 483 | ''' 484 | This method will call bitcoin_withdrawal and return the result. 485 | This is a resource that requires signature. 486 | :return:a list of pending BTC deposits to your wallet 487 | ''' 488 | resource = 'unconfirmed_btc/' 489 | 490 | nonce, signature = self.__get_signature() 491 | 492 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 493 | 'key': self.api_key, 494 | 'nonce': nonce, 495 | 'signature': signature, 496 | }) 497 | 498 | return json.loads(response.text) 499 | 500 | def wallet_address(self): 501 | ''' 502 | This method will call bitcoin_withdrawal and return the result. 503 | This is a resource that requires signature. 504 | :return: a string representing your wallet address 505 | ''' 506 | resource = 'bitcoin_deposit_address/' 507 | 508 | nonce, signature = self.__get_signature() 509 | 510 | response = requests.post('{}{}'.format(self.api_endpoint, resource), data={ 511 | 'key': self.api_key, 512 | 'nonce': nonce, 513 | 'signature': signature, 514 | }) 515 | 516 | return json.loads(response.text) 517 | 518 | def __on_open(self, channel): 519 | channel_string = self.ws_channels[channel] 520 | 521 | def open_channel(internal_ws): 522 | return internal_ws.send(channel_string) 523 | 524 | return open_channel 525 | 526 | def __generic_error_callback(self, *args, **kwargs): 527 | pass 528 | 529 | def __generic_close_callback(self, *args, **kwargs): 530 | pass 531 | 532 | def attach_ws(self, channel, callback, error_callback=None, close_callback=None): 533 | ''' 534 | This method lets you attach a callback or callbacks to a specific channel that will react each time web socket 535 | gets a message. 536 | :param channel: one of bitstamp.WS_CHANNEL_LIVE_TRADES, bitstamp.WS_CHANNEL_ORDER_BOOK or bitstamp.WS_CHANNEL_ORDER_BOOK_DIFF 537 | :param callback: a method that will react to the message received on the web socket 538 | :param error_callback: optional handler for errors 539 | :param close_callback: optional handler for close event 540 | :return: None 541 | ''' 542 | if error_callback is None: 543 | error_callback = self.__generic_error_callback 544 | 545 | if close_callback is None: 546 | close_callback = self.__generic_close_callback 547 | 548 | self.ws = websocket.WebSocketApp( 549 | self.websockets_endpoint, 550 | on_message=self.__data_message_closure(callback), 551 | on_error=error_callback, 552 | on_close=close_callback 553 | ) 554 | self.ws.on_open = self.__on_open(channel) 555 | self.ws.run_forever() 556 | 557 | def close_ws(self): 558 | ''' 559 | Closes an open web socket 560 | :return: 561 | ''' 562 | if self.ws: 563 | self.ws.close() 564 | else: 565 | raise Exception('Web socket hasn\'t been opened yet') 566 | 567 | def __data_message_closure(self, callback): 568 | # Send through only those messages that actually have any relevant data 569 | def on_message(ws, message): 570 | message = json.loads(message) 571 | if message.get('event') in self.ws_data_events: 572 | callback(json.loads(message.get('data'))) 573 | 574 | return on_message 575 | 576 | @staticmethod 577 | def parse_datetime(string): 578 | ''' 579 | Convenience method that parses datetime objects found in results of the API 580 | :param string: formatted datetime 581 | :return: datetime object parsed from the passed string 582 | ''' 583 | return datetime.strptime(string, BITSTAMP_DATETIME_FORMAT) 584 | --------------------------------------------------------------------------------