├── .gitignore ├── LICENSE ├── __init__.py ├── client ├── __init__.py ├── constants.py ├── models.py └── xtb_client.py ├── local.py ├── readme.md ├── requirements.txt ├── settings.py └── tests ├── __init__.py ├── conftest.py ├── test_basic_functionality.py └── test_price_history.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 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Mac stuff 114 | .DS_Store 115 | 116 | # Local variables file 117 | local.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Oscar Ramirez 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 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuxskar/PyXTBClient/1e7f33282212a90ea7d8bd850b4804be35ac57f5/__init__.py -------------------------------------------------------------------------------- /client/__init__.py: -------------------------------------------------------------------------------- 1 | from .xtb_client import XTBClient 2 | -------------------------------------------------------------------------------- /client/constants.py: -------------------------------------------------------------------------------- 1 | class Period: 2 | """Periods available for the charts 3 | Values are the minutes of each period 4 | """ 5 | m1 = 1 6 | m5 = 5 7 | m15 = 15 8 | m30 = 30 9 | h1 = 60 # 1 hour 10 | h4 = 240 # 4 hours 11 | d1 = 1440 # 1 day 12 | w1 = 10080 # 1 week 13 | mn1 = 43200 # 1 month 14 | 15 | 16 | class ProfitMode: 17 | forex = 5 18 | cfd = 6 19 | 20 | descriptions = { 21 | forex: 'Forex', 22 | cfd: 'CFD' 23 | } 24 | 25 | 26 | class MarginMode: 27 | forex = 101 28 | cfd_leveraged = 102 29 | cfd = 103 30 | 31 | descriptions = { 32 | forex: 'Forex', 33 | cfd_leveraged: 'CFD Leveraged', 34 | cfd: 'CFD' 35 | } 36 | -------------------------------------------------------------------------------- /client/models.py: -------------------------------------------------------------------------------- 1 | from client.constants import MarginMode, ProfitMode 2 | 3 | 4 | class RateInfoRecord: 5 | def __init__(self, close, ctm, ctmString, high, low, open, vol, digits=0, symbol=None, raw=False): 6 | """ Rate information record 7 | :param close {float} Value of close price (shift from open price) 8 | :param ctm {timestamp} Candle start time in CET / CEST time zone (see Daylight Saving Time, DST) 9 | - since epoch in milliseconds 10 | :param ctmString {string} String representation of the 'ctm' field 11 | - Ex: "Jan 10, 2014 3:04:00 PM" 12 | :param high {float} Highest value in the given period (shift from open price) 13 | :param low {float} Lowest value in the given period (shift from open price) 14 | :param open {float} Open price (in base currency * 10 to the power of digits) 15 | :param vol {float} Volume in lots 16 | :param digits {int} 17 | :param symbol {str} Symbol that the records belongs to 18 | :param raw {bool} If raw means that there is a need to convert the values of close, high, low as they are 19 | relative to the open price and must be adjusted 20 | """ 21 | self.open = open 22 | self.close = close 23 | self.high = high 24 | self.low = low 25 | self.digits = digits 26 | self.symbol = symbol 27 | self.ctm = ctm 28 | self.ctm_string = ctmString 29 | self.volume = vol 30 | 31 | if raw: 32 | divisor = 1 if not digits else 1/pow(10, digits) 33 | self.open = round(open * divisor, digits) 34 | self.close = round(self.open + (self.close * divisor), digits) 35 | self.high = round(self.open + (self.high * divisor), digits) 36 | self.low = round(self.open + (self.low * divisor), digits) 37 | 38 | def __repr__(self): 39 | return '{}'.format(self.__dict__) 40 | 41 | 42 | class Symbol(object): 43 | def __init__(self, symbol, currency, categoryName, currencyProfit, quoteId, quoteIdCross, marginMode, profitMode, 44 | pipsPrecision, contractSize, exemode, time, expiration, stopsLevel, precision, swapType, stepRuleId, 45 | type, instantMaxVolume, groupName, description, longOnly, trailingEnabled, marginHedgedStrong, 46 | swapEnable, percentage, bid, ask, high, low, lotMin, lotMax, lotStep, tickSize, tickValue, swapLong, 47 | swapShort, leverage, spreadRaw, spreadTable, starting, swap_rollover3days, marginMaintenance, 48 | marginHedged, initialMargin, shortSelling, currencyPair, timeString): 49 | """ 50 | Represents a symbol to trade with 51 | Ex: EURUSD, WHEAT or US500 52 | """ 53 | self.symbol = symbol 54 | self.currency = currency 55 | self.category_name = categoryName 56 | self.currency_profit = currencyProfit 57 | self.quote_id = quoteId 58 | self.quote_id_cross = quoteIdCross 59 | self.margin_mode = marginMode 60 | self.profit_mode = profitMode 61 | self.pips_precision = pipsPrecision 62 | self.contract_size = contractSize 63 | self.exemode = exemode 64 | self.time = time 65 | self.expiration = expiration 66 | self.stops_level = stopsLevel 67 | self.precision = precision 68 | self.swap_type = swapType 69 | self.step_rule_id = stepRuleId 70 | self.type = type 71 | self.instant_max_volume = instantMaxVolume 72 | self.group_name = groupName 73 | self.description = description 74 | self.long_only = longOnly 75 | self.trailing_enabled = trailingEnabled 76 | self.margin_hedged_strong = marginHedgedStrong 77 | self.swap_enable = swapEnable 78 | self.percentage = percentage 79 | self.bid = bid 80 | self.ask = ask 81 | self.high = high 82 | self.low = low 83 | self.lot_min = lotMin 84 | self.lot_max = lotMax 85 | self.lot_step = lotStep 86 | self.tick_size = tickSize 87 | self.tick_value = tickValue 88 | self.swap_long = swapLong 89 | self.swap_short = swapShort 90 | self.leverage = leverage 91 | self.spread_raw = spreadRaw 92 | self.spread_table = spreadTable 93 | self.starting = starting 94 | self.swap_rollover3days = swap_rollover3days 95 | self.margin_maintenance = marginMaintenance 96 | self.margin_hedged = marginHedged 97 | self.initial_margin = initialMargin 98 | self.short_selling = shortSelling 99 | self.currency_pair = currencyPair 100 | self.time_string = timeString 101 | 102 | self.profit_mode_description = ProfitMode.descriptions.get(self.profit_mode, 'N/A') 103 | self.margin_mode_description = MarginMode.descriptions.get(self.margin_mode, 'N/A') 104 | 105 | def __repr__(self): 106 | return "{} - {} ({} {}) {}".format(self.symbol, self.category_name, self.margin_mode_description, 107 | self.currency, self.description) 108 | -------------------------------------------------------------------------------- /client/xtb_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import json 4 | import logging 5 | import socket 6 | import ssl 7 | 8 | from client.constants import Period 9 | from client.models import RateInfoRecord, Symbol 10 | 11 | logger = logging.getLogger('XTBClient') 12 | logger.setLevel(os.environ.get('XTB_LOG_LEVEL') or logging.DEBUG) 13 | 14 | 15 | class XTBClient: 16 | def __init__(self, host='xapia.x-station.eu', port=5124, stream_port=5125): 17 | """ 18 | X-Trades Broker client 19 | :param host: host of the demo or real servers (by default demo: 'xapia.x-station.eu') 20 | :param port: port of the demo or real servers (by default demo: 5144) 21 | :param stream_port: streaming port of the demo or real servers (by default demo: 5145) 22 | """ 23 | # replace host name with IP, this should fail connection attempt, 24 | # but it doesn't in Python 2.x 25 | host = socket.getaddrinfo(host, port)[0][4][0] 26 | 27 | # create socket and connect to server 28 | # server address is specified later in connect() method 29 | self.sock = socket.socket() 30 | self.sock.connect((host, port)) 31 | 32 | # wrap socket to add SSL support 33 | self.sock = ssl.wrap_socket(self.sock) 34 | 35 | self.stream = socket.socket() 36 | self.stream.connect((host, stream_port)) 37 | 38 | self.stream = ssl.wrap_socket(self.stream) 39 | 40 | self.stream_session_id = None 41 | 42 | @staticmethod 43 | def _to_camel_case(string): 44 | first, *rest = string.split('_') 45 | if rest: 46 | return first.lower() + ''.join(word.capitalize() for word in rest) 47 | return first 48 | 49 | def _receive_socket_data(self, sock, buffer_size=4096): 50 | """Getting ALL the data pending to be received on the socket""" 51 | return b''.join(self._receive_socket_chucked_data(sock, buffer_size)) 52 | 53 | @staticmethod 54 | def _receive_socket_chucked_data(sock, buffer_size=4096): 55 | """ 56 | Yielding the data of the buffer in chucks of 4KiB 57 | :param buffer_size: size of the buffer to ask data from, by default 4KiB 58 | :return: received data 59 | """ 60 | end_of_data = b'\n\n' # the API uses this constant to determinate the end of the transmission 61 | data = sock.recv(buffer_size) 62 | while data: 63 | eod_idx = data.find(end_of_data) 64 | if eod_idx != -1: 65 | yield data[:eod_idx] 66 | break 67 | 68 | yield data 69 | if len(data) < buffer_size: 70 | # There is no more data left 71 | # break 72 | pass 73 | 74 | data = sock.recv(buffer_size) 75 | yield b'' 76 | 77 | def _send_action(self, command, ret_format='json', arguments=None, sock='sock', **kwargs): 78 | """ 79 | Sending supported commands to the API using the sock of the XTBClient object 80 | :param command: string defining the command to be sent 81 | :param kwargs: dictionary with the different options to the API 82 | note: the keys of the object will be transform from underscore_case to camelCase if required 83 | :param ret_format: can be either json or raw to get the answer back 84 | :return: response from the XTB server 85 | """ 86 | payload = {"command": command, "prettyPrint": True} 87 | if kwargs: 88 | payload.update({self._to_camel_case(k): v for k, v in kwargs.items()}) 89 | if arguments: 90 | payload["arguments"] = {self._to_camel_case(k): v for k, v in arguments.items()} 91 | 92 | payload_request = json.dumps(payload, indent=4) 93 | logger.debug(payload_request) 94 | 95 | sending_sock = self.sock if sock == 'sock' else self.stream 96 | sending_sock.send(payload_request.encode('utf-8')) 97 | 98 | received_data = self._receive_socket_data(sending_sock) 99 | if ret_format == 'json': 100 | return json.loads(received_data) 101 | elif ret_format == 'raw': 102 | return received_data 103 | else: 104 | raise ValueError('Invalid format {} specified'.format(ret_format)) 105 | 106 | def login(self, user_id, password, app_id=None, app_name=None): 107 | """Login method to connect the client to XTB API 108 | :param user_id: user_id or string that identifies the user of the platform 109 | :param password: password to access 110 | :param app_id: app_id to get registered on the API 111 | :param app_name: app_name to get registered on the API 112 | :return: response from the API 113 | """ 114 | params = {"user_id": user_id, "password": password} 115 | if app_id: 116 | params['app_id'] = app_id 117 | if app_name: 118 | params['app_name'] = app_name 119 | 120 | login_info = self._send_action('login', arguments=params) 121 | 122 | # Saving session id to be able to use it later 123 | self.stream_session_id = login_info.get('streamSessionId') 124 | return login_info 125 | 126 | def logout(self): 127 | """Logout method to disconnect the client from the API""" 128 | return self._send_action("logout") 129 | 130 | def ping(self): 131 | """Regularly calling this function is enough to refresh the internal 132 | state of all the components in the system. 133 | It is recommended that any application that does not execute other 134 | commands, should call this command at least once every 10 minutes. 135 | Please note that the streaming counterpart of this function is 136 | combination of ping and getKeepAlive. 137 | """ 138 | return self._send_action("ping") 139 | 140 | def get_balance(self): 141 | """Allows to get actual account indicators values in real-time, 142 | as soon as they are available in the system.""" 143 | if not self.stream_session_id: 144 | raise ValueError('Client not logged in, please log in before try to perform this action') 145 | return self._send_action("getBalance", sock='stream', **{"stream_session_id": self.stream_session_id}).get( 146 | 'data') 147 | 148 | def get_all_symbols(self): 149 | """Returns array of all symbols available for the user""" 150 | for data in self._send_action("getAllSymbols").get('returnData'): 151 | yield Symbol(**data) 152 | 153 | def get_chart_range_request(self, symbol, start, end, period=Period.h4, ticks=0): 154 | """Returns chart info with data between given start and end dates. 155 | :param symbol {string} Symbol 156 | :param start {timestamp} Start of chart block (rounded down to the nearest interval and excluding) 157 | :param end {timestamp} Time End of chart block (rounded down to the nearest interval and excluding) 158 | :param period {int} Number of minutes for the period, please use Period class on constants 159 | :param ticks {int} Number of ticks needed, this field is optional 160 | Ticks field - if ticks is not set or value is 0, getChartRangeRequest works as before 161 | (you must send valid start and end time fields). 162 | If ticks value is not equal to 0, field end is ignored. 163 | If ticks >0 (e.g. N) then API returns N candles from time start. 164 | If ticks <0 then API returns N candles to time start. 165 | It is possible for API to return fewer chart candles than set in tick field. 166 | :return [RateInfoRecord] 167 | """ 168 | res = self._send_action("getChartRangeRequest", arguments={ 169 | 'info': { 170 | "end": end, 171 | "period": period, 172 | "start": start, 173 | "symbol": symbol, 174 | "ticks": ticks 175 | }}) 176 | if res.get('status') is not True: 177 | raise ValueError('Some error getting the response {}'.format(res)) 178 | return_data = res.get('returnData') 179 | digits = return_data.get('digits') 180 | return [RateInfoRecord(digits=digits, symbol=symbol, raw=True, **info) for info in return_data.get('rateInfos')] 181 | -------------------------------------------------------------------------------- /local.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PyXTBClient 2 | 3 | Python library to communicate with X-Trade Brokers via API 4 | 5 | ## Getting Started 6 | 7 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. 8 | 9 | ### Prerequisites 10 | 11 | To make sure it is working correctly open up an API demo account here http://developers.xstore.pro/api and execute the basic tests like this 12 | 13 | ``` 14 | pip install -r requirements.txt 15 | export XTB_USER='' 16 | export XTB_PASS='' 17 | 18 | ``` 19 | 20 | ## Authors 21 | 22 | * **Oscar Ramirez** - *Initial work* - [Tuxskar](https://github.com/tuxskar) 23 | 24 | ## License 25 | 26 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | XTB_USER = os.environ.get('XTB_USER') or 0 4 | XTB_PASS = os.environ.get('XTB_PASS') or '' 5 | 6 | from local import * -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuxskar/PyXTBClient/1e7f33282212a90ea7d8bd850b4804be35ac57f5/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from client import XTBClient 2 | from settings import XTB_USER, XTB_PASS 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def xtb_client(): 9 | client = XTBClient() 10 | logged_in_msg = client.login(XTB_USER, XTB_PASS) 11 | assert logged_in_msg.get('status') is True, 'Error code {}'.format(logged_in_msg.get('errorCode')) 12 | yield client 13 | response = client.logout() 14 | print('logging out info {}'.format(response)) 15 | assert response.get('status') is True 16 | -------------------------------------------------------------------------------- /tests/test_basic_functionality.py: -------------------------------------------------------------------------------- 1 | def test_get_symbols(xtb_client): 2 | symbols = list(xtb_client.get_all_symbols()) 3 | assert len(symbols) > 0 4 | 5 | 6 | def test_get_balance(xtb_client): 7 | balance = xtb_client.get_balance() 8 | assert balance.get('balance') is not None 9 | 10 | 11 | def test_ping(xtb_client): 12 | response = xtb_client.ping() 13 | assert response 14 | -------------------------------------------------------------------------------- /tests/test_price_history.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from client.constants import Period 4 | 5 | 6 | def test_historical_default_prices(xtb_client): 7 | start = int(datetime.datetime(2018, 9, 1).timestamp() * 1000) 8 | end = int(datetime.datetime(2018, 9, 15).timestamp() * 1000) 9 | symbol = 'SPA35' # ibex 10 | period = Period.h4 11 | ibex_history = xtb_client.get_chart_range_request(symbol, start, end, period) 12 | assert len(ibex_history) > 0 13 | --------------------------------------------------------------------------------