├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── exchange_history_example.csv ├── pytest.ini ├── requirements.txt ├── revolut └── __init__.py ├── revolut_bot └── __init__.py ├── revolut_cli.py ├── revolut_transactions.py ├── revolutbot.py ├── setup.cfg ├── setup.py └── test ├── test_revolut.py └── test_revolut_bot.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Specific for this project 107 | exchange_history.csv 108 | 109 | # VSCode 110 | .vscode/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | # command to install dependencies 5 | before_script: 6 | - pip install -r requirements.txt 7 | - pip install python-coveralls 8 | - pip install pytest-cov 9 | install: 10 | - pip install . 11 | # command to run tests 12 | script: 13 | - pytest # or py.test for Python versions 3.5 and below 14 | after_success: 15 | coveralls 16 | deploy: 17 | provider: pypi 18 | user: "thibdct" 19 | password: 20 | secure: "hcma42E/pq5Ixr/V4KUZ0CtkCkpaeldGgiLuSoQOzRaQRjOgPbMud0VNrXcIrl2tB+qiw51fgcti9oQrzogbzJuMEXToPnR8RP3zPdZUmpg8c9usgqIwmplhXj7bNibfQJhxwl0r1gprNbuKlfXrNcLM2uaqHWGCeKCwO+JW7gzIv4Cb9uu6v1mTfhyR/EnTZHzLoqUHgj7dNbLUKTypjT8dfQakhFT3bsJfCTg8gJDvcfdth9r0eFiwnopVbPsXO2OwbMuDuv17T+7ykNfXAL8Q/1HhnUTIIr9ptEBq8CH0aD/+/hzUl3l/69KukyEPn5CtQivLfVtiB4pw3UK/1xMqb70iSugq441lHzwdmqmpg2p5OXT+WDtiuWPUkhezJcu8/irUTk7Iq0DXMKUASbVHSIuH10FILos4P8Og5pF9+2Zzo+rZcENVpDaeBCQ7Lx0KVN6fZzm2Xe5r4JiJWjrQzmzMS71oWu2PbLh0/G+kaZw3DSsAd0VEyu/qcyzmAegdJVo021tdgw/6mmcYUZIp4jYqUsT8KJfInAnBei9NyAOYFIbL+D/RkOBHGjODO44zgCYHPNutKlyW2JbYJ0F48lcuRGBuXDxeqsS8UaWPrCuHDWx0Wp1BNIiifp41TmzHZ/+5Dw9Fl5uZr+Ved3n8tCZqamMgCf+7lVRo5zo=" 21 | on: 22 | tags: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Thibault Ducret 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.txt 3 | include MANIFEST.in -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # revolut-python 2 | 3 | [![Travis](https://img.shields.io/travis/tducret/revolut-python.svg)](https://travis-ci.org/tducret/revolut-python) 4 | [![Coveralls github](https://img.shields.io/coveralls/github/tducret/revolut-python.svg)](https://coveralls.io/github/tducret/revolut-python) 5 | [![PyPI](https://img.shields.io/pypi/v/revolut.svg)](https://pypi.org/project/revolut/) 6 | ![License](https://img.shields.io/github/license/tducret/revolut-python.svg) 7 | 8 | # Description 9 | 10 | Non-official client for the [Revolut Bank](https://www.revolut.com/) 11 | 12 | I wrote a French blog post about it [here](https://www.tducret.com/scraping/2018/08/17/un-robot-d-achat-et-de-vente-de-bitcoins-developpe-en-python.html) 13 | 14 | # Requirements 15 | 16 | - Python 3 17 | - pip3 18 | 19 | # Installation 20 | 21 | ```bash 22 | pip3 install -U revolut 23 | ``` 24 | 25 | ## CLI tool : revolut_cli.py 26 | 27 | ```bash 28 | Usage: revolut_cli.py [OPTIONS] 29 | 30 | Get the account balances on Revolut 31 | 32 | Options: 33 | -d, --device-id TEXT your Revolut token (or set the env var 34 | REVOLUT_DEVICE_ID) 35 | 36 | -t, --token TEXT your Revolut token (or set the env var REVOLUT_TOKEN) 37 | -l, --language TEXT language ("fr" or "en"), for the csv header and 38 | separator 39 | 40 | -a, --account TEXT account name (ex : "EUR CURRENT") to get the balance 41 | for the account 42 | 43 | --version Show the version and exit. 44 | --help Show this message and exit 45 | ``` 46 | 47 | Example output : 48 | 49 | ```csv 50 | Account name,Balance,Currency 51 | EUR CURRENT,100.50,EUR 52 | GBP CURRENT,20.00,GBP 53 | USD CURRENT,0.00,USD 54 | AUD CURRENT,0.00,AUD 55 | BTC CURRENT,0.00123456,BTC 56 | EUR SAVINGS (My vault),10.30,EUR 57 | ``` 58 | 59 | If you don't have a Revolut token yet, the tool will allow you to obtain one. 60 | 61 | ⚠️ **If you don't receive a SMS when trying to get a token, you need to logout from the app on your Smartphone.** 62 | ⚠️ **You may also receive an authentication email instead of a SMS. Take note of the link on the `Authenticate` button. It should look like `https://revolut.com/app/email-authenticate/?scope=login`. You can enter this code in the CLI.** 63 | 64 | ## Pulling transactions 65 | 66 | ```bash 67 | Usage: revolut_transactions.py [OPTIONS] 68 | 69 | Get the account balances on Revolut 70 | 71 | Options: 72 | -d, --device-id TEXT your Revolut token (or set the env var 73 | REVOLUT_DEVICE_ID) 74 | 75 | -t, --token TEXT your Revolut token (or set the env var 76 | REVOLUT_TOKEN) 77 | 78 | -l, --language [en|fr] language for the csv header and separator 79 | -t, --from_date [%Y-%m-%d] transactions lookback date in YYYY-MM-DD 80 | format (ex: "2019-10-26"). Default 30 days 81 | back 82 | 83 | -fmt, --output_format [csv|json] 84 | output format 85 | -r, --reverse reverse the order of the transactions 86 | displayed 87 | 88 | --help Show this message and exit. 89 | ``` 90 | 91 | Example output : 92 | 93 | ```csv 94 | Date-time,Description,Amount,Currency 95 | 08/26/2019 21:31:00,Card Delivery Fee,-59.99,SEK 96 | 09/14/2019 12:50:07,donkey.bike **pending**,0.0,SEK 97 | 09/14/2019 13:03:15,Top-Up by *6458,200.0,SEK 98 | 09/30/2019 16:19:19,Reward user for the invite,200.0,SEK 99 | 10/12/2019 23:51:02,Tiptapp Reservation,-250.0,SEK 100 | ``` 101 | 102 | ## TODO 103 | 104 | - [ ] Document revolutbot.py 105 | - [ ] Create a RaspberryPi Dockerfile for revolutbot (to check if rates grows very often) 106 | - [ ] Improve coverage for revolutbot 107 | -------------------------------------------------------------------------------- /exchange_history_example.csv: -------------------------------------------------------------------------------- 1 | date,hour,from_amount,from_currency,to_amount,to_currency 2 | 01/01/2018,09:00:00,100.00,USD,86.66,EUR 3 | 05/01/2018,19:00:00,86.66,EUR,102.00,USD -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --doctest-modules --cov revolut --cov revolut_bot -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.6.0 2 | click>=6.7 -------------------------------------------------------------------------------- /revolut/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This package allows you to communicate with your Revolut accounts 4 | """ 5 | 6 | import base64 7 | from datetime import datetime 8 | import json 9 | import requests 10 | from urllib.parse import urljoin 11 | 12 | __version__ = '0.1.4' # Should be the same in setup.py 13 | 14 | API_BASE = "https://api.revolut.com" 15 | _URL_GET_ACCOUNTS = API_BASE + "/user/current/wallet" 16 | _URL_GET_TRANSACTIONS_LAST = API_BASE + "/user/current/transactions/last" 17 | _URL_QUOTE = API_BASE + "/quote/" 18 | _URL_EXCHANGE = API_BASE + "/exchange" 19 | _URL_GET_TOKEN_STEP1 = API_BASE + "/signin" 20 | _URL_GET_TOKEN_STEP2 = API_BASE + "/signin/confirm" 21 | 22 | _DEFAULT_TOKEN_FOR_SIGNIN = "QXBwOlM5V1VuU0ZCeTY3Z1dhbjc=" 23 | 24 | _AVAILABLE_CURRENCIES = ["USD", "RON", "HUF", "CZK", "GBP", "CAD", "THB", 25 | "SGD", "CHF", "AUD", "ILS", "DKK", "PLN", "MAD", 26 | "AED", "EUR", "JPY", "ZAR", "NZD", "HKD", "TRY", 27 | "QAR", "NOK", "SEK", "BTC", "ETH", "XRP", "BCH", 28 | "LTC", "SAR", "RUB", "RSD", "MXN", "ISK", "HRK", 29 | "BGN", "XAU", "IDR", "INR", "MYR", "PHP", "XLM", 30 | "EOS", "OMG", "XTZ", "ZRX"] 31 | 32 | _VAULT_ACCOUNT_TYPE = "SAVINGS" 33 | _ACTIVE_ACCOUNT = "ACTIVE" 34 | _TRANSACTION_COMPLETED = "COMPLETED" 35 | _TRANSACTION_FAILED = "FAILED" 36 | _TRANSACTION_PENDING = "PENDING" 37 | _TRANSACTION_REVERTED = "REVERTED" 38 | _TRANSACTION_DECLINED = "DECLINED" 39 | 40 | 41 | # The amounts are stored as integer on Revolut. 42 | # They apply a scale factor depending on the currency 43 | _DEFAULT_SCALE_FACTOR = 100 44 | _SCALE_FACTOR_CURRENCY_DICT = { 45 | "EUR": 100, 46 | "BTC": 100000000, 47 | "ETH": 100000000, 48 | "BCH": 100000000, 49 | "XRP": 100000000, 50 | "LTC": 100000000, 51 | } 52 | 53 | 54 | class Amount: 55 | """ Class to handle the Revolut amount with currencies """ 56 | def __init__(self, currency, revolut_amount=None, real_amount=None): 57 | if currency not in _AVAILABLE_CURRENCIES: 58 | raise KeyError(currency) 59 | self.currency = currency 60 | 61 | if revolut_amount is not None: 62 | if type(revolut_amount) != int: 63 | raise TypeError(type(revolut_amount)) 64 | self.revolut_amount = revolut_amount 65 | self.real_amount = self.get_real_amount() 66 | 67 | elif real_amount is not None: 68 | if type(real_amount) not in [float, int]: 69 | raise TypeError(type(real_amount)) 70 | self.real_amount = float(real_amount) 71 | self.revolut_amount = self.get_revolut_amount() 72 | else: 73 | raise ValueError("revolut_amount or real_amount must be set") 74 | 75 | self.real_amount_str = self.get_real_amount_str() 76 | 77 | def get_real_amount_str(self): 78 | """ Get the real amount with the proper format, without currency """ 79 | if self.currency in ["BTC", "ETH", "BCH", "XRP", "LTC"]: 80 | digits_after_float = 8 81 | else: 82 | digits_after_float = 2 83 | 84 | return("%.*f" % (digits_after_float, self.real_amount)) 85 | 86 | def __str__(self): 87 | return('{} {}'.format(self.real_amount_str, self.currency)) 88 | 89 | def __repr__(self): 90 | return("Amount(real_amount={}, currency='{}')".format( 91 | self.real_amount, self.currency)) 92 | 93 | def get_real_amount(self): 94 | """ Get the real amount from a Revolut amount 95 | >>> a = Amount(revolut_amount=100, currency="EUR") 96 | >>> a.get_real_amount() 97 | 1.0 98 | """ 99 | scale = _SCALE_FACTOR_CURRENCY_DICT.get( 100 | self.currency, _DEFAULT_SCALE_FACTOR) 101 | return float(self.revolut_amount/scale) 102 | 103 | def get_revolut_amount(self): 104 | """ Get the Revolut amount from a real amount 105 | >>> a = Amount(real_amount=1, currency="EUR") 106 | >>> a.get_revolut_amount() 107 | 100 108 | """ 109 | scale = _SCALE_FACTOR_CURRENCY_DICT.get( 110 | self.currency, _DEFAULT_SCALE_FACTOR) 111 | return int(self.real_amount*scale) 112 | 113 | 114 | class Transaction: 115 | """ Class to handle an exchange transaction """ 116 | def __init__(self, from_amount, to_amount, date): 117 | if type(from_amount) != Amount: 118 | raise TypeError 119 | if type(to_amount) != Amount: 120 | raise TypeError 121 | if type(date) != datetime: 122 | raise TypeError 123 | self.from_amount = from_amount 124 | self.to_amount = to_amount 125 | self.date = date 126 | 127 | def __str__(self): 128 | return('({}) {} => {}'.format(self.date.strftime("%d/%m/%Y %H:%M:%S"), 129 | self.from_amount, 130 | self.to_amount)) 131 | 132 | 133 | class Client: 134 | """ Do the requests with the Revolut servers """ 135 | def __init__(self, token, device_id): 136 | self.session = requests.session() 137 | self.session.headers = { 138 | 'Host': 'api.revolut.com', 139 | 'X-Api-Version': '1', 140 | 'X-Client-Version': '6.34.3', 141 | 'X-Device-Id': device_id, 142 | 'User-Agent': 'Revolut/5.5 500500250 (CLI; Android 4.4.2)', 143 | 'Authorization': 'Basic '+token, 144 | } 145 | 146 | def _get(self, url, *, expected_status_code=200, **kwargs): 147 | ret = self.session.get(url=url, **kwargs) 148 | if ret.status_code != expected_status_code: 149 | raise ConnectionError( 150 | 'Status code {} for url {}\n{}'.format( 151 | ret.status_code, url, ret.text)) 152 | return ret 153 | 154 | def _post(self, url, *, expected_status_code=200, **kwargs): 155 | ret = self.session.post(url=url, **kwargs) 156 | if ret.status_code != expected_status_code: 157 | raise ConnectionError( 158 | 'Status code {} for url {}\n{}'.format( 159 | ret.status_code, url, ret.text)) 160 | return ret 161 | 162 | 163 | class Revolut: 164 | def __init__(self, token, device_id): 165 | self.client = Client(token=token, device_id=device_id) 166 | 167 | def get_account_balances(self): 168 | """ Get the account balance for each currency 169 | and returns it as a dict {"balance":XXXX, "currency":XXXX} """ 170 | ret = self.client._get(_URL_GET_ACCOUNTS) 171 | raw_accounts = ret.json() 172 | 173 | account_balances = [] 174 | for raw_account in raw_accounts.get("pockets"): 175 | account_balances.append({ 176 | "balance": raw_account.get("balance"), 177 | "currency": raw_account.get("currency"), 178 | "type": raw_account.get("type"), 179 | "state": raw_account.get("state"), 180 | # name is present when the account is a vault (type = SAVINGS) 181 | "vault_name": raw_account.get("name", ""), 182 | }) 183 | self.account_balances = Accounts(account_balances) 184 | return self.account_balances 185 | 186 | def get_account_transactions(self, from_date=None, to_date=None): 187 | """Get the account transactions.""" 188 | raw_transactions = [] 189 | params = {} 190 | if to_date: 191 | params['to'] = int(to_date.timestamp()) * 1000 192 | if from_date: 193 | params['from'] = int(from_date.timestamp()) * 1000 194 | 195 | while True: 196 | ret = self.client._get(_URL_GET_TRANSACTIONS_LAST, params=params) 197 | ret_transactions = ret.json() 198 | if not ret_transactions: 199 | break 200 | params['to'] = ret_transactions[-1]['startedDate'] 201 | raw_transactions.extend(ret_transactions) 202 | 203 | return AccountTransactions(raw_transactions) 204 | 205 | def get_wallet_id(self): 206 | """ Get the main wallet_id """ 207 | ret = self.client._get(_URL_GET_ACCOUNTS) 208 | raw = ret.json() 209 | return raw.get('id') 210 | 211 | def quote(self, from_amount, to_currency): 212 | if type(from_amount) != Amount: 213 | raise TypeError("from_amount must be with the Amount type") 214 | 215 | if to_currency not in _AVAILABLE_CURRENCIES: 216 | raise KeyError(to_currency) 217 | 218 | url_quote = urljoin(_URL_QUOTE, '{}{}?amount={}&side=SELL'.format( 219 | from_amount.currency, 220 | to_currency, 221 | from_amount.revolut_amount)) 222 | ret = self.client._get(url_quote) 223 | raw_quote = ret.json() 224 | quote_obj = Amount(revolut_amount=raw_quote["to"]["amount"], 225 | currency=to_currency) 226 | return quote_obj 227 | 228 | def exchange(self, from_amount, to_currency, simulate=False): 229 | if type(from_amount) != Amount: 230 | raise TypeError("from_amount must be with the Amount type") 231 | 232 | if to_currency not in _AVAILABLE_CURRENCIES: 233 | raise KeyError(to_currency) 234 | 235 | data = { 236 | "fromCcy": from_amount.currency, 237 | "fromAmount": from_amount.revolut_amount, 238 | "toCcy": to_currency, 239 | "toAmount": None, 240 | } 241 | 242 | if simulate: 243 | # Because we don't want to exchange currencies 244 | # for every test ;) 245 | simu = '[{"account":{"id":"FAKE_ID"},\ 246 | "amount":-1,"balance":0,"completedDate":123456789,\ 247 | "counterpart":{"account":\ 248 | {"id":"FAKE_ID"},\ 249 | "amount":170,"currency":"BTC"},"currency":"EUR",\ 250 | "description":"Exchanged to BTC","direction":"sell",\ 251 | "fee":0,"id":"FAKE_ID",\ 252 | "legId":"FAKE_ID","rate":0.0001751234,\ 253 | "startedDate":123456789,"state":"COMPLETED","type":"EXCHANGE",\ 254 | "updatedDate":123456789},\ 255 | {"account":{"id":"FAKE_ID"},"amount":170,\ 256 | "balance":12345,"completedDate":12345678,"counterpart":\ 257 | {"account":{"id":"FAKE_ID"},\ 258 | "amount":-1,"currency":"EUR"},"currency":"BTC",\ 259 | "description":"Exchanged from EUR","direction":"buy","fee":0,\ 260 | "id":"FAKE_ID",\ 261 | "legId":"FAKE_ID",\ 262 | "rate":5700.0012345,"startedDate":123456789,\ 263 | "state":"COMPLETED","type":"EXCHANGE",\ 264 | "updatedDate":123456789}]' 265 | raw_exchange = json.loads(simu) 266 | else: 267 | ret = self.client._post(_URL_EXCHANGE, json=data) 268 | raw_exchange = ret.json() 269 | 270 | if raw_exchange[0]["state"] == "COMPLETED": 271 | amount = raw_exchange[0]["counterpart"]["amount"] 272 | currency = raw_exchange[0]["counterpart"]["currency"] 273 | exchanged_amount = Amount(revolut_amount=amount, 274 | currency=currency) 275 | exchange_transaction = Transaction(from_amount=from_amount, 276 | to_amount=exchanged_amount, 277 | date=datetime.now()) 278 | else: 279 | raise ConnectionError("Transaction error : %s" % ret.text) 280 | 281 | return exchange_transaction 282 | 283 | 284 | class Account: 285 | """ Class to handle an account """ 286 | def __init__(self, account_type, balance, state, vault_name): 287 | self.account_type = account_type # CURRENT, SAVINGS 288 | self.balance = balance 289 | self.state = state # ACTIVE, INACTIVE 290 | self.vault_name = vault_name 291 | self.name = self.build_account_name() 292 | 293 | def build_account_name(self): 294 | if self.account_type == _VAULT_ACCOUNT_TYPE: 295 | account_name = '{currency} {type} ({vault_name})'.format( 296 | currency=self.balance.currency, 297 | type=self.account_type, 298 | vault_name=self.vault_name) 299 | else: 300 | account_name = '{currency} {type}'.format( 301 | currency=self.balance.currency, 302 | type=self.account_type) 303 | return account_name 304 | 305 | def __str__(self): 306 | return "{name} : {balance}".format(name=self.name, 307 | balance=str(self.balance)) 308 | 309 | 310 | class Accounts: 311 | """ Class to handle the account balances """ 312 | 313 | def __init__(self, account_balances): 314 | self.raw_list = account_balances 315 | self.list = [ 316 | Account( 317 | account_type=account.get("type"), 318 | balance=Amount( 319 | currency=account.get("currency"), 320 | revolut_amount=account.get("balance"), 321 | ), 322 | state=account.get("state"), 323 | vault_name=account.get("vault_name"), 324 | ) 325 | for account in self.raw_list 326 | ] 327 | 328 | def get_account_by_name(self, account_name): 329 | """ Get an account by its name """ 330 | for account in self.list: 331 | if account.name == account_name: 332 | return account 333 | 334 | def __len__(self): 335 | return len(self.list) 336 | 337 | def __getitem__(self, key): 338 | """ Method to access the object as a list 339 | (ex : accounts[1]) """ 340 | return self.list[key] 341 | 342 | def csv(self, lang="fr"): 343 | lang_is_fr = lang == "fr" 344 | if lang_is_fr: 345 | csv_str = "Nom du compte;Solde;Devise" 346 | else: 347 | csv_str = "Account name,Balance,Currency" 348 | 349 | # Europe uses 'comma' as decimal separator, 350 | # so it can't be used as delimiter: 351 | delimiter = ";" if lang_is_fr else "," 352 | 353 | for account in self.list: 354 | if account.state == _ACTIVE_ACCOUNT: # Do not print INACTIVE 355 | csv_str += "\n" + delimiter.join(( 356 | account.name, 357 | account.balance.real_amount_str, 358 | account.balance.currency, 359 | )) 360 | 361 | return csv_str.replace(".", ",") if lang_is_fr else csv_str 362 | 363 | 364 | class AccountTransaction: 365 | """ Class to handle an account transaction """ 366 | def __init__( 367 | self, 368 | transactions_type, 369 | state, 370 | started_date, 371 | completed_date, 372 | amount, 373 | fee, 374 | description, 375 | account_id 376 | ): 377 | self.transactions_type = transactions_type 378 | self.state = state 379 | self.started_date = started_date 380 | self.completed_date = completed_date 381 | self.amount = amount 382 | self.fee = fee 383 | self.description = description 384 | self.account_id = account_id 385 | 386 | def __str__(self): 387 | return "{description}: {amount}".format( 388 | description=self.description, 389 | amount=str(self.amount) 390 | ) 391 | 392 | def get_datetime__str(self, date_format="%d/%m/%Y %H:%M:%S"): 393 | """ 'Pending' transactions do not have 'completed_date' yet 394 | so return 'started_date' instead """ 395 | timestamp = self.completed_date if self.completed_date \ 396 | else self.started_date 397 | # Convert from timestamp to datetime 398 | dt = datetime.fromtimestamp( 399 | timestamp / 1000 400 | ) 401 | dt_str = dt.strftime(date_format) 402 | return dt_str 403 | 404 | def get_description(self): 405 | # Adding 'pending' for processing transactions 406 | description = self.description 407 | if self.state == _TRANSACTION_PENDING: 408 | description = '{} **pending**'.format(description) 409 | return description 410 | 411 | def get_amount__str(self): 412 | """ Convert amount to float and return string representation """ 413 | return str(self.amount.real_amount) 414 | 415 | 416 | class AccountTransactions: 417 | """ Class to handle the account transactions """ 418 | 419 | def __init__(self, account_transactions): 420 | self.raw_list = account_transactions 421 | self.list = [ 422 | AccountTransaction( 423 | transactions_type=transaction.get("type"), 424 | state=transaction.get("state"), 425 | started_date=transaction.get("startedDate"), 426 | completed_date=transaction.get("completedDate"), 427 | amount=Amount(revolut_amount=transaction.get('amount'), 428 | currency=transaction.get('currency')), 429 | fee=transaction.get('fee'), 430 | description=transaction.get('description'), 431 | account_id=transaction.get('account').get('id') 432 | ) 433 | for transaction in self.raw_list 434 | ] 435 | 436 | def __len__(self): 437 | return len(self.list) 438 | 439 | def csv(self, lang="fr", reverse=False): 440 | lang_is_fr = lang == "fr" 441 | if lang_is_fr: 442 | csv_str = "Date-heure (DD/MM/YYYY HH:MM:ss);Description;Montant;Devise" 443 | date_format = "%d/%m/%Y %H:%M:%S" 444 | else: 445 | csv_str = "Date-time (MM/DD/YYYY HH:MM:ss),Description,Amount,Currency" 446 | date_format = "%m/%d/%Y %H:%M:%S" 447 | 448 | # Europe uses 'comma' as decimal separator, 449 | # so it can't be used as delimiter: 450 | delimiter = ";" if lang_is_fr else "," 451 | 452 | # Do not export declined or failed payments 453 | transaction_list = list(reversed(self.list)) if reverse else self.list 454 | for account_transaction in transaction_list: 455 | if account_transaction.state not in [ 456 | _TRANSACTION_DECLINED, 457 | _TRANSACTION_FAILED, 458 | _TRANSACTION_REVERTED 459 | ]: 460 | 461 | csv_str += "\n" + delimiter.join(( 462 | account_transaction.get_datetime__str(date_format), 463 | account_transaction.get_description(), 464 | account_transaction.get_amount__str(), 465 | account_transaction.amount.currency 466 | )) 467 | return csv_str.replace(".", ",") if lang_is_fr else csv_str 468 | 469 | 470 | def get_token_step1(device_id, phone, password, simulate=False): 471 | """ Function to obtain a Revolut token (step 1 : send a code by sms/email) """ 472 | if simulate: 473 | return "SMS" 474 | c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN) 475 | data = {"phone": phone, "password": password} 476 | ret = c._post(_URL_GET_TOKEN_STEP1, json=data) 477 | channel = ret.json().get("channel") 478 | return channel 479 | 480 | 481 | def get_token_step2(device_id, phone, code, simulate=False): 482 | """ Function to obtain a Revolut token (step 2 : with code) """ 483 | if simulate: 484 | # Because we don't want to receive a code through sms 485 | # for every test ;) 486 | simu = '{"user":{"id":"fakeuserid","createdDate":123456789,\ 487 | "address":{"city":"my_city","country":"FR","postcode":"12345",\ 488 | "region":"my_region","streetLine1":"1 rue mon adresse",\ 489 | "streetLine2":"Appt 1"},\"birthDate":[1980,1,1],"firstName":"John",\ 490 | "lastName":"Doe","phone":"+33612345678","email":"myemail@email.com",\ 491 | "emailVerified":false,"state":"ACTIVE","referralCode":"refcode",\ 492 | "kyc":"PASSED","termsVersion":"2018-05-25","underReview":false,\ 493 | "riskAssessed":false,"locale":"en-GB"},"wallet":{"id":"wallet_id",\ 494 | "ref":"12345678","state":"ACTIVE","baseCurrency":"EUR",\ 495 | "topupLimit":3000000,"totalTopup":0,"topupResetDate":123456789,\ 496 | "pockets":[{"id":"pocket_id","type":"CURRENT","state":"ACTIVE",\ 497 | "currency":"EUR","balance":100,"blockedAmount":0,"closed":false,\ 498 | "creditLimit":0}]},"accessToken":"myaccesstoken"}' 499 | raw_get_token = json.loads(simu) 500 | else: 501 | c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN) 502 | code = code.replace("-", "") # If the user would put - 503 | data = {"phone": phone, "code": code} 504 | ret = c._post(_URL_GET_TOKEN_STEP2, json=data) 505 | raw_get_token = ret.json() 506 | return raw_get_token 507 | 508 | 509 | def extract_token(json_response): 510 | user_id = json_response["user"]["id"] 511 | access_token = json_response["accessToken"] 512 | token_to_encode = "{}:{}".format(user_id, access_token).encode("ascii") 513 | # Ascii encoding required by b64encode function : 8 bits char as input 514 | token = base64.b64encode(token_to_encode) 515 | return token.decode("ascii") 516 | 517 | 518 | def signin_biometric(device_id, phone, access_token, selfie_filepath): 519 | files = {"selfie": open(selfie_filepath, "rb")} 520 | c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN) 521 | c.session.auth = (phone, access_token) 522 | res = c._post(API_BASE + "/biometric-signin/selfie", files=files) 523 | biometric_id = res.json()["id"] 524 | res = c._post(API_BASE + "/biometric-signin/confirm/" + biometric_id) 525 | return res.json() 526 | -------------------------------------------------------------------------------- /revolut_bot/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This package allows you to control the Revolut bot 4 | """ 5 | 6 | import csv 7 | from datetime import datetime 8 | import io 9 | 10 | from revolut import Amount, Transaction 11 | 12 | _CSV_COLUMNS = ["date", "hour", "from_amount", "from_currency", 13 | "to_amount", "to_currency"] 14 | 15 | 16 | def csv_to_dict(csv_str, separator=","): 17 | """ From a csv string, returns a list of dictionnaries 18 | >>> csv_to_dict("a,b,c\\n1,2,3") 19 | [{'a': '1', 'b': '2', 'c': '3'}] 20 | >>> csv_to_dict("a,b,c\\n1,2,3\\n4,5,6") 21 | [{'a': '1', 'b': '2', 'c': '3'}, {'a': '4', 'b': '5', 'c': '6'}] 22 | >>> csv_to_dict("a;b;c\\n1;2;3", separator=";") 23 | [{'a': '1', 'b': '2', 'c': '3'}]""" 24 | reader = csv.DictReader(io.StringIO(csv_str), delimiter=separator) 25 | 26 | # By default, DictReader returns OrderedDict => convert to dict: 27 | return list(map(dict, reader)) 28 | 29 | 30 | def append_dict_to_csv(filename, dict_obj, separator=",", 31 | col_names=_CSV_COLUMNS): 32 | """ Append a dict object, to a csv file """ 33 | with open(filename, 'a', newline='\n') as csvfile: 34 | writer = csv.DictWriter(csvfile, 35 | delimiter=separator, 36 | fieldnames=col_names, 37 | lineterminator='\n') # To avoid '^M' 38 | writer.writerow(dict_obj) 39 | 40 | 41 | def convert_Transaction_to_dict(transaction_obj): 42 | return { 43 | "date": transaction_obj.date.strftime("%d/%m/%Y"), 44 | "hour": transaction_obj.date.strftime("%H:%M:%S"), 45 | "from_amount": transaction_obj.from_amount.real_amount, 46 | "from_currency": transaction_obj.from_amount.currency, 47 | "to_amount": transaction_obj.to_amount.real_amount, 48 | "to_currency": transaction_obj.to_amount.currency, 49 | } 50 | 51 | 52 | def update_historyfile(filename, exchange_transaction): 53 | """ Update the history file with an exchange transaction """ 54 | tr_dict = convert_Transaction_to_dict(transaction_obj=exchange_transaction) 55 | append_dict_to_csv(filename=filename, dict_obj=tr_dict) 56 | 57 | 58 | def read_file_to_str(filename): 59 | with open(filename, 'r') as f: 60 | ret_str = f.read() 61 | return ret_str 62 | 63 | 64 | def get_last_transactions_from_csv(filename="exchange_history.csv", 65 | separator=","): 66 | csv_str = read_file_to_str(filename=filename) 67 | last_transactions = csv_to_dict(csv_str=csv_str, separator=separator) 68 | 69 | return list(map(dict_transaction_to_Transaction, last_transactions)) 70 | 71 | 72 | def dict_transaction_to_Transaction(tr_dict): 73 | """ Converts a transaction dictionnary to a Transaction object """ 74 | if set(tr_dict) != set(_CSV_COLUMNS): 75 | raise TypeError("Columns expected : {}\n{} received".format( 76 | _CSV_COLUMNS, list(tr_dict))) 77 | str_date = "{} {}".format(tr_dict["date"], 78 | tr_dict["hour"]) 79 | tr = Transaction(from_amount=Amount( 80 | real_amount=float(tr_dict["from_amount"]), 81 | currency=tr_dict["from_currency"]), 82 | to_amount=Amount( 83 | real_amount=float(tr_dict["to_amount"]), 84 | currency=tr_dict["to_currency"]), 85 | date=datetime.strptime(str_date, "%d/%m/%Y %H:%M:%S")) 86 | return tr 87 | 88 | 89 | def get_amount_with_margin(amount, percent_margin): 90 | """ Returns the amount with a margin 91 | >>> print(get_amount_with_margin(amount=Amount(real_amount=100,\ 92 | currency="EUR"), percent_margin=1)) 93 | 101.00 EUR 94 | """ 95 | if type(amount) != Amount: 96 | raise TypeError 97 | if type(percent_margin) not in [float, int]: 98 | raise TypeError 99 | margin = percent_margin/100 100 | 101 | amount_with_margin = amount.real_amount * (1 + margin) 102 | 103 | return Amount(real_amount=amount_with_margin, currency=amount.currency) 104 | -------------------------------------------------------------------------------- /revolut_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import click 5 | from getpass import getpass 6 | import uuid 7 | import sys 8 | 9 | from revolut import Revolut, __version__, get_token_step1, get_token_step2, signin_biometric, extract_token 10 | 11 | # Usage : revolut_cli.py --help 12 | 13 | @click.command() 14 | @click.option( 15 | '--device-id', '-d', 16 | envvar="REVOLUT_DEVICE_ID", 17 | type=str, 18 | help='your Revolut token (or set the env var REVOLUT_DEVICE_ID)', 19 | ) 20 | @click.option( 21 | '--token', '-t', 22 | envvar="REVOLUT_TOKEN", 23 | type=str, 24 | help='your Revolut token (or set the env var REVOLUT_TOKEN)', 25 | ) 26 | @click.option( 27 | '--language', '-l', 28 | type=str, 29 | help='language ("fr" or "en"), for the csv header and separator', 30 | default='fr' 31 | ) 32 | @click.option( 33 | '--account', '-a', 34 | type=str, 35 | help='account name (ex : "EUR CURRENT") to get the balance for the account' 36 | ) 37 | @click.version_option( 38 | version=__version__, 39 | message='%(prog)s, based on [revolut] package version %(version)s' 40 | ) 41 | def main(device_id, token, language, account): 42 | """ Get the account balances on Revolut """ 43 | 44 | if token is None: 45 | print("You don't seem to have a Revolut token") 46 | answer = input("Would you like to generate a token [yes/no]? ") 47 | selection(answer) 48 | device_id = 'cli_{}'.format(uuid.getnode()) # Unique id for a machine 49 | while token is None: 50 | try: 51 | token = get_token(device_id=device_id) 52 | except Exception as e: 53 | login_error_handler(e) 54 | 55 | if device_id is None: 56 | device_id = 'revolut_cli' # For retro-compatibility 57 | rev = Revolut(device_id=device_id, token=token) 58 | account_balances = rev.get_account_balances() 59 | if account: 60 | print(account_balances.get_account_by_name(account).balance) 61 | else: 62 | print(account_balances.csv(lang=language)) 63 | 64 | 65 | def get_token(device_id): 66 | phone = input( 67 | "What is your mobile phone (used with your Revolut " 68 | "account) [ex : +33612345678] ? ") 69 | password = getpass( 70 | "What is your Revolut app password [ex: 1234] ? ") 71 | verification_channel = get_token_step1( 72 | device_id=device_id, 73 | phone=phone, 74 | password=password 75 | ) 76 | 77 | if verification_channel.upper() == "EMAIL": 78 | print() 79 | print("Your verification code has been sent by email.") 80 | print("Take note of the link on the **Authenticate** button.") 81 | print("It should look like https://revolut.com/app/email-authenticate/?scope=login") 82 | 83 | code = input( 84 | "Please enter the 6 digit code you received by {} " 85 | "[ex : 123456] : ".format(verification_channel) 86 | ) 87 | 88 | response = get_token_step2( 89 | device_id=device_id, 90 | phone=phone, 91 | code=code, 92 | ) 93 | 94 | if "thirdFactorAuthAccessToken" in response: 95 | access_token = response["thirdFactorAuthAccessToken"] 96 | print() 97 | print("Selfie 3rd factor authentication was requested.") 98 | selfie_filepath = input( 99 | "Provide a selfie image file path (800x600) [ex : selfie.png] ") 100 | response = signin_biometric( 101 | device_id, phone, access_token, selfie_filepath) 102 | 103 | token = extract_token(response) 104 | token_str = "Your token is {}".format(token) 105 | device_id_str = "Your device id is {}".format(device_id) 106 | 107 | dashes = len(token_str) * "-" 108 | print("\n".join(("", dashes, token_str, device_id_str, dashes, ""))) 109 | print("You may use it with the --token of this command or set the " 110 | "environment variable in your ~/.bash_profile or ~/.bash_rc, " 111 | "for example :", end="\n\n") 112 | print(">>> revolut_cli.py --device-id={} --token={}".format(device_id, token)) 113 | print("or") 114 | print('echo "export REVOLUT_DEVICE_ID={}" >> ~/.bash_profile' 115 | .format(device_id)) 116 | print('echo "export REVOLUT_TOKEN={}" >> ~/.bash_profile' 117 | .format(token)) 118 | return token 119 | 120 | def selection(user_input): 121 | yes_list = ["yes", "ye", "ya", "y", "yeah"] 122 | no_list = ["no", "nah", "nope", "n"] 123 | 124 | user_input = user_input.lower() 125 | if user_input in yes_list: 126 | return 127 | elif user_input in no_list: 128 | print("Thanks for using the Revolut desktop app!") 129 | sys.exit() 130 | else: 131 | print("Input not recognized, expecting 'yes' or 'no") 132 | sys.exit() 133 | 134 | def login_error_handler(error): 135 | error_list = { 136 | "The string supplied did not seem to be a phone number" : \ 137 | "Please check the supplied number and try again.", 138 | "Status code 401" : "Incorrect login details, please try again.", 139 | "phone is empty" : "You did not enter a phone number..." 140 | } 141 | error = str(error) 142 | for entry in error_list: 143 | if entry in error: 144 | print(error_list.get(entry)) 145 | return 146 | print("An unknown error has occurred: {}".format(error)) 147 | return 148 | 149 | if __name__ == "__main__": 150 | main() 151 | -------------------------------------------------------------------------------- /revolut_transactions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import click 5 | import json 6 | import os 7 | 8 | from datetime import datetime 9 | from datetime import timedelta 10 | 11 | from revolut import Revolut, __version__ 12 | 13 | 14 | @click.command() 15 | @click.option( 16 | '--device-id', '-d', 17 | envvar="REVOLUT_DEVICE_ID", 18 | type=str, 19 | help='your Revolut token (or set the env var REVOLUT_DEVICE_ID)', 20 | default='revolut_cli', 21 | ) 22 | @click.option( 23 | '--token', '-t', 24 | envvar="REVOLUT_TOKEN", 25 | type=str, 26 | help='your Revolut token (or set the env var REVOLUT_TOKEN)', 27 | ) 28 | @click.option( 29 | '--language', '-l', 30 | type=click.Choice(['en', 'fr']), 31 | help='language for the csv header and separator', 32 | default='fr' 33 | ) 34 | @click.option( 35 | '--from_date', '-t', 36 | type=click.DateTime(formats=["%Y-%m-%d"]), 37 | help='transactions lookback date in YYYY-MM-DD format (ex: "2019-10-26"). Default 30 days back', 38 | default=(datetime.now()-timedelta(days=30)).strftime("%Y-%m-%d") 39 | ) 40 | @click.option( 41 | '--output_format', '-fmt', 42 | type=click.Choice(['csv', 'json']), 43 | help="output format", 44 | default='csv', 45 | ) 46 | @click.option( 47 | '--reverse', '-r', 48 | is_flag=True, 49 | help='reverse the order of the transactions displayed', 50 | ) 51 | def main(device_id, token, language, from_date, output_format, reverse): 52 | """ Get the account balances on Revolut """ 53 | if token is None: 54 | print("You don't seem to have a Revolut token. Use 'revolut_cli' to obtain one") 55 | exit(1) 56 | 57 | rev = Revolut(device_id=device_id, token=token) 58 | account_transactions = rev.get_account_transactions(from_date) 59 | if output_format == 'csv': 60 | print(account_transactions.csv(lang=language, reverse=reverse)) 61 | elif output_format == 'json': 62 | transactions = account_transactions.raw_list 63 | if reverse: 64 | transactions = reversed(transactions) 65 | print(json.dumps(transactions)) 66 | else: 67 | print("output format {!r} not implemented".format(output_format)) 68 | exit(1) 69 | 70 | 71 | if __name__ == "__main__": 72 | main() 73 | -------------------------------------------------------------------------------- /revolutbot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import click 4 | from revolut import Revolut, __version__ 5 | import revolut_bot 6 | import sys 7 | 8 | # Usage : revolutbot.py --help 9 | 10 | _BOT_PERCENT_MARGIN = 1 # at least 1% benefit to exchange 11 | _VERBOSE_MODE = False # can be changed with --verbose parameter 12 | 13 | _RETURN_CODE_BUY = 0 14 | _RETURN_CODE_DO_NOT_BUY = 1 15 | _RETURN_CODE_ERROR = 2 16 | 17 | 18 | @click.command() 19 | @click.option( 20 | '--device-id', '-d', 21 | envvar="REVOLUT_DEVICE_ID", 22 | type=str, 23 | help='your Revolut token (or set the env var REVOLUT_DEVICE_ID)', 24 | default='revolut_cli', 25 | ) 26 | @click.option( 27 | '--token', '-t', 28 | envvar="REVOLUT_TOKEN", 29 | type=str, 30 | help='your Revolut token (or set the env var REVOLUT_TOKEN)', 31 | ) 32 | @click.option( 33 | '--historyfile', '-f', 34 | type=str, 35 | help='csv file with the exchange history', 36 | required=True, 37 | ) 38 | @click.option( 39 | '--forceexchange', 40 | is_flag=True, 41 | help='force the exchange, ignoring the bot decision (you may lose money)', 42 | ) 43 | @click.option( 44 | '--simulate', '-s', 45 | is_flag=True, 46 | help='do not really exchange your money if set', 47 | ) 48 | @click.option( 49 | '--verbose', '-v', 50 | is_flag=True, 51 | help='verbose mode', 52 | ) 53 | @click.version_option( 54 | version=__version__, 55 | message='%(prog)s, based on [revolut] package version %(version)s' 56 | ) 57 | def main(device_id, token, simulate, historyfile, verbose, forceexchange): 58 | if token is None: 59 | print("You don't seem to have a Revolut token") 60 | print("Please execute revolut_cli.py first to get one") 61 | sys.exit(_RETURN_CODE_ERROR) 62 | 63 | global _VERBOSE_MODE 64 | _VERBOSE_MODE = verbose 65 | rev = Revolut(device_id=device_id, token=token) 66 | 67 | to_buy_or_not_to_buy(revolut=rev, 68 | simulate=simulate, 69 | filename=historyfile, 70 | forceexchange=forceexchange) 71 | 72 | 73 | def log(log_str=""): 74 | if _VERBOSE_MODE: 75 | print(log_str) 76 | 77 | 78 | def to_buy_or_not_to_buy(revolut, simulate, filename, forceexchange): 79 | percent_margin = _BOT_PERCENT_MARGIN 80 | 81 | last_transactions = revolut_bot.get_last_transactions_from_csv( 82 | filename=filename) 83 | last_tr = last_transactions[-1] # The last transaction 84 | log() 85 | log("Last transaction : {}\n".format(last_tr)) 86 | previous_currency = last_tr.from_amount.currency 87 | 88 | current_balance = last_tr.to_amount # How much we currently have 89 | 90 | current_balance_in_other_currency = revolut.quote( 91 | from_amount=current_balance, 92 | to_currency=previous_currency) 93 | log("Today : {} in {} : {}\n".format( 94 | current_balance, previous_currency, current_balance_in_other_currency)) 95 | 96 | last_sell = last_tr.from_amount # How much did it cost before selling 97 | 98 | last_sell_plus_margin = revolut_bot.get_amount_with_margin( 99 | amount=last_sell, 100 | percent_margin=percent_margin) 101 | log("Min value to buy : {} + {}% (margin) = {}\n".format( 102 | last_sell, 103 | percent_margin, 104 | last_sell_plus_margin)) 105 | 106 | buy_condition = current_balance_in_other_currency.real_amount > \ 107 | last_sell_plus_margin.real_amount 108 | 109 | if buy_condition or forceexchange: 110 | if buy_condition: 111 | log("{} > {}".format( 112 | current_balance_in_other_currency, 113 | last_sell_plus_margin)) 114 | elif forceexchange: 115 | log("/!\\ Force exchange option enabled") 116 | log("=> BUY") 117 | 118 | if simulate: 119 | log("(Simulation mode : do not really buy)") 120 | else: 121 | exchange_transaction = revolut.exchange( 122 | from_amount=current_balance, 123 | to_currency=previous_currency, 124 | simulate=simulate) 125 | log("{} bought".format(exchange_transaction.to_amount.real_amount)) 126 | log("Update history file : {}".format(filename)) 127 | revolut_bot.update_historyfile( 128 | filename=filename, 129 | exchange_transaction=exchange_transaction) 130 | sys.exit(_RETURN_CODE_BUY) 131 | else: 132 | log("{} < {}".format( 133 | current_balance_in_other_currency, 134 | last_sell_plus_margin)) 135 | log("=> DO NOT BUY") 136 | sys.exit(_RETURN_CODE_DO_NOT_BUY) 137 | 138 | 139 | if __name__ == "__main__": 140 | main() 141 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | [aliases] 4 | test=pytest -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from pathlib import Path 3 | from setuptools import setup 4 | 5 | HERE = Path(__file__).parent 6 | reqs_path = HERE / 'requirements.txt' 7 | with open(reqs_path) as reqs_file: 8 | requirements = reqs_file.read().splitlines() 9 | 10 | # Based on http://peterdowns.com/posts/first-time-with-pypi.html 11 | 12 | __version__ = '0.1.4' # Should match with __init.py__ 13 | _NAME = 'revolut' 14 | _PACKAGE_LIST = ['revolut', 'revolut_bot'] 15 | _URL_GITHUB = 'https://github.com/tducret/revolut-python' 16 | _DESCRIPTION = 'Package to get account balances and do operations on Revolut' 17 | _MOTS_CLES = ['api', 'revolut', 'bank', 'parsing', 'cli', 18 | 'python-wrapper', 'scraping', 'scraper', 'parser'] 19 | _SCRIPTS = ['revolut_cli.py', 'revolutbot.py', 'revolut_transactions.py'] 20 | # To delete here + 'scripts' dans setup() 21 | # if no command is used in the package 22 | 23 | setup( 24 | name=_NAME, 25 | packages=_PACKAGE_LIST, 26 | package_data={}, 27 | scripts=_SCRIPTS, 28 | version=__version__, 29 | license='MIT', 30 | platforms='Posix; MacOS X', 31 | description=_DESCRIPTION, 32 | long_description=_DESCRIPTION, 33 | author='Thibault Ducret', 34 | author_email='thibault.ducret@gmail.com', 35 | url=_URL_GITHUB, 36 | download_url='%s/tarball/%s' % (_URL_GITHUB, __version__), 37 | keywords=_MOTS_CLES, 38 | setup_requires=requirements, 39 | install_requires=requirements, 40 | classifiers=['Programming Language :: Python :: 3'], 41 | python_requires='>=3', 42 | tests_require=['pytest'], 43 | ) 44 | 45 | # ------------------------------------------ 46 | # To upload a new version on pypi 47 | # ------------------------------------------ 48 | # Make sure everything was pushed (with a git status) 49 | # (or git commit --am "Comment" and git push) 50 | # export VERSION=0.1.4; git tag $VERSION -m "Update X-Client-Version + allow passing a selfie when Third factor authentication is required"; git push --tags 51 | 52 | # If you need to delete a tag 53 | # git push --delete origin $VERSION; git tag -d $VERSION 54 | -------------------------------------------------------------------------------- /test/test_revolut.py: -------------------------------------------------------------------------------- 1 | from revolut import Amount, Accounts, Account, Transaction, Revolut, Client 2 | from revolut import get_token_step1, get_token_step2 3 | import pytest 4 | import os 5 | 6 | # To be tested with : python -m pytest -vs test/test_revolut.py 7 | 8 | _AVAILABLE_CURRENCIES = ["USD", "RON", "HUF", "CZK", "GBP", "CAD", "THB", 9 | "SGD", "CHF", "AUD", "ILS", "DKK", "PLN", "MAD", 10 | "AED", "EUR", "JPY", "ZAR", "NZD", "HKD", "TRY", 11 | "QAR", "NOK", "SEK", "BTC", "ETH", "XRP", "BCH", 12 | "LTC"] 13 | 14 | _DEVICE_ID = os.environ.get('REVOLUT_DEVICE_ID') 15 | _TOKEN = os.environ.get('REVOLUT_TOKEN') 16 | 17 | _SIMU_EXCHANGE = True # True = Do not execute a real currency exchange 18 | _SIMU_GET_TOKEN = True # True = Do not try to get a real token 19 | # (sms reception involved) 20 | if _SIMU_GET_TOKEN is True: 21 | _PHONE = "+33612345678" 22 | _PASSWORD = "1234" 23 | else: 24 | _PHONE = os.environ.get('REVOLUT_PHONE') 25 | _PASSWORD = os.environ.get('REVOLUT_TOKEN') 26 | 27 | assert _DEVICE_ID 28 | assert _TOKEN 29 | revolut = Revolut(token=_TOKEN, device_id=_DEVICE_ID) 30 | 31 | 32 | def test_class_Amount(): 33 | amount = Amount(revolut_amount=100, currency="EUR") 34 | assert amount.real_amount == 1 35 | assert str(amount) == "1.00 EUR" 36 | 37 | amount = Amount(real_amount=1, currency="EUR") 38 | assert amount.revolut_amount == 100 39 | assert str(amount) == "1.00 EUR" 40 | 41 | amount = Amount(revolut_amount=100000000, currency="BTC") 42 | assert amount.real_amount == 1 43 | assert str(amount) == "1.00000000 BTC" 44 | 45 | 46 | def test_class_Amount_errors(): 47 | with pytest.raises(KeyError): 48 | Amount(revolut_amount=100, currency="UNKNOWN") 49 | 50 | with pytest.raises(TypeError): 51 | Amount(revolut_amount="abc", currency="BTC") 52 | 53 | with pytest.raises(TypeError): 54 | Amount(real_amount="def", currency="EUR") 55 | 56 | with pytest.raises(ValueError): 57 | Amount(currency="BTC") 58 | 59 | 60 | def test_get_account_balances(): 61 | accounts = revolut.get_account_balances() 62 | assert len(accounts) > 0 63 | 64 | print() 65 | print('[{} accounts]'.format(len(accounts))) 66 | 67 | for account in accounts: 68 | assert type(account) == Account 69 | print('{}'.format(account)) 70 | 71 | 72 | def test_quote(): 73 | eur_to_btc = Amount(real_amount=5508.85, currency="EUR") 74 | quote_eur_btc = revolut.quote(from_amount=eur_to_btc, to_currency="BTC") 75 | assert type(quote_eur_btc) == Amount 76 | print() 77 | print('{} => {}'.format(eur_to_btc, eur_to_btc)) 78 | 79 | btc_to_eur = Amount(real_amount=1, currency="BTC") 80 | quote_btc_eur = revolut.quote(from_amount=btc_to_eur, to_currency="EUR") 81 | assert type(quote_btc_eur) == Amount 82 | print('{} => {}'.format(btc_to_eur, quote_btc_eur)) 83 | 84 | 85 | def test_quote_commission(): 86 | currency1 = "USD" 87 | currency2 = "EUR" 88 | step1 = Amount(real_amount=5000, currency=currency1) 89 | step2 = revolut.quote(from_amount=step1, to_currency=currency2) 90 | step3 = revolut.quote(from_amount=step2, to_currency=currency1) 91 | print() 92 | comm_rate = 1-(step3.real_amount/step1.real_amount) 93 | print('Commission {}<->{} {:.2%}'.format(currency1, currency2, comm_rate)) 94 | assert comm_rate < 0.05 95 | 96 | 97 | def test_quote_errors(): 98 | with pytest.raises(TypeError): 99 | revolut.quote(from_amount="100 EUR", to_currency="EUR") 100 | 101 | with pytest.raises(TypeError): 102 | revolut.quote(from_amount=100, to_currency="EUR") 103 | 104 | with pytest.raises(KeyError): 105 | eur_to_unknown = Amount(real_amount=100, currency="EUR") 106 | revolut.quote(from_amount=eur_to_unknown, to_currency="UNKNOWN") 107 | 108 | 109 | def test_exchange(): 110 | eur_to_btc = Amount(real_amount=0.01, currency="EUR") 111 | exchange_transaction = revolut.exchange(from_amount=eur_to_btc, 112 | to_currency="BTC", 113 | simulate=_SIMU_EXCHANGE) 114 | assert type(exchange_transaction) == Transaction 115 | print() 116 | print('{} => {} : exchange OK'.format(eur_to_btc, exchange_transaction)) 117 | 118 | 119 | def test_exchange_errors(): 120 | with pytest.raises(TypeError): 121 | revolut.exchange(from_amount="100 EUR", to_currency="EUR") 122 | 123 | with pytest.raises(TypeError): 124 | revolut.exchange(from_amount=100, to_currency="EUR") 125 | 126 | with pytest.raises(KeyError): 127 | eur_to_unknown = Amount(real_amount=100, currency="EUR") 128 | revolut.exchange(from_amount=eur_to_unknown, to_currency="UNKNOWN") 129 | 130 | with pytest.raises(ConnectionError): 131 | # Should return a status code 400 132 | one_million_euros = Amount(real_amount=1000000, currency="EUR") 133 | revolut.exchange(from_amount=one_million_euros, to_currency="BTC") 134 | 135 | with pytest.raises(ConnectionError): 136 | # Should return a status code 422 for insufficient funds 137 | ten_thousands_euros = Amount(real_amount=10000, currency="AUD") 138 | revolut.exchange(from_amount=ten_thousands_euros, to_currency="BTC") 139 | 140 | with pytest.raises(ConnectionError): 141 | # Should return a status code 400 because from and to currencies 142 | # must be different 143 | ten_thousands_euros = Amount(real_amount=1, currency="EUR") 144 | revolut.exchange(from_amount=ten_thousands_euros, to_currency="EUR") 145 | 146 | with pytest.raises(ConnectionError): 147 | # Should return a status code 400 because the amount must be > 0 148 | ten_thousands_euros = Amount(real_amount=1, currency="EUR") 149 | revolut.exchange(from_amount=ten_thousands_euros, to_currency="EUR") 150 | 151 | 152 | def test_class_account(): 153 | account = Account(account_type="CURRENT", 154 | balance=Amount(real_amount=200.85, currency="EUR"), 155 | state="ACTIVE", 156 | vault_name="") 157 | assert account.name == "EUR CURRENT" 158 | assert str(account) == "EUR CURRENT : 200.85 EUR" 159 | 160 | vault = Account(account_type="SAVINGS", 161 | balance=Amount(real_amount=150.35, currency="USD"), 162 | state="ACTIVE", 163 | vault_name="My vault") 164 | assert vault.name == "USD SAVINGS (My vault)" 165 | assert str(vault) == "USD SAVINGS (My vault) : 150.35 USD" 166 | 167 | 168 | def test_class_accounts(): 169 | account_dicts = [{"balance": 10000, "currency": "EUR", 170 | "type": "CURRENT", "vault_name": "", "state": "ACTIVE"}, 171 | {"balance": 550, "currency": "USD", 172 | "type": "CURRENT", "vault_name": "", "state": "ACTIVE"}, 173 | {"balance": 0, "currency": "GBP", "vault_name": "", 174 | "type": "CURRENT", "state": "INACTIVE"}, 175 | {"balance": 1000000, "currency": "BTC", 176 | "type": "CURRENT", "vault_name": "", "state": "ACTIVE"}, 177 | {"balance": 1000, "currency": "EUR", 178 | "vault_name": "My vault", 179 | "type": "SAVINGS", "state": "ACTIVE"}] 180 | 181 | accounts = Accounts(account_dicts) 182 | assert len(accounts.list) == 5 183 | assert type(accounts[0]) == Account 184 | 185 | csv_fr = accounts.csv(lang="fr") 186 | print(csv_fr) 187 | assert csv_fr == "Nom du compte;Solde;Devise\n\ 188 | EUR CURRENT;100,00;EUR\n\ 189 | USD CURRENT;5,50;USD\n\ 190 | BTC CURRENT;0,01000000;BTC\n\ 191 | EUR SAVINGS (My vault);10,00;EUR" 192 | 193 | csv_en = accounts.csv(lang="en") 194 | print(csv_en) 195 | assert csv_en == "Account name,Balance,Currency\n\ 196 | EUR CURRENT,100.00,EUR\n\ 197 | USD CURRENT,5.50,USD\n\ 198 | BTC CURRENT,0.01000000,BTC\n\ 199 | EUR SAVINGS (My vault),10.00,EUR" 200 | 201 | account = accounts.get_account_by_name("BTC CURRENT") 202 | print(account) 203 | assert type(account) == Account 204 | 205 | account = accounts.get_account_by_name("Not existing") 206 | assert account is None 207 | 208 | 209 | def test_client_errors(): 210 | with pytest.raises(ConnectionError): 211 | c = Client(device_id="unknown", token="unknown") 212 | c._get("https://api.revolut.com/unknown_page") 213 | 214 | 215 | def test_get_token(capsys): 216 | _DEVICE_ID_TEST = "cli" 217 | get_token_step1(device_id=_DEVICE_ID_TEST, 218 | phone=_PHONE, 219 | password=_PASSWORD, 220 | simulate=_SIMU_GET_TOKEN) 221 | 222 | if _SIMU_GET_TOKEN is True: 223 | code = "123456" 224 | else: 225 | with capsys.disabled(): 226 | print() 227 | code = input( 228 | "Please enter the sms code sent to {} : ".format(_PHONE)) 229 | 230 | token = get_token_step2(device_id=_DEVICE_ID_TEST, 231 | phone=_PHONE, 232 | code=code, 233 | simulate=_SIMU_GET_TOKEN) 234 | assert token != "" 235 | print() 236 | print("Your token is {}".format(token)) 237 | 238 | if _SIMU_GET_TOKEN is not True: 239 | new_revolut = Revolut(token=token, device_id=_DEVICE_ID_TEST) 240 | 241 | accounts = new_revolut.get_account_balances() 242 | assert len(accounts) > 0 243 | 244 | print() 245 | print('[{} accounts]'.format(len(accounts))) 246 | 247 | for account in accounts: 248 | assert type(account) == Amount 249 | print('{}'.format(account)) 250 | -------------------------------------------------------------------------------- /test/test_revolut_bot.py: -------------------------------------------------------------------------------- 1 | import revolut_bot 2 | from revolut import Amount, Transaction 3 | from datetime import datetime 4 | import pytest 5 | import os 6 | 7 | # To be tested with : python -m pytest -vs test/test_revolut_bot.py 8 | 9 | _DEVICE_ID = os.environ.get('REVOLUT_DEVICE_ID') 10 | _TOKEN = os.environ.get('REVOLUT_TOKEN') 11 | 12 | 13 | def test_class_Transaction(): 14 | transaction = Transaction( 15 | from_amount=Amount(real_amount=10, currency="USD"), 16 | to_amount=Amount(real_amount=8.66, currency="EUR"), 17 | date=datetime.strptime("10/07/18 16:30", "%d/%m/%y %H:%M")) 18 | 19 | assert type(transaction) == Transaction 20 | assert str(transaction) == "(10/07/2018 16:30:00) 10.00 USD => 8.66 EUR" 21 | print() 22 | print(transaction) 23 | 24 | 25 | def test_class_Transaction_errors(): 26 | with pytest.raises(TypeError): 27 | Transaction(from_amount="10 USD", 28 | to_amount=Amount(real_amount=8.66, currency="EUR"), 29 | date=datetime.strptime("10/07/18 16:30", "%d/%m/%y %H:%M")) 30 | 31 | with pytest.raises(TypeError): 32 | Transaction(from_amount=Amount(real_amount=10, currency="USD"), 33 | to_amount="8.66 EUR", 34 | date=datetime.strptime("10/07/18 16:30", "%d/%m/%y %H:%M")) 35 | 36 | with pytest.raises(TypeError): 37 | Transaction(from_amount=Amount(real_amount=10, currency="USD"), 38 | to_amount=Amount(real_amount=8.66, currency="EUR"), 39 | date="10/07/18 16:30") 40 | 41 | 42 | def test_get_last_transactions_from_csv(): 43 | last_transactions = revolut_bot.get_last_transactions_from_csv( 44 | filename="exchange_history_example.csv") 45 | assert type(last_transactions) == list 46 | last_tr = last_transactions[-1] 47 | assert type(last_tr) == Transaction 48 | print() 49 | for tr in last_transactions: 50 | print(tr) 51 | 52 | 53 | def test_get_last_transactions_from_csv_errors(): 54 | with pytest.raises(FileNotFoundError): 55 | revolut_bot.get_last_transactions_from_csv(filename="unknown_file.csv") 56 | 57 | with pytest.raises(TypeError): 58 | revolut_bot.get_last_transactions_from_csv( 59 | filename="exchange_history_example.csv", 60 | separator="BAD_SEPARATOR") 61 | 62 | 63 | def test_csv_functions(): 64 | _TEST_CSV_FILENAME = "test_file.csv" 65 | with open(_TEST_CSV_FILENAME, "w") as f: 66 | f.write("a,b,c\n") 67 | f.write("1,2,3\n") 68 | f.write("4,5,6\n") 69 | csv_str = revolut_bot.read_file_to_str(_TEST_CSV_FILENAME) 70 | assert csv_str == "a,b,c\n1,2,3\n4,5,6\n" 71 | 72 | csv_dict = revolut_bot.csv_to_dict(csv_str) 73 | assert csv_dict == [{"a": "1", "b": "2", "c": "3"}, 74 | {"a": "4", "b": "5", "c": "6"}] 75 | 76 | new_csv_dict = {"a": "7", "b": "8", "c": "9"} 77 | revolut_bot.append_dict_to_csv( 78 | filename=_TEST_CSV_FILENAME, 79 | dict_obj=new_csv_dict, separator=",", 80 | col_names=["a", "b", "c"]) 81 | csv_str = revolut_bot.read_file_to_str(_TEST_CSV_FILENAME) 82 | assert csv_str == "a,b,c\n1,2,3\n4,5,6\n7,8,9\n" 83 | 84 | csv_dict = revolut_bot.csv_to_dict(csv_str) 85 | assert csv_dict == [{"a": "1", "b": "2", "c": "3"}, 86 | {"a": "4", "b": "5", "c": "6"}, 87 | {"a": "7", "b": "8", "c": "9"}] 88 | 89 | os.remove(_TEST_CSV_FILENAME) 90 | 91 | 92 | def test_get_amount_with_margin(): 93 | amount = Amount(real_amount=10, currency="USD") 94 | percent_margin = 1 # 1% 95 | amount_with_margin = revolut_bot.get_amount_with_margin( 96 | amount=amount, 97 | percent_margin=percent_margin) 98 | assert type(amount_with_margin) == Amount 99 | assert amount_with_margin.real_amount == 10.1 100 | assert amount_with_margin.currency == "USD" 101 | 102 | 103 | def test_get_amount_with_margin_errors(): 104 | with pytest.raises(TypeError): 105 | revolut_bot.get_amount_with_margin(amount=10, percent_margin=1) 106 | with pytest.raises(TypeError): 107 | revolut_bot.get_amount_with_margin( 108 | amount=Amount(real_amount=10, currency="USD"), 109 | percent_margin="1%") 110 | 111 | 112 | def test_convert_Transaction_to_dict(): 113 | transaction = Transaction( 114 | from_amount=Amount(real_amount=10, currency="USD"), 115 | to_amount=Amount(real_amount=8.66, currency="EUR"), 116 | date=datetime.strptime("10/07/18 16:30", "%d/%m/%y %H:%M")) 117 | tr_dict = revolut_bot.convert_Transaction_to_dict(transaction) 118 | assert tr_dict == {'date': '10/07/2018', 119 | 'from_amount': 10.0, 120 | 'from_currency': 'USD', 121 | 'hour': '16:30:00', 122 | 'to_amount': 8.66, 123 | 'to_currency': 'EUR'} 124 | --------------------------------------------------------------------------------