├── tests ├── __init__.py ├── conftest.py └── test_comdirect_client.py ├── comdirect_api ├── __init__.py ├── auth │ ├── __init__.py │ ├── comdirect_auth.py │ └── auth_service.py ├── service │ ├── __init__.py │ ├── report_service.py │ ├── document_service.py │ ├── instrument_service.py │ ├── account_service.py │ ├── depot_service.py │ └── order_service.py └── comdirect_client.py ├── requirements.txt ├── .github └── workflows │ ├── python-publish.yml │ └── python-test.yml ├── setup.py ├── CONTRIBUTING.md ├── LICENSE ├── README.md └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /comdirect_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /comdirect_api/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /comdirect_api/service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.8 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | certifi==2021.5.30 8 | # via requests 9 | chardet==4.0.0 10 | # via requests 11 | idna==2.10 12 | # via requests 13 | requests==2.25.1 14 | # via comdirect-api-simple (setup.py) 15 | urllib3==1.26.6 16 | # via requests 17 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | deploy: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.6' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install setuptools wheel twine 23 | - name: Build and publish 24 | env: 25 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 26 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 27 | run: | 28 | python setup.py sdist bdist_wheel 29 | twine upload dist/* 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="comdirect-api-simple", 8 | version="0.0.16", 9 | author="Alexander Knittel", 10 | author_email="alx.kntl@gmail.com", 11 | description="A package for read operations for the comdirect API", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/alex-kn/comdirect-api-simple", 15 | packages=setuptools.find_packages(exclude=["tests"]), 16 | install_requires=[ 17 | 'requests', 18 | ], 19 | classifiers=[ 20 | "Programming Language :: Python :: 3", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | ], 24 | python_requires='>=3.6', 25 | ) 26 | -------------------------------------------------------------------------------- /comdirect_api/service/report_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Union 2 | 3 | 4 | class ReportService: 5 | def get_report(self, product_type: Union[str, List[str]] = None) -> Any: 6 | """10.1.1. List of all balances for a client's own and connected products. 7 | 8 | Args: 9 | product_type (Union[str, List[str]], optional): 10 | Filter by one or more of "ACCOUNT", "CARD", "DEPOT", "LOAN", "SAVINGS" 11 | (list or comma-separated string). Defaults to None. 12 | 13 | Returns: 14 | Any: Response object 15 | """ 16 | url = "{0}/reports/participants/user/v1/allbalances".format(self.api_url) 17 | params = { 18 | "productType": ",".join(product_type) 19 | if type(product_type) is list 20 | else product_type 21 | } 22 | response = self.session.get(url, params=params).json() 23 | return response 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome. This project aims to be a simple python wrapper for the REST API for private consumers of the German Comdirect bank (https://developer.comdirect.de). Contribution are welcome, be it in the form of pull requests or new issues. 4 | 5 | ## Getting started 6 | 7 | To get started simply fork and clone the repository, followed by a quick `pip install -r requirements.txt` to install the needed dependencies in your environment. 8 | 9 | Then start a python session in your terminal and dig in (see [Usage](https://github.com/alex-kn/comdirect-api-simple#usage)). Alternatively, use a Jupyter notebook as a playground to execute different requests repeatedly and compare the responses. 10 | 11 | ## Submitting Changes 12 | 13 | Guidelines for Pull Requests: 14 | 15 | * PRs should contain a list of changes in the description. 16 | * Changes should be tested against the API. Unfortunately Comdirect doesn't provide a test server so the real API has to be used. 17 | * Code should be formatted using [Black](https://black.readthedocs.io/) 18 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: "3.6" 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install flake8 pytest 22 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 23 | - name: Lint with flake8 24 | run: | 25 | # stop the build if there are Python syntax errors or undefined names 26 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 27 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 28 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics 29 | - name: Test with pytest 30 | run: | 31 | pytest 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alexander Knittel 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 | -------------------------------------------------------------------------------- /comdirect_api/auth/comdirect_auth.py: -------------------------------------------------------------------------------- 1 | from requests.auth import AuthBase 2 | import uuid 3 | import time 4 | 5 | 6 | class ComdirectAuth(AuthBase): 7 | def __init__(self, access_token, refresh_token): 8 | self.session_id = str(uuid.uuid4()) 9 | self.access_token = access_token 10 | self.refresh_token = refresh_token 11 | 12 | def __call__(self, request): 13 | request.headers.update( 14 | { 15 | "Authorization": "Bearer {0}".format(self.access_token), 16 | "x-http-request-info": str( 17 | { 18 | "clientRequestId": { 19 | "sessionId": self.session_id, 20 | "requestId": generate_request_id(), 21 | } 22 | } 23 | ), 24 | } 25 | ) 26 | return request 27 | 28 | def session_tan_created(self, access_token, refresh_token): 29 | self.access_token = access_token 30 | self.refresh_token = refresh_token 31 | 32 | 33 | def generate_request_id(): 34 | return str(round(time.time() * 1000))[-9:] 35 | -------------------------------------------------------------------------------- /comdirect_api/service/document_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple 2 | 3 | 4 | class DocumentService: 5 | def get_documents(self, first_index: int = 0, count: int = 1000) -> Any: 6 | """9.1.1. Delivers a list of documents for the customer. 7 | 8 | Args: 9 | first_index (int, optional): Index of the first document. Defaults to 0. 10 | count (int, optional): The maximum number of documents that will be returned. Defaults to 1000. 11 | 12 | Returns: 13 | Any: Response object 14 | """ 15 | url = "{0}/messages/clients/user/v2/documents".format(self.api_url) 16 | params = { 17 | "paging-first": first_index, 18 | "paging-count": count, 19 | } 20 | response = self.session.get(url, params=params).json() 21 | return response 22 | 23 | def get_document(self, document_id: str) -> Tuple[Any, str]: 24 | """9.1.2. Download a document for the given UUID. 25 | 26 | Args: 27 | document_id (str): The unique ID of the document. 28 | 29 | Returns: 30 | Tuple[Any, str]: Tuple of (Document, Content type) 31 | """ 32 | url = "{0}/messages/v2/documents/{1}".format(self.api_url, document_id) 33 | response = self.session.get(url, headers={"Accept": "application/pdf"}) 34 | content_type = response.headers["content-type"] 35 | return response.content, content_type 36 | -------------------------------------------------------------------------------- /tests/test_comdirect_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from comdirect_api.comdirect_client import ComdirectClient 4 | 5 | 6 | def test_comdirect_client_fresh_init(): 7 | client_id = "dummy_id" 8 | client_secret = "dummy_secret" 9 | api_url = "https://api.comdirect.de/api" 10 | oauth_url = "https://api.comdirect.de" 11 | 12 | client = ComdirectClient(client_id, client_secret) 13 | 14 | headers = client.session.headers 15 | assert headers["Accept"] == "application/json" 16 | assert headers["Content-Type"] == "application/json" 17 | assert client.api_url == api_url 18 | assert client.oauth_url == oauth_url 19 | 20 | assert client.auth_service.api_url == api_url 21 | assert client.auth_service.oauth_url == oauth_url 22 | assert client.auth_service.client_id == client_id 23 | assert client.auth_service.client_secret == client_secret 24 | 25 | 26 | def test_comdirect_client_import_session(tmp_path): 27 | client_id = "dummy_id" 28 | client_secret = "dummy_secret" 29 | client = ComdirectClient(client_id, client_secret) 30 | 31 | client.session_export(os.path.join(tmp_path, "session.pkl")) 32 | 33 | new_client = ComdirectClient(client_id, 34 | client_secret, 35 | import_session=os.path.join( 36 | tmp_path, "session.pkl")) 37 | 38 | assert new_client.auth_service.client_id == client_id 39 | assert new_client.auth_service.client_secret == client_secret 40 | -------------------------------------------------------------------------------- /comdirect_api/service/instrument_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class InstrumentService: 5 | def get_instrument( 6 | self, 7 | instrument_id: str, 8 | order_dimensions: bool = False, 9 | fund_distribution: bool = False, 10 | derivative_data: bool = False, 11 | static_data: bool = True, 12 | ) -> Any: 13 | """6.1.1. Request for an intrument's information 14 | 15 | Args: 16 | instrument_id (str): Instrument identification - can be either the WKN, the ISIN or the symbol. 17 | order_dimensions (bool, optional): Include the order dimension object. Defaults to False. 18 | fund_distribution (bool, optional): Include the fund distribution object if the instrument is a fund. 19 | Defaults to False. 20 | derivative_data (bool, optional): include the derivative data object if the instrument is a derivative. 21 | Defaults to False. 22 | static_data (bool, optional): Include the static data object. Defaults to True. 23 | 24 | Returns: 25 | Any: Reponse object 26 | """ 27 | url = "{0}/brokerage/v1/instruments/{1}".format(self.api_url, instrument_id) 28 | params = {} 29 | 30 | if order_dimensions: 31 | params["with-attr"] = "orderDimensions" 32 | if fund_distribution: 33 | if "with-attr" in params.keys(): 34 | params["with-attr"] = params["with-attr"] + ",fundDistribution“" 35 | else: 36 | params["with-attr"] = "fundDistribution“" 37 | if derivative_data: 38 | if "with-attr" in params.keys(): 39 | params["with-attr"] = params["with-attr"] + ",derivativeData“" 40 | else: 41 | params["with-attr"] = "derivativeData“" 42 | if static_data is False: 43 | params["without-attr"] = "staticData" 44 | 45 | response = self.session.get(url, params=params).json() 46 | return response 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Comdirect API 2 | 3 | This is an unofficial python wrapper for the comdirect API for private consumers (April 2020). 4 | 5 | This package currently supports the following operations: 6 | 7 | * Read balances and transactions 8 | * Read depot information 9 | * Read and download Documents 10 | * Read and update orders 11 | * Read instrument information 12 | * Export and import the session 13 | 14 | Use at your own risk. 15 | 16 | ## Install 17 | 18 | Install the package using `pip` 19 | 20 | ```shell script 21 | pip install comdirect-api-simple 22 | ``` 23 | 24 | ## Usage 25 | 26 | Initialize the client: 27 | 28 | ```python 29 | from comdirect_api.comdirect_client import ComdirectClient 30 | 31 | client_id = '' 32 | client_secret = '' 33 | client = ComdirectClient(client_id, client_secret) 34 | ``` 35 | 36 | 37 | Login with your credentials like so: 38 | 39 | ```python 40 | user = 'your_zugangsnummer' 41 | password = 'your_pin' 42 | client.fetch_tan(user, password) 43 | ``` 44 | After confirming the login on your photoTAN app you can activate your session. 45 | 46 | ```python 47 | client.activate_session() 48 | ``` 49 | You can refresh your token with: 50 | 51 | ```python 52 | client.refresh_token() 53 | ``` 54 | 55 | The the client is now ready for use, for example: 56 | 57 | ```python 58 | balances = client.get_all_balances() 59 | print(balances['values']) 60 | ``` 61 | 62 | It is also possible to send a GET request to a self defined endpoint, for example: 63 | 64 | ```python 65 | client.get('reports/participants/user/v1/allbalances', productType='ACCOUNT') 66 | ``` 67 | 68 | List all the complete order-book and filter for OPEN orders: 69 | 70 | ```python 71 | data = client.get_all_orders(depotId, order_status='OPEN') 72 | print(data) 73 | ``` 74 | 75 | You can change an OPEN order as follows: 76 | 77 | ```python 78 | orderId = 'XXYYYAA...' 79 | order = client.get_order(orderId) 80 | order['triggerLimit']['value'] = '16.6' 81 | [challenge_id, challenge] = client.set_order_change_validation(orderId, order) 82 | orderChanged=client.set_order_change(orderId, data, challenge_id) 83 | ``` 84 | 85 | To export the session you can use 86 | 87 | ```python 88 | client.activate_session() 89 | ... 90 | client.session_export() 91 | ``` 92 | 93 | To import it in another instance call: 94 | 95 | ```python 96 | client = ComdirectClient('client_id', 'client_secret', import_session=True) 97 | ``` 98 | 99 | More information about the official API can be found at https://developer.comdirect.de 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Example user template template 3 | ### Example user template 4 | 5 | # IntelliJ project files 6 | .idea 7 | *.iml 8 | out 9 | gen 10 | ### Python template 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # VS Code 17 | .vscode 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | pip-wheel-metadata/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .nox/ 57 | .coverage 58 | .coverage.* 59 | .cache 60 | nosetests.xml 61 | coverage.xml 62 | *.cover 63 | *.py,cover 64 | .hypothesis/ 65 | .pytest_cache/ 66 | cover/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | db.sqlite3 76 | db.sqlite3-journal 77 | 78 | # Flask stuff: 79 | instance/ 80 | .webassets-cache 81 | 82 | # Scrapy stuff: 83 | .scrapy 84 | 85 | # Sphinx documentation 86 | docs/_build/ 87 | 88 | # PyBuilder 89 | .pybuilder/ 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | # For a library or package, you might want to ignore these files since the code is 101 | # intended to run in multiple environments; otherwise, check them in: 102 | # .python-version 103 | 104 | # pipenv 105 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 106 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 107 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 108 | # install all needed dependencies. 109 | #Pipfile.lock 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | /comd.yml 155 | token.txt 156 | upload.cmd 157 | -------------------------------------------------------------------------------- /comdirect_api/comdirect_client.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | import requests 3 | import pickle 4 | 5 | from comdirect_api.auth.auth_service import AuthService 6 | from comdirect_api.service.account_service import AccountService 7 | from comdirect_api.service.depot_service import DepotService 8 | from comdirect_api.service.document_service import DocumentService 9 | from comdirect_api.service.report_service import ReportService 10 | from comdirect_api.service.order_service import OrderService 11 | from comdirect_api.service.instrument_service import InstrumentService 12 | 13 | 14 | class ComdirectClient( 15 | AccountService, 16 | DepotService, 17 | DocumentService, 18 | InstrumentService, 19 | OrderService, 20 | ReportService, 21 | ): 22 | def __init__( 23 | self, 24 | client_id: str, 25 | client_secret: str, 26 | import_session: Union[str, bool] = False, 27 | ): 28 | self.api_url = "https://api.comdirect.de/api" 29 | self.oauth_url = "https://api.comdirect.de" 30 | 31 | if not import_session: 32 | self.session = requests.Session() 33 | self.session.headers.update( 34 | { 35 | "Accept": "application/json", 36 | "Content-Type": "application/json", 37 | } 38 | ) 39 | self.auth_service = AuthService( 40 | client_id, client_secret, self.session, self.api_url, self.oauth_url 41 | ) 42 | else: 43 | if import_session is True: 44 | import_session = "session.pkl" 45 | with open(import_session, "rb") as input: 46 | self.session = pickle.load(input) 47 | self.auth_service = pickle.load(input) 48 | 49 | def session_export(self, filename: str = "session.pkl"): 50 | with open(filename, "wb") as output: 51 | pickle.dump(self.session, output, pickle.HIGHEST_PROTOCOL) 52 | pickle.dump(self.auth_service, output, pickle.HIGHEST_PROTOCOL) 53 | 54 | def fetch_tan(self, zugangsnummer, pin, tan_type=None): 55 | return self.auth_service.fetch_tan(zugangsnummer, pin, tan_type) 56 | 57 | def activate_session(self, tan=None): 58 | self.auth_service.activate_session(tan) 59 | 60 | def refresh_token(self): 61 | self.auth_service.refresh_token() 62 | 63 | def revoke_token(self): 64 | self.auth_service.revoke() 65 | 66 | def get( 67 | self, endpoint: str, base_url: str = "https://api.comdirect.de/api", **kwargs 68 | ) -> Any: 69 | """Sends a generic GET-request to a given endpoint with given parameters 70 | 71 | Args: 72 | endpoint (str): endpoint without leading slash, e.g. 'banking/clients/clientId/v2/accounts/balances' 73 | base_url (str, optional): Base URL. Defaults to 'https://api.comdirect.de/api'. 74 | 75 | Kwargs: Request parameters 76 | 77 | Returns: 78 | Any: Response object 79 | """ 80 | url = "{0}/{1}".format(base_url, endpoint) 81 | return self.session.get(url, params=kwargs).json() 82 | -------------------------------------------------------------------------------- /comdirect_api/service/account_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class AccountService: 5 | def get_all_balances(self, without_account: bool = False) -> Any: 6 | """4.1.1. Request for account information, including cash balance and buying power, for all accounts. 7 | 8 | Args: 9 | without_account (bool, optional): Suppresses the master data of the accounts. Defaults to False. 10 | 11 | Returns: 12 | Any: Response object 13 | """ 14 | url = "{0}/banking/clients/user/v2/accounts/balances".format(self.api_url) 15 | params = {"without-attr": "account"} if without_account else None 16 | response = self.session.get(url, params=params).json() 17 | return response 18 | 19 | def get_balance(self, account_uuid: str) -> Any: 20 | """4.1.2. Request for account information, including cash balance and buying power. 21 | 22 | Args: 23 | account_uuid (str): Account identifier 24 | 25 | Returns: 26 | Any: Response object 27 | """ 28 | url = "{0}/banking/v2/accounts/{1}/balances".format(self.api_url, account_uuid) 29 | response = self.session.get(url).json() 30 | return response 31 | 32 | def get_account_transactions( 33 | self, 34 | account_uuid: str, 35 | with_account: bool = False, 36 | transaction_state: str = "BOTH", 37 | paging_count: int = 20, 38 | paging_first: int = 0, 39 | min_booking_date: str = None, 40 | max_booking_date: str = None, 41 | ) -> Any: 42 | """4.1.3 .Requests and returns a list of transactions for the given account. 43 | 44 | Fetches transactions for a specific account. Not setting a min_booking_date currently limits the result to 45 | the last 180 days due to an API limitation. 46 | 47 | Args: 48 | account_uuid (str): Account identifier 49 | with_account (bool, optional): Include account master data in the response. Defaults to False. 50 | transaction_state (str, optional): "BOTH", "BOOKED", or "NOTBOOKED". Defaults to "BOTH". 51 | paging_count (int, optional): [description]. Defaults to 20. 52 | paging_first (int, optional): Index of first returned transaction. Only possible for booked transactions 53 | (transaction_state='BOOKED'). Defaults to 0. 54 | min_booking_date (str, optional): min booking date in format YYYY-MM-DD. Defaults to None. 55 | max_booking_date (str, optional): max booking date in format YYYY-MM-DD. Defaults to None. 56 | 57 | Returns: 58 | Any: Response object 59 | """ 60 | url = "{0}/banking/v1/accounts/{1}/transactions".format( 61 | self.api_url, account_uuid 62 | ) 63 | params = { 64 | "transactionState": transaction_state, 65 | "paging-count": paging_count, 66 | "paging-first": paging_first, 67 | "min-bookingDate": min_booking_date, 68 | "max-bookingDate": max_booking_date, 69 | } 70 | if with_account: 71 | params["with-attr"] = "account" 72 | 73 | response = self.session.get(url, params=params).json() 74 | return response 75 | -------------------------------------------------------------------------------- /comdirect_api/service/depot_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class DepotService: 5 | def get_all_depots(self) -> Any: 6 | """5.1.1. Request for a list of the master data for the securities accounts of the registered user 7 | 8 | Returns: 9 | Any: Response object 10 | """ 11 | 12 | url = "{0}/brokerage/clients/user/v3/depots".format(self.api_url) 13 | response = self.session.get(url).json() 14 | return response 15 | 16 | def get_depot_positions( 17 | self, 18 | depot_id: str, 19 | with_depot: bool = True, 20 | with_positions: bool = True, 21 | with_instrument: bool = False, 22 | instrument_id: bool = None, 23 | ) -> Any: 24 | """5.1.2. Request for securities positions. 25 | 26 | Request for securities positions, optionally including only the total balance with securities account 27 | information. 28 | 29 | Args: 30 | depot_id (str): Reference to securities account number 31 | with_depot (bool, optional): Include depot information in response. Defaults to True. 32 | with_positions (bool, optional): Include position information in response. Defaults to True. 33 | with_instrument (bool, optional): Include instrument information for positions. 34 | Ignored if with_positions is False. Defaults to False. 35 | instrument_id (bool, optional): [description]. Defaults to None. 36 | 37 | Returns: 38 | Any: Response object 39 | """ 40 | url = "{0}/brokerage/v3/depots/{1}/positions".format(self.api_url, depot_id) 41 | params = {} 42 | if not with_depot and not with_positions: 43 | params["without_attr"] = "depot,positions" 44 | elif with_depot and not with_positions: 45 | params["without_attr"] = "positions" 46 | elif not with_depot and with_positions: 47 | params["without_attr"] = "depot" 48 | 49 | if with_instrument and with_positions: 50 | params["with_attr"] = "instrument" 51 | 52 | if instrument_id: 53 | params["instrumentId"] = instrument_id 54 | 55 | response = self.session.get(url, params=params).json() 56 | return response 57 | 58 | def get_position( 59 | self, depot_id: str, position_id: str, with_instrument: bool = False 60 | ) -> Any: 61 | """5.1.3. Request for retrieving a single position of specific depot. 62 | 63 | Args: 64 | depot_id (str): Reference to securities account number 65 | position_id (str): Position identification number in securities account 66 | with_instrument (bool, optional): Include instrument information for position. Defaults to False. 67 | 68 | Returns: 69 | Any: Response object 70 | """ 71 | url = "{0}/brokerage/v3/depots/{1}/positions/{2}".format( 72 | self.api_url, depot_id, position_id 73 | ) 74 | params = {"with-attr": "instrument"} if with_instrument else None 75 | response = self.session.get(url, params=params).json() 76 | return response 77 | 78 | def get_depot_transactions( 79 | self, depot_id: str, with_instrument: bool = False, **kwargs 80 | ): 81 | """5.1.4. Depot transactions. 82 | 83 | Args: 84 | depot_id (str): Reference to securities account number 85 | with_instrument (bool, optional): Include instrument information for positions. Defaults to False. 86 | 87 | Kwargs: 88 | wkn (str): 89 | filter by WKN 90 | isin (str): 91 | filter by ISIN 92 | instrument_id (str): 93 | filter by instrumentId 94 | max_booking_date (str): 95 | filter by booking date, Format YYYY-MM-TT 96 | transaction_direction (str): 97 | filter by transactionDirection: "IN", "OUT" 98 | transaction_type (str): 99 | filter by transactionType: "BUY", "SELL", "TRANSFER_IN", "TRANSFER_OUT" 100 | booking_status (str): 101 | filter by bookingStatus: "BOOKED", "NOTBOOKED", "BOTH" 102 | min_transaction_value (str): 103 | filter by min-transactionValue 104 | max_transaction_value (str): 105 | filter by max-transactionValue 106 | 107 | Raises: 108 | ValueError: If a keyword arg is invalid. 109 | 110 | Returns: 111 | Any: Response object 112 | """ 113 | kwargs_mapping = { 114 | "wkn": "WKN", 115 | "isin": "ISIN", 116 | "instrument_id": "instrumentId", 117 | "max_booking_date": "max-bookingDate", 118 | "transaction_direction": "transactionDirection", 119 | "transaction_type": "transactionType", 120 | "booking_status": "bookingStatus", 121 | "min_transaction_value": "min-transactionValue", 122 | "max_transaction_value": "max-transactionValue", 123 | } 124 | 125 | url = "{0}/brokerage/v3/depots/{1}/transactions".format(self.api_url, depot_id) 126 | params = {"without-attr": "instrument"} if not with_instrument else {} 127 | 128 | for arg, val in kwargs.items(): 129 | api_arg = kwargs_mapping.get(arg) 130 | if api_arg is None: 131 | raise ValueError("Keyword argument {} is invalid".format(arg)) 132 | else: 133 | params[api_arg] = val 134 | 135 | response = self.session.get(url, params=params).json() 136 | return response 137 | -------------------------------------------------------------------------------- /comdirect_api/auth/auth_service.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from comdirect_api.auth.comdirect_auth import ComdirectAuth 4 | 5 | 6 | class AuthService: 7 | 8 | def __init__(self, client_id, client_secret, session, api_url, oauth_url): 9 | self.client_id = client_id 10 | self.client_secret = client_secret 11 | self.api_url = api_url 12 | self.oauth_url = oauth_url 13 | self.session = session 14 | self.auth = None 15 | self.session_identifier = None 16 | self.challenge_id = None 17 | 18 | def fetch_tan(self, zugangsnummer, pin, tan_type=None): 19 | access_token, refresh_token = self.__oauth_resource_owner_password_credentials_flow(zugangsnummer, pin) 20 | self.auth = ComdirectAuth(access_token, refresh_token) 21 | self.session.auth = self.auth 22 | 23 | self.session_identifier = self.__get_session_status() 24 | self.challenge_id, challenge = self.__post_session_tan(self.session_identifier, tan_type) 25 | return challenge 26 | 27 | def activate_session(self, tan=None): 28 | self.__activate_session_tan(self.session_identifier, self.challenge_id, tan) 29 | access_token, refresh_token = self.__oauth_cd_secondary_flow() 30 | self.auth.session_tan_created(access_token, refresh_token) 31 | 32 | def refresh_token(self): 33 | url = '{0}/oauth/token'.format(self.oauth_url) 34 | payload = { 35 | "client_id": self.client_id, 36 | "client_secret": self.client_secret, 37 | "grant_type": 'refresh_token', 38 | "refresh_token": self.auth.refresh_token, 39 | } 40 | headers = { 41 | 'Content-Type': 'application/x-www-form-urlencoded' 42 | } 43 | response = self.session.post(url, headers=headers, data=payload) 44 | if response.status_code == 200: 45 | response_json = response.json() 46 | self.auth.access_token = response_json['access_token'] 47 | self.auth.refresh_token = response_json['refresh_token'] 48 | else: 49 | raise AuthenticationException(response.headers['x-http-response-info']) 50 | 51 | def revoke(self): 52 | url = "{0}/oauth/revoke".format(self.oauth_url) 53 | headers = { 54 | 'Content-Type': 'application/x-www-form-urlencoded', 55 | } 56 | response = self.session.delete(url, headers=headers) 57 | if response.status_code == 204: 58 | print('Token revoked') 59 | else: 60 | raise AuthenticationException(response.headers['x-http-response-info']) 61 | 62 | def __oauth_resource_owner_password_credentials_flow(self, zugangsnummer, pin): 63 | url = '{0}/oauth/token'.format(self.oauth_url) 64 | payload = { 65 | "client_id": self.client_id, 66 | "client_secret": self.client_secret, 67 | "grant_type": 'password', 68 | "username": str(zugangsnummer), 69 | "password": str(pin), 70 | } 71 | headers = { 72 | 'Content-Type': 'application/x-www-form-urlencoded' 73 | } 74 | response = self.session.post(url, headers=headers, data=payload) 75 | if response.status_code == 200: 76 | response_json = response.json() 77 | return response_json['access_token'], response_json['refresh_token'] 78 | else: 79 | raise AuthenticationException(response.headers['x-http-response-info']) 80 | 81 | def __get_session_status(self): 82 | url = '{0}/session/clients/user/v1/sessions'.format(self.api_url) 83 | 84 | response = self.session.get(url) 85 | if response.status_code == 200: 86 | response_json = response.json()[0] 87 | return response_json['identifier'] 88 | else: 89 | raise AuthenticationException(response.headers['x-http-response-info']) 90 | 91 | def __post_session_tan(self, session_identifier, tan_type=None): 92 | 93 | headers = None 94 | if tan_type is not None: 95 | headers = {'x-once-authentication-info': json.dumps({"typ": tan_type})} 96 | 97 | url = "{0}/session/clients/user/v1/sessions/{1}/validate".format(self.api_url, session_identifier) 98 | payload = '{\"identifier\" : \"' + session_identifier + '\",\"sessionTanActive\": true,\"activated2FA\": true}' 99 | response = self.session.post(url, data=payload, headers=headers) 100 | if response.status_code == 201: 101 | response_json = json.loads(response.headers['x-once-authentication-info']) 102 | typ = response_json['typ'] 103 | print("TAN-TYP: {}".format(typ)) 104 | if typ == 'P_TAN' or typ == 'M_TAN': 105 | return response_json['id'], response_json['challenge'] 106 | else: 107 | return response_json['id'], None 108 | else: 109 | raise AuthenticationException(response.headers['x-http-response-info']) 110 | 111 | def __activate_session_tan(self, session_identifier, challenge_id, tan=None): 112 | url = "{0}/session/clients/user/v1/sessions/{1}".format(self.api_url, session_identifier) 113 | payload = '{\"identifier\" : \"' + session_identifier + '\",\"sessionTanActive\": true,\"activated2FA\": true}' 114 | headers = { 115 | 'x-once-authentication-info': json.dumps({ 116 | "id": challenge_id 117 | }) 118 | } 119 | if tan is not None: 120 | headers['x-once-authentication'] = str(tan) 121 | 122 | response = self.session.patch(url, headers=headers, data=payload) 123 | if response.status_code == 200: 124 | print('Session TAN activated') 125 | else: 126 | raise AuthenticationException(response.headers['x-http-response-info']) 127 | 128 | def __oauth_cd_secondary_flow(self): 129 | url = "{0}/oauth/token".format(self.oauth_url) 130 | 131 | payload = { 132 | "client_id": self.client_id, 133 | "client_secret": self.client_secret, 134 | "grant_type": 'cd_secondary', 135 | "token": self.auth.access_token, 136 | } 137 | headers = { 138 | 'Content-Type': 'application/x-www-form-urlencoded', 139 | } 140 | 141 | response = self.session.post(url, headers=headers, data=payload) 142 | if response.status_code == 200: 143 | response_json = response.json() 144 | return response_json['access_token'], response_json['refresh_token'] 145 | else: 146 | raise AuthenticationException(response.headers['x-http-response-info']) 147 | 148 | 149 | class AuthenticationException(Exception): 150 | def __init__(self, response_info): 151 | self.response_info = response_info 152 | super().__init__(self.response_info) 153 | -------------------------------------------------------------------------------- /comdirect_api/service/order_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import json 3 | 4 | 5 | class OrderService: 6 | def get_dimensions(self, **kwargs) -> Any: 7 | """7.1.1. Request for the trading venue and order options for a particular instrument. 8 | 9 | Kwargs: Filter Parameter 10 | instrument_id: Instrument id (UUID), unique identification of an instrument (security, derivative, etc.). 11 | wkn: WKN 12 | isin: ISIN 13 | mneomic: Mneomic 14 | venue_id: Venue id (UUID), unique identification of a venue. 15 | side: Possible transaction types. Available values: 16 | BUY, SELL 17 | order_type: The order type. Available values: 18 | MARKET, LIMIT, QUOTE, STOP_MARKET, STOP_LIMIT, TRAILING_STOP_MARKET, 19 | TRAILING_STOP_LIMIT, ONE_CANCELS_OTHER, NEXT_ORDER 20 | type: Type of venue. Available values : EXCHANGE, FUND, OFF 21 | 22 | Raises: 23 | ValueError: If a keyword argument is invalid. 24 | 25 | Returns: 26 | Any: Response object 27 | """ 28 | kwargs_mapping = { 29 | "instrument_id": "instrumentId", 30 | "wkn": "WKN", 31 | "isin": "ISIN", 32 | "mneomic": "mneomic", 33 | "venue_id": "venueId", 34 | "side": "side", 35 | "order_type": "orderType", 36 | "type": "type", 37 | } 38 | 39 | url = "{0}/brokerage/v3/orders/dimensions".format(self.api_url) 40 | params = {} 41 | 42 | for arg, val in kwargs.items(): 43 | api_arg = kwargs_mapping.get(arg) 44 | if api_arg is None: 45 | raise ValueError("Keyword argument {} is invalid".format(arg)) 46 | else: 47 | params[api_arg] = val 48 | response = self.session.get(url, json=params).json() 49 | return response 50 | 51 | def get_all_orders( 52 | self, 53 | depot_id: str, 54 | with_instrument: bool = False, 55 | with_executions: bool = True, 56 | **kwargs 57 | ) -> Any: 58 | """7.1.2 Delivers a list fo all orders for the given depotId. 59 | 60 | Args: 61 | depot_id (str): Reference to securities account number (as UUID). 62 | with_instrument (bool, optional): Enables attribute: instrument. Defaults to False. 63 | with_executions (bool, optional): Enables attribute: executions. Defaults to True. 64 | 65 | Kwargs: Filter Parameter 66 | order_status: Status of the order. Available values: 67 | PENDING, OPEN, EXECUTED, SETTLED, CANCELLED_USER, EXPIRED, CANCELLED_SYSTEM, CANCELLED_TRADE, UNKNOWN 68 | venue_id: Venue id (UUID), unique identification of a venue. 69 | side: Possible transaction types. Available values: 70 | BUY, SELL 71 | order_type: The order type. Available values: 72 | MARKET, LIMIT, QUOTE, STOP_MARKET, STOP_LIMIT, TRAILING_STOP_MARKET, TRAILING_STOP_LIMIT, 73 | ONE_CANCELS_OTHER, NEXT_ORDER 74 | 75 | Raises: 76 | ValueError: If a keyword argument is invalid. 77 | 78 | Returns: 79 | Any: Response object 80 | """ 81 | kwargs_mapping = { 82 | "order_status": "orderStatus", 83 | "venue_id": "venueId", 84 | "side": "side", 85 | "order_type": "orderType", 86 | } 87 | 88 | url = "{0}/brokerage/depots/{1}/v3/orders".format(self.api_url, depot_id) 89 | params = {} 90 | 91 | if with_instrument: 92 | params["with-attr"] = "instrument" 93 | if not with_executions: 94 | params["without-attr"] = "executions" 95 | 96 | for arg, val in kwargs.items(): 97 | api_arg = kwargs_mapping.get(arg) 98 | if api_arg is None: 99 | raise ValueError("Keyword argument {} is invalid".format(arg)) 100 | else: 101 | params[api_arg] = val 102 | 103 | response = self.session.get(url, params=params).json() 104 | return response 105 | 106 | def get_order(self, order_id: str) -> Any: 107 | """7.1.3. Delivers an order for the given orderId. 108 | 109 | Args: 110 | order_id (str): Unique orderId (UUID). 111 | 112 | Raises: 113 | OrderException: If an error occurred. 114 | 115 | Returns: 116 | Any: Reponse object 117 | """ 118 | url = "{0}/brokerage/v3/orders/{1}".format(self.api_url, order_id) 119 | params = {} 120 | 121 | response = self.session.get(url, params=params) 122 | if response.status_code == 200: 123 | return response.json() 124 | else: 125 | raise OrderException(response.headers["x-http-response-info"]) 126 | 127 | def set_change_validation(self, order_id: str, changed_order: Any) -> Any: 128 | """7.1.5. Validation of an order modification or order cancellation and triggering of a TAN Challenge in a non-usage 129 | case of a Session-TAN 130 | 131 | Args: 132 | order_id (str): Reference to order identifier (as UUID). 133 | changed_order (Any): Altered order from get_order 134 | 135 | Raises: 136 | OrderException: If an error occurred 137 | 138 | Returns: 139 | Any: [challenge_id, challenge | None] (if challenge not neccessary: None) 140 | """ 141 | url = "{0}/brokerage/v3/orders/{1}/validation".format(self.api_url, order_id) 142 | response = self.session.post(url, json=changed_order) 143 | if response.status_code == 201: 144 | response_json = json.loads(response.headers["x-once-authentication-info"]) 145 | typ = response_json["typ"] 146 | print("TAN-TYP: {}".format(typ)) 147 | if typ == "P_TAN" or typ == "M_TAN": 148 | return response_json["id"], response_json["challenge"] 149 | else: 150 | return response_json["id"], None 151 | else: 152 | raise OrderException(response.headers["x-http-response-info"]) 153 | 154 | def set_change( 155 | self, order_id: str, changed_order: Any, challenge_id: str, tan: int = None 156 | ) -> Any: 157 | """7.1.11. Order modification. 158 | 159 | Args: 160 | order_id (str): Reference to order identifier (as UUID). 161 | changed_order (Any): same altered order as for set_change_validation 162 | challenge_id (str): challenge id from set_change_validation 163 | tan (int, optional): TAN if necessary. Defaults to None. 164 | 165 | Raises: 166 | OrderException: If an error occurred 167 | 168 | Returns: 169 | Any: Response object 170 | """ 171 | url = "{0}/brokerage/v3/orders/{1}".format(self.api_url, order_id) 172 | headers = {"x-once-authentication-info": json.dumps({"id": challenge_id})} 173 | if tan is not None: 174 | headers["x-once-authentication"] = str(tan) 175 | 176 | response = self.session.patch(url, headers=headers, json=changed_order) 177 | if response.status_code == 200: 178 | return response.json() 179 | else: 180 | raise OrderException(response.headers["x-http-response-info"]) 181 | 182 | 183 | class OrderException(Exception): 184 | def __init__(self, response_info): 185 | self.response_info = response_info 186 | super().__init__(self.response_info) 187 | --------------------------------------------------------------------------------