├── .dockerignore ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── dependabot-auto-merge.yml ├── .gitignore ├── .mypy.ini ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── config └── trading_bot.toml ├── docker └── Dockerfile ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── requirements.txt └── source │ ├── modules.rst │ └── system.rst ├── poetry.lock ├── pyproject.toml ├── test ├── common │ └── MockRequests.py ├── test_broker.py ├── test_configuration.py ├── test_data │ ├── alpha_vantage │ │ ├── av_daily_boll_bands_buy.json │ │ ├── av_daily_boll_bands_sell.json │ │ ├── mock_av_daily.json │ │ ├── mock_av_weekly.json │ │ ├── mock_macd_ext_buy.json │ │ ├── mock_macd_ext_hold.json │ │ └── mock_macd_ext_sell.json │ ├── credentials.json │ ├── epics_list.txt │ ├── ig │ │ ├── mock_account_details.json │ │ ├── mock_error.json │ │ ├── mock_historic_price.json │ │ ├── mock_login.json │ │ ├── mock_market_info.json │ │ ├── mock_market_search.json │ │ ├── mock_navigate_markets_markets.json │ │ ├── mock_navigate_markets_nodes.json │ │ ├── mock_positions.json │ │ ├── mock_set_account.json │ │ ├── mock_watchlist.json │ │ └── mock_watchlist_list.json │ ├── trading_bot.toml │ └── yfinance │ │ └── mock_history_day_max.json ├── test_ig_interface.py ├── test_market_provider.py ├── test_simple_boll_bands.py ├── test_simple_macd.py ├── test_strategy_factory.py ├── test_time_provider.py ├── test_trading_bot.py ├── test_utils.py └── test_weighted_avg_peak.py └── tradingbot ├── __init__.py ├── __main__.py ├── components ├── __init__.py ├── backtester.py ├── broker │ ├── __init__.py │ ├── abstract_interfaces.py │ ├── av_interface.py │ ├── broker.py │ ├── factories.py │ ├── ig_interface.py │ └── yf_interface.py ├── configuration.py ├── market_provider.py ├── time_provider.py └── utils.py ├── interfaces ├── __init__.py ├── market.py ├── market_history.py ├── market_macd.py └── position.py ├── strategies ├── __init__.py ├── base.py ├── factories.py ├── simple_bollinger_bands.py ├── simple_macd.py └── weighted_avg_peak.py └── trading_bot.py /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !Makefile 3 | !poetry.lock 4 | !pyproject.toml 5 | !README.md 6 | !tradingbot 7 | !config 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ; ignore = E203, E266, E501, W503, F403 3 | ; max-line-length = 88 4 | ; max-complexity = 18 5 | ; select = B,C,E,F,W,T4,B9 6 | select = C,E,F,W,B,B950 7 | extend-ignore = E501, W503 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | 10 | # Maintain dependencies for Python 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: TradingBot CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master # Prevent building other branches to speed up PRs 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | docker-image-name: ilcardella/tradingbot 13 | push-docker-image: ${{ startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/master' }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ['3.8', '3.9', '3.10', '3.11'] 21 | name: Python ${{ matrix.python-version }} 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | architecture: x64 32 | 33 | - name: Pip cache 34 | uses: actions/cache@v3 35 | with: 36 | # This path is specific to Ubuntu 37 | path: ~/.cache/pip 38 | # Look to see if there is a cache hit for the corresponding poetry.lock 39 | key: ${{ runner.os }}-pip-${{ matrix.python-version }} 40 | restore-keys: | 41 | ${{ runner.os }}-pip- 42 | ${{ runner.os }}- 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | python -m pip install poetry 48 | 49 | - name: Poetry cache 50 | uses: actions/cache@v3 51 | with: 52 | path: ~/.cache/pypoetry/virtualenvs 53 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} 54 | restore-keys: | 55 | ${{ runner.os }}-poetry- 56 | 57 | - name: Run tests 58 | run: make ci 59 | 60 | docker: 61 | needs: build 62 | runs-on: ubuntu-latest 63 | name: Docker build and push 64 | 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v4 68 | 69 | - name: Prepare 70 | id: prepare 71 | run: | 72 | DOCKER_IMAGE=${{ env.docker-image-name }} 73 | VERSION=latest 74 | PLATFORMS=linux/amd64,linux/arm64 75 | if [[ $GITHUB_REF == refs/tags/* ]]; then 76 | VERSION=${GITHUB_REF#refs/tags/v} 77 | elif [[ $GITHUB_REF != refs/heads/master ]]; then 78 | VERSION=${GITHUB_REF#refs/heads/} 79 | VERSION=${VERSION##*/} 80 | fi 81 | TAGS="${DOCKER_IMAGE}:${VERSION}" 82 | if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then 83 | TAGS="$TAGS,${DOCKER_IMAGE}:latest" 84 | fi 85 | 86 | echo "platforms=${PLATFORMS}" >> $GITHUB_OUTPUT 87 | echo "tags=${TAGS}" >> $GITHUB_OUTPUT 88 | 89 | - name: Set up QEMU 90 | uses: docker/setup-qemu-action@master 91 | 92 | - name: Set up Docker Buildx 93 | uses: docker/setup-buildx-action@master 94 | 95 | - name: Cache Docker layers 96 | uses: actions/cache@v3 97 | id: cache 98 | with: 99 | path: /tmp/.buildx-cache 100 | key: ${{ runner.os }}-buildx-${{ github.sha }} 101 | restore-keys: | 102 | ${{ runner.os }}-buildx- 103 | 104 | - name: Login to DockerHub 105 | if: ${{ env.push-docker-image }} 106 | uses: docker/login-action@v3 107 | with: 108 | username: ${{ secrets.DOCKER_USERNAME }} 109 | password: ${{ secrets.DOCKER_PASSWORD }} 110 | 111 | - name: Build and push 112 | id: docker_build 113 | uses: docker/build-push-action@v5 114 | with: 115 | context: . 116 | file: docker/Dockerfile 117 | platforms: ${{ steps.prepare.outputs.platforms }} 118 | push: ${{ env.push-docker-image }} 119 | tags: ${{ steps.prepare.outputs.tags }} 120 | cache-from: type=local,src=/tmp/.buildx-cache 121 | cache-to: type=local,dest=/tmp/.buildx-cache 122 | 123 | - name: Clear 124 | if: always() 125 | run: | 126 | rm -f ${HOME}/.docker/config.json 127 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 9 * * 4' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v3 38 | with: 39 | languages: ${{ matrix.language }} 40 | # If you wish to specify custom queries, you can do so here or in a config file. 41 | # By default, queries listed here will override any specified in a config file. 42 | # Prefix the list here with "+" to use these queries and those in the config file. 43 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v3 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v3 63 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | 3 | on: 4 | pull_request_target: 5 | 6 | jobs: 7 | auto-merge: 8 | runs-on: ubuntu-latest 9 | name: Dependabot auto-merge 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Auto-merge 15 | uses: ahmadnassri/action-dependabot-auto-merge@v2 16 | with: 17 | target: minor 18 | github-token: ${{ secrets.DEPENDABOT_TOKEN }} 19 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 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 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # App specific 104 | data/* 105 | log.txt 106 | pid.txt 107 | markets_db/ 108 | .pytest_cache 109 | _build 110 | _builds 111 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # Optionally build your docs in additional formats such as PDF 19 | formats: 20 | - pdf 21 | 22 | # We recommend specifying your dependencies to enable reproducible builds: 23 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 24 | python: 25 | install: 26 | - requirements: docs/requirements.txt 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [] 8 | ### Added 9 | - Input CLI parameter `--config` to specify a configuration file 10 | - Added `simple_boll_bands` strategy 11 | - Added `--single-pass` optional argument to perform a single iteration of the strategy 12 | - Support for Python 3.9 13 | - IGInterface `api_timeout` configuration parameter to pace http requests 14 | 15 | ### Changed 16 | - Moved `paper_trading` configuration outside of the single broker interface 17 | - Converted configuration file from `json` to `toml` format 18 | - YFinance interface fetch only necessary data for specified data range 19 | - When using a watchlist as market source, markets are only fetched once 20 | 21 | ### Fixed 22 | - Bug preventing to process trade when account does not hold any position yet 23 | - Fixed arm64 docker image build adding missing build dependencies 24 | - Issue 372 - Fixed security warning in ig_interface.py logging 25 | - Issue 358 - Removed default value for `--config` optional argument 26 | 27 | ### Removed 28 | - Support for Python 3.6 and 3.7 29 | 30 | ## [2.0.0] - 2020-07-29 31 | ### Changed 32 | - Improved and expanded configuration file format 33 | - TradingBot is installed in the user space and support files in the user home folder 34 | - Moved main function in `tradingbot` init module 35 | - Replaced dependency manager with Dependabot 36 | 37 | ### Fixed 38 | - Broker package missing __init__.py 39 | - Module imports to absolute from installed main package 40 | 41 | ### Removed 42 | - Support of Python 3.5 43 | - Setup.py configuration 44 | 45 | ### Added 46 | - Common interfaces to unify stocks and account interfaces 47 | - Support for Yahoo Finance 48 | - Formatting and linting support with black and flake8 49 | - Static types checking with mypy 50 | - Make `install-system` target to install TradingBot 51 | - Support Docker image on aarch64 architecture 52 | 53 | ## [1.2.0] - 2019-11-16 54 | ### Added 55 | - Added backtesting feature 56 | - Created Components module 57 | - Added TimeProvider module 58 | - Added setup.py to handle installation 59 | 60 | ### Changed 61 | - Updated CI configuration to test the Docker image 62 | - Updated the Docker image with TradingBot already installed 63 | 64 | ## [1.1.0] - 2019-09-01 65 | ### Changed 66 | - Replaced bash script with python 67 | - Moved sources in `src` installation folder 68 | - Corrected IGInterface numpy dependency 69 | - Added Pipenv integration 70 | - Exported logic from Custom Strategy to simplify workflow 71 | - Added dev-requirements.txt for retro compatibility 72 | - Updated Sphinx documentation 73 | 74 | ## [1.0.1] - 2019-05-09 75 | ### Changed 76 | - Updated renovate configuration 77 | 78 | ## [1.0.0] - 2019-04-21 79 | ### Added 80 | - Initial release 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alberto Cardellini 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifeq ($(origin .RECIPEPREFIX), undefined) 2 | $(error This Make does not support .RECIPEPREFIX. Please use GNU Make 4.0 or later) 3 | endif 4 | .RECIPEPREFIX = > 5 | 6 | INSTALL_DIR = ${HOME}/.TradingBot 7 | DATA_DIR = $(INSTALL_DIR)/data 8 | CONFIG_DIR = $(INSTALL_DIR)/config 9 | LOG_DIR = $(INSTALL_DIR)/log 10 | 11 | default: check 12 | 13 | test: 14 | > poetry run python -m pytest 15 | 16 | docs: 17 | > poetry run make -C docs html 18 | 19 | install: 20 | > poetry install -v 21 | 22 | update: 23 | > poetry update 24 | 25 | remove-env: 26 | > poetry env remove python3 27 | 28 | install-system: clean 29 | > python3 -m pip install --user . 30 | > mkdir -p $(CONFIG_DIR) 31 | > mkdir -p $(DATA_DIR) 32 | > mkdir -p $(LOG_DIR) 33 | > cp config/trading_bot.toml $(CONFIG_DIR) 34 | 35 | build: clean 36 | > poetry build 37 | 38 | docker: clean 39 | > docker build -t ilcardella/tradingbot -f docker/Dockerfile . 40 | 41 | mypy: 42 | > poetry run mypy tradingbot/ 43 | 44 | flake: 45 | > poetry run flake8 tradingbot/ test/ 46 | 47 | isort: 48 | > poetry run isort tradingbot/ test/ 49 | 50 | black: 51 | > poetry run black tradingbot/ test/ 52 | 53 | format: isort black 54 | 55 | lint: flake mypy 56 | 57 | check: install format lint test 58 | 59 | ci: check docs build 60 | 61 | clean: 62 | > rm -rf *egg-info 63 | > rm -rf build/ 64 | > rm -rf dist/ 65 | > find . -name '*.pyc' -exec rm -f {} + 66 | > find . -name '*.pyo' -exec rm -f {} + 67 | > find . -name '*~' -exec rm -f {} + 68 | > find . -name '__pycache__' -exec rm -rf {} + 69 | > find . -name '_build' -exec rm -rf {} + 70 | > find . -name '.mypy_cache' -exec rm -rf {} + 71 | > find . -name '.pytest_cache' -exec rm -rf {} + 72 | 73 | .PHONY: test lint format install docs build docker install-system ci check mypy flake isort black remove update 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TradingBot 2 | ![Build Status](https://github.com/ilcardella/TradingBot/workflows/TradingBot%20CI/badge.svg) ![Documentation Status](https://readthedocs.org/projects/tradingbot/badge/?version=latest) ![Docker Pulls](https://img.shields.io/docker/pulls/ilcardella/tradingbot) 3 | 4 | This is an attempt to create an autonomous market trading script using the IG 5 | REST API and any other available data source for market prices. 6 | 7 | TradingBot is meant to be a "forever running" process that keeps 8 | analysing the markets taking actions whether some conditions are met. 9 | It is halfway from an academic project and a real useful piece of 10 | software, I guess I will see how it goes :) 11 | 12 | The main goal of this project is to provide the capability to 13 | write a custom trading strategy with the minimum effort. 14 | TradingBot handles all the boring stuff. 15 | 16 | All the credits for the `WeightedAvgPeak` strategy goes to GitHub user @tg12. 17 | 18 | ## Dependencies 19 | 20 | - Python 3.6+ 21 | - Poetry (only for development) 22 | - Docker (optional) 23 | 24 | View file `pyproject.toml` for the full list of required python packages. 25 | 26 | ## Install 27 | 28 | First if you have not done yet, install python: 29 | ``` 30 | sudo apt-get update 31 | sudo apt-get install python3 32 | ``` 33 | 34 | Clone this repo and install `TradingBot` by running the following command from 35 | the repository root folder 36 | ``` 37 | make install-system 38 | ``` 39 | 40 | ## Setup 41 | 42 | Login to your IG Dashboard 43 | 44 | - Obtain an API KEY from the settings panel 45 | - If using the demo account, create demo credentials 46 | - Take note of your spread betting account ID (demo or real) 47 | - (Optional) Visit AlphaVantage website: `https://www.alphavantage.co` and request a free api key 48 | - Insert these info in a file called `.credentials` 49 | 50 | This must be in json format 51 | ```json 52 | { 53 | "username": "username", 54 | "password": "password", 55 | "api_key": "apikey", 56 | "account_id": "accountId", 57 | "av_api_key": "apiKey" 58 | } 59 | ``` 60 | - Copy the `.credentials` file into the `${HOME}/.TradingBot/config` folder 61 | - Revoke permissions to read the file 62 | ``` 63 | cd config 64 | sudo chmod 600 ${HOME}/.TradingBot/config/.credentials 65 | ``` 66 | 67 | ### Market source 68 | 69 | There are different ways to define which markets to analyse with TradinbgBot. You can select your preferred option in the configuration file under the `market_source` section: 70 | 71 | - **Local file** 72 | 73 | You can create a file `epic_ids.txt` containg IG epics of the companies you want to monitor. 74 | You need to copy this file into the `${HOME}/.TradingBot/data` folder. 75 | 76 | - **Watchlist** 77 | 78 | You can use an IG watchlist, TradingBot will analyse every market added to the selected watchlist 79 | 80 | - **API** 81 | 82 | TradingBot navigates the IG markets dynamically using the available API call to fetch epic ids. 83 | 84 | ### Configuration file 85 | 86 | The configuration file is in the `config` folder and it contains several configurable parameter to personalise 87 | how TradingBot work. It is important to setup this file appropriately in order to avoid unexpected behaviours. 88 | 89 | ## Start TradingBot 90 | 91 | You can start TradingBot with 92 | ``` 93 | trading_bot 94 | ``` 95 | 96 | You can start it in detached mode letting it run in the background with 97 | ``` 98 | nohup trading_bot >/dev/null 2>&1 & 99 | ``` 100 | 101 | ### Close all the open positions 102 | 103 | ``` 104 | trading_bot --close-positions [-c] 105 | ``` 106 | 107 | ## Stop TradingBot 108 | 109 | To stop a TradingBot instance running in the background 110 | ``` 111 | ps -ef | grep trading_bot | xargs kill -9 112 | ``` 113 | 114 | ## Uninstall 115 | You can use `pip` to uninstall `TradingBot`: 116 | ``` 117 | sudo pip3 uninstall TradingBot 118 | ``` 119 | 120 | ## Development 121 | 122 | The `Makefile` is the entrypoint for any development action. 123 | `Poetry` handles the dependency management and the `pyproject.toml` contains the required 124 | python packages. 125 | 126 | Install [poetry](https://python-poetry.org/docs/) and create the virtual environment: 127 | ``` 128 | cd /path/to/repository 129 | make install 130 | ``` 131 | 132 | ## Test 133 | 134 | You can run the test from the workspace with: 135 | ``` 136 | make test 137 | ``` 138 | 139 | ## Documentation 140 | 141 | The Sphinx documentation contains further details about each TradingBot module 142 | with source code documentation of each class member. 143 | Explanation is provided regarding how to create your own Strategy and how to integrate 144 | it with the system. 145 | 146 | Read the documentation at: 147 | 148 | https://tradingbot.readthedocs.io 149 | 150 | You can build it locally with: 151 | ``` 152 | make docs 153 | ``` 154 | 155 | The generated html files will be in `docs/_build/html`. 156 | 157 | ## Automate 158 | 159 | **NOTE**: TradingBot monitors the market opening hours and suspend the trading when the market is closed. Generally you should NOT need a cron job! 160 | 161 | You can set up the crontab job to run and kill TradinBot at specific times. 162 | The only configuration required is to edit the crontab file adding the preferred schedule: 163 | ``` 164 | crontab -e 165 | ``` 166 | As an example this will start TradingBot at 8:00 in the morning and will stop it at 16:35 in the afternoon, every week day (Mon to Fri): 167 | ```shell 168 | 00 08 * * 1-5 trading_bot 169 | 35 16 * * 1-5 kill -9 $(ps | grep trading_bot | grep -v grep | awk '{ print $1 }') 170 | ``` 171 | NOTE: Remember to set the correct timezone in your machine! 172 | 173 | ## Docker 174 | 175 | You can run TradingBot in a [Docker](https://docs.docker.com/) container. 176 | 177 | The Docker images are configured with a default TradingBot configuration and it 178 | does not have any `.credentials` files. 179 | **You must mount these files when running the Docker container** 180 | 181 | ### Pull 182 | 183 | The Docker images are available in the official [Docker Hub](https://hub.docker.com/r/ilcardella/tradingbot). 184 | Currently `TradingBot` supports both `amd64` and `arm64` architectures. 185 | You can pull the Docker image directly from the Docker Hub. 186 | Latest version: 187 | ``` 188 | docker pull ilcardella/tradingbot:latest 189 | ``` 190 | Tagged version: 191 | ``` 192 | docker pull ilcardella/tradingbot:v2.0.0 193 | ``` 194 | 195 | ### Build 196 | You can build the Docker image yourself using the `Dockerfile` in the `docker` folder: 197 | ``` 198 | cd /path/to/repo 199 | make docker 200 | ``` 201 | 202 | ### Run 203 | As mentioned above, it's important that you configure TradingBot before starting it. 204 | Once the image is available you can run `TradingBot` in a Docker container mounting the configuration files: 205 | ``` 206 | docker run -d \ 207 | -v /path/to/trading_bot.toml:/.TradingBot/config/trading_bot.toml \ 208 | -v /path/to/.credentials:/.TradingBot/config/.credentials \ 209 | tradingbot:latest 210 | ``` 211 | 212 | You can also mount the log folder to store the logs on the host adding `-v /host/folder:/.TradingBot/log` 213 | 214 | 215 | ## Contributing 216 | 217 | Any contribution or suggestion is welcome, please follow the suggested workflow. 218 | 219 | ### Pull Requests 220 | 221 | To add a new feature or to resolve a bug, create a feature branch from the 222 | `master` branch. 223 | 224 | Commit your changes and if possible add unit/integration test cases. 225 | Eventually push your branch and create a Pull Request against `master`. 226 | 227 | If you instead find problems or you have ideas and suggestions for future 228 | improvements, please open an Issue. Thanks for the support! 229 | -------------------------------------------------------------------------------- /config/trading_bot.toml: -------------------------------------------------------------------------------- 1 | # Percentage of account value to use 2 | max_account_usable = 50 3 | time_zone = "Europe/London" 4 | # Path to the credentials file 5 | credentials_filepath = "{home}/.TradingBot/config/.credentials" 6 | # Seconds to wait for between each spin of the bot across all the markets 7 | spin_interval = 3600 8 | # Enable paper trading 9 | paper_trading = false 10 | 11 | [logging] 12 | enable = true 13 | log_filepath = "{home}/.TradingBot/log/trading_bot_{timestamp}.log" 14 | debug = false 15 | 16 | [market_source] 17 | active = "watchlist" 18 | values = ["list", "api", "watchlist"] 19 | [market_source.epic_id_list] 20 | filepath = "{home}/.TradingBot/data/epic_ids.txt" 21 | [market_source.watchlist] 22 | name = "trading_bot" 23 | 24 | [stocks_interface] 25 | active = "yfinance" 26 | values = ["yfinance", "alpha_vantage", "ig_interface"] 27 | [stocks_interface.ig_interface] 28 | order_type = "MARKET" 29 | order_size = 1 30 | order_expiry = "DFB" 31 | order_currency = "GBP" 32 | order_force_open = true 33 | use_g_stop = false 34 | use_demo_account = true 35 | controlled_risk = false 36 | api_timeout = 3 37 | [stocks_interface.alpha_vantage] 38 | api_timeout = 12 39 | [stocks_interface.yfinance] 40 | api_timeout = 0.5 41 | 42 | [account_interface] 43 | active = "ig_interface" 44 | values = ["ig_interface"] 45 | 46 | [strategies] 47 | active = "simple_macd" 48 | values = ["simple_macd", "weighted_avg_peak", "simple_boll_bands"] 49 | [strategies.simple_macd] 50 | max_spread_perc = 5 51 | limit_perc = 10 52 | stop_perc = 5 53 | [strategies.weighted_avg_peak] 54 | max_spread = 3 55 | limit_perc = 10 56 | stop_perc = 5 57 | [strategies.simple_boll_bands] 58 | window = 60 59 | limit_perc = 10 60 | stop_perc = 5 61 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim-buster 2 | 3 | ARG POETRY_VERSION 4 | ARG DEBIAN_FRONTEND=noninteractive 5 | RUN apt-get update \ 6 | && apt-get install -y --no-install-recommends build-essential \ 7 | libssl-dev \ 8 | libffi-dev \ 9 | python-dev \ 10 | && rm -rf /var/lib/apt/lists/* \ 11 | && python -m pip install --upgrade pip \ 12 | && python -m pip install poetry 13 | 14 | ENV PATH=/root/.local/bin:$PATH 15 | 16 | WORKDIR /workspace 17 | COPY ./ /workspace 18 | 19 | RUN make install-system \ 20 | && rm -rf /workspace 21 | 22 | WORKDIR / 23 | 24 | CMD ["trading_bot"] 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath("../tradingbot")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "TradingBot" 24 | copyright = "2019, Alberto Cardellini" 25 | author = "Alberto Cardellini" 26 | 27 | # The short X.Y version 28 | version = "1.0" 29 | # The full version, including alpha/beta/rc tags 30 | release = "1.0.0" 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.coverage", 45 | "sphinx.ext.napoleon", 46 | "sphinx.ext.viewcode", 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ["_templates"] 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = ".rst" 57 | 58 | # The master toctree document. 59 | master_doc = "index" 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This pattern also affects html_static_path and html_extra_path. 71 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = None 75 | 76 | 77 | # -- Options for HTML output ------------------------------------------------- 78 | 79 | # The theme to use for HTML and HTML Help pages. See the documentation for 80 | # a list of builtin themes. 81 | # 82 | html_theme = "sphinx_rtd_theme" 83 | 84 | # Theme options are theme-specific and customize the look and feel of a theme 85 | # further. For a list of options available for each theme, see the 86 | # documentation. 87 | # 88 | html_theme_options = { 89 | "canonical_url": "", 90 | "analytics_id": "", 91 | "logo_only": False, 92 | "display_version": True, 93 | "prev_next_buttons_location": "bottom", 94 | "style_external_links": False, 95 | "collapse_navigation": False, 96 | "sticky_navigation": True, 97 | "navigation_depth": 4, 98 | "includehidden": True, 99 | "titles_only": False, 100 | } 101 | 102 | # Add any paths that contain custom static files (such as style sheets) here, 103 | # relative to this directory. They are copied after the builtin static files, 104 | # so a file named "default.css" will overwrite the builtin "default.css". 105 | # html_static_path = ['_static'] 106 | 107 | # Custom sidebar templates, must be a dictionary that maps document names 108 | # to template names. 109 | # 110 | # The default sidebars (for documents that don't match any pattern) are 111 | # defined by theme itself. Builtin themes are using these templates by 112 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 113 | # 'searchbox.html']``. 114 | # 115 | # html_sidebars = {} 116 | 117 | 118 | # -- Options for HTMLHelp output --------------------------------------------- 119 | 120 | # Output file base name for HTML help builder. 121 | htmlhelp_basename = "TradingBotdoc" 122 | 123 | 124 | # -- Options for LaTeX output ------------------------------------------------ 125 | 126 | latex_elements = { 127 | # The paper size ('letterpaper' or 'a4paper'). 128 | # 129 | # 'papersize': 'letterpaper', 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | # Additional stuff for the LaTeX preamble. 134 | # 135 | # 'preamble': '', 136 | # Latex figure (float) alignment 137 | # 138 | # 'figure_align': 'htbp', 139 | } 140 | 141 | # Grouping the document tree into LaTeX files. List of tuples 142 | # (source start file, target name, title, 143 | # author, documentclass [howto, manual, or own class]). 144 | latex_documents = [ 145 | ( 146 | master_doc, 147 | "TradingBot.tex", 148 | "TradingBot Documentation", 149 | "Alberto Cardellini", 150 | "manual", 151 | ), 152 | ] 153 | 154 | 155 | # -- Options for manual page output ------------------------------------------ 156 | 157 | # One entry per manual page. List of tuples 158 | # (source start file, name, description, authors, manual section). 159 | man_pages = [(master_doc, "tradingbot", "TradingBot Documentation", [author], 1)] 160 | 161 | 162 | # -- Options for Texinfo output ---------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | ( 169 | master_doc, 170 | "TradingBot", 171 | "TradingBot Documentation", 172 | author, 173 | "TradingBot", 174 | "One line description of project.", 175 | "Miscellaneous", 176 | ), 177 | ] 178 | 179 | 180 | # -- Options for Epub output ------------------------------------------------- 181 | 182 | # Bibliographic Dublin Core info. 183 | epub_title = project 184 | 185 | # The unique identifier of the text. This can be a ISBN number 186 | # or the project homepage. 187 | # 188 | # epub_identifier = '' 189 | 190 | # A unique identification for the text. 191 | # 192 | # epub_uid = '' 193 | 194 | # A list of files that should not be packed into the epub file. 195 | epub_exclude_files = ["search.html"] 196 | 197 | 198 | # -- Extension configuration ------------------------------------------------- 199 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ########################## 2 | TradingBot's documentation 3 | ########################## 4 | 5 | ************ 6 | Introduction 7 | ************ 8 | 9 | TradingBot is an autonomous trading system that uses customised strategies to 10 | trade in the London Stock Exchange market. 11 | This documentation provides an overview of the system, explaining how to create 12 | new trading strategies and how to integrate them with TradingBot. 13 | Explore the next sections for a detailed documentation of each module too. 14 | 15 | .. toctree:: 16 | :maxdepth: 1 17 | :numbered: 18 | 19 | source/system.rst 20 | source/modules.rst 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | readthedocs-sphinx-search 4 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | .. _modules: 2 | 3 | Modules 4 | ####### 5 | 6 | TradingBot is composed by different modules organised by their nature. 7 | Each section of this document provide a description of the module meaning 8 | along with the documentation of its internal members. 9 | 10 | 11 | TradingBot 12 | ********** 13 | 14 | .. automodule:: tradingbot 15 | 16 | .. autoclass:: TradingBot 17 | :members: 18 | 19 | Components 20 | ********** 21 | 22 | The ``Components`` module contains the components that provides services 23 | used by TradingBot. 24 | 25 | .. automodule:: tradingbot.components 26 | 27 | 28 | MarketProvider 29 | ============== 30 | 31 | .. autoclass:: MarketProvider 32 | :members: 33 | 34 | Enums 35 | ----- 36 | 37 | .. autoclass:: MarketSource 38 | :members: 39 | 40 | TimeProvider 41 | ============ 42 | 43 | .. autoclass:: TimeProvider 44 | :members: 45 | 46 | Enums 47 | ----- 48 | 49 | .. autoclass:: TimeAmount 50 | :members: 51 | 52 | Backtester 53 | ========== 54 | 55 | .. autoclass:: Backtester 56 | :members: 57 | 58 | Configuration 59 | ============= 60 | 61 | .. autoclass:: Configuration 62 | :members: 63 | 64 | Utils 65 | ===== 66 | 67 | .. autoclass:: Utils 68 | :members: 69 | 70 | Enums 71 | ----- 72 | 73 | .. autoclass:: TradeDirection 74 | :members: 75 | 76 | .. autoclass:: Interval 77 | :members: 78 | 79 | Exceptions 80 | ---------- 81 | 82 | .. autoclass:: MarketClosedException 83 | :members: 84 | 85 | .. autoclass:: NotSafeToTradeException 86 | :members: 87 | 88 | Broker 89 | ****** 90 | 91 | The ``Broker`` class is the wrapper of all the trading services and provides 92 | the main interface for the ``strategies`` to access market data and perform 93 | trades. 94 | 95 | .. automodule:: tradingbot.components.broker 96 | 97 | AbstractInterfaces 98 | ================== 99 | 100 | .. autoclass:: AbstractInterface 101 | :members: 102 | 103 | .. autoclass:: AccountInterface 104 | :members: 105 | 106 | .. autoclass:: StocksInterface 107 | :members: 108 | 109 | IGInterface 110 | =========== 111 | 112 | .. autoclass:: IGInterface 113 | :members: 114 | 115 | Enums 116 | ----- 117 | 118 | .. autoclass:: IG_API_URL 119 | :members: 120 | 121 | AVInterface 122 | =========== 123 | 124 | .. autoclass:: AVInterface 125 | :members: 126 | 127 | Enums 128 | ----- 129 | 130 | .. autoclass:: AVInterval 131 | :members: 132 | 133 | YFinanceInterface 134 | ================= 135 | 136 | .. autoclass:: YFInterval 137 | :members: 138 | 139 | Broker 140 | ====== 141 | 142 | .. autoclass:: Broker 143 | :members: 144 | 145 | BrokerFactory 146 | ============= 147 | 148 | .. autoclass:: BrokerFactory 149 | :members: 150 | 151 | .. autoclass:: InterfaceNames 152 | :members: 153 | 154 | Interfaces 155 | ********** 156 | 157 | The ``Interfaces`` module contains all the interfaces used to exchange 158 | information between different TradingBot components. 159 | The purpose of this module is have clear internal API and avoid integration 160 | errors. 161 | 162 | .. automodule:: tradingbot.interfaces 163 | 164 | Market 165 | ====== 166 | 167 | .. autoclass:: Market 168 | :members: 169 | 170 | MarketHistory 171 | ============= 172 | 173 | .. autoclass:: MarketHistory 174 | :members: 175 | 176 | MarketMACD 177 | ========== 178 | 179 | .. autoclass:: MarketMACD 180 | :members: 181 | 182 | Position 183 | ======== 184 | 185 | .. autoclass:: Position 186 | :members: 187 | 188 | Strategies 189 | ********** 190 | 191 | The ``Strategies`` module contains the strategies used by TradingBot to 192 | analyse the markets. The ``Strategy`` class is the parent from where 193 | any custom strategy **must** inherit from. 194 | The other modules described here are strategies available in TradingBot. 195 | 196 | .. automodule:: tradingbot.strategies 197 | 198 | Strategy 199 | ======== 200 | 201 | .. autoclass:: Strategy 202 | :members: 203 | 204 | StrategyFactory 205 | =============== 206 | 207 | .. autoclass:: StrategyFactory 208 | :members: 209 | 210 | SimpleMACD 211 | ========== 212 | 213 | .. autoclass:: SimpleMACD 214 | :members: 215 | 216 | Weighted Average Peak Detection 217 | =============================== 218 | 219 | .. autoclass:: WeightedAvgPeak 220 | :members: 221 | 222 | Simple Bollinger Bands 223 | ====================== 224 | 225 | .. autoclass:: SimpleBollingerBands 226 | :members: 227 | -------------------------------------------------------------------------------- /docs/source/system.rst: -------------------------------------------------------------------------------- 1 | System Overview 2 | ############### 3 | 4 | TradingBot is a python program with the goal to automate the trading 5 | of stocks in the London Stock Exchange market. 6 | It is designed around the idea that to trade in the stock market 7 | you need a **strategy**: a strategy is a set of rules that define the 8 | conditions where to buy, sell or hold a certain market. 9 | TradingBot design lets the user implement a custom strategy 10 | without the trouble of developing all the boring stuff to make it work. 11 | 12 | The following sections give an overview of the main components that compose 13 | TradingBot. 14 | 15 | TradingBot 16 | ********** 17 | 18 | TradingBot is the main entiy used to initialised all the 19 | components that will be used during the main routine. 20 | It reads the configuration file and the credentials file, it creates the 21 | configured strategy instance, the broker interface and it handle the 22 | processing of the markets with the active strategy. 23 | 24 | Broker interface 25 | **************** 26 | 27 | TradingBot requires an interface with an executive broker in order to open 28 | and close trades in the market. 29 | The broker interface is initialised in the ``TradingBot`` module and 30 | it should be independent from its underlying implementation. 31 | 32 | At the current status, the only supported broker is IGIndex. This broker 33 | provides a very good set of API to analyse the market and manage the account. 34 | TradingBot makes also use of other 3rd party services to fetch market data such 35 | as price snapshot or technical indicators. 36 | 37 | Strategy 38 | ******** 39 | 40 | The ``Strategy`` is the core of the TradingBot system. 41 | It is a generic template class that can be extended with custom functions to 42 | execute trades according to the personalised strategy. 43 | 44 | How to use your own strategy 45 | ============================ 46 | 47 | Anyone can create a new strategy from scratch in a few simple steps. 48 | With your own strategy you can define your own set of rules 49 | to decide whether to buy, sell or hold a specific market. 50 | 51 | #. Setup your development environment (see ``README.md``) 52 | 53 | #. Create a new python module inside the Strategy folder : 54 | 55 | .. code-block:: shell 56 | 57 | cd Strategies 58 | touch my_strategy.py 59 | 60 | #. Edit the file and add a basic strategy template like the following: 61 | 62 | .. code-block:: python 63 | 64 | import os 65 | import inspect 66 | import sys 67 | import logging 68 | 69 | # Required for correct import path 70 | currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) 71 | parentdir = os.path.dirname(currentdir) 72 | sys.path.insert(0,parentdir) 73 | 74 | from Components.Utils import Utils, Interval, TradeDirection 75 | from .Strategy import Strategy 76 | # Import any other required module 77 | 78 | class my_strategy(Strategy): # Extends Strategy module 79 | """ 80 | Description of the strategy 81 | """ 82 | def read_configuration(self, config): 83 | # Read from the config json and store config parameters 84 | pass 85 | 86 | def initialise(self): 87 | # Initialise the strategy 88 | pass 89 | 90 | def fetch_datapoints(self, market): 91 | """ 92 | Fetch any required datapoints (historic prices, indicators, etc.). 93 | The object returned by this function is passed to the 'find_trade_signal()' 94 | function 'datapoints' argument 95 | """ 96 | # As an example, this means the strategy needs 50 data point of 97 | # of past prices from the 1-hour chart of the market 98 | return self.broker.get_prices(market.epic, Interval.HOUR, 50) 99 | 100 | def find_trade_signal(self, market, prices): 101 | # Here is where you want to implement your own code! 102 | # The market instance provide information of the market to analyse while 103 | # the prices dictionary contains the required price datapoints 104 | # Returns the trade direction, stop level and limit level 105 | # As an examle: 106 | return TradeDirection.BUY, 90, 150 107 | 108 | def backtest(self, market, start_date, end_date): 109 | # This is still a work in progress 110 | # The idea here is to perform a backtest of the strategy for the given market 111 | return {"balance": balance, "trades": trades} 112 | 113 | #. Add the implementation for these functions: 114 | 115 | * *read_configuration*: ``config`` is the configuration wrapper instance loaded from the configuration file 116 | * *initialise*: initialise the strategy or any internal members 117 | * *fetch_datapoints*: fetch the required past price datapoints 118 | * *find_trade_signal*: it is the core of your custom strategy, here you can use the broker interface to decide if trade the given epic 119 | * *backtest*: backtest the strategy for a market within the date range 120 | 121 | #. ``Strategy`` parent class provides a ``Broker`` type internal member that 122 | can be accessed with ``self.broker``. This member is the TradingBot broker 123 | interface and provide functions to fetch market data, historic prices and 124 | technical indicators. See the :ref:`modules` section for more details. 125 | 126 | #. ``Strategy`` parent class provides access to another internal member that 127 | list the current open position for the configured account. Access it with 128 | ``self.positions``. 129 | 130 | #. Edit the ``StrategyFactory`` module inporting the new strategy and adding 131 | its name to the ``StrategyNames`` enum. Then add it to the *make* function 132 | 133 | #. Edit the ``TradingBot`` configuration file adding a new section for your strategy parameters 134 | 135 | #. Create a unit test for your strategy 136 | 137 | #. Share your strategy creating a Pull Request :) 138 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "TradingBot" 3 | version = "2.0.0" 4 | description = "Autonomous market trader based on custom strategies" 5 | authors = ["Alberto Cardellini"] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/ilcardella/TradingBot" 9 | documentation = "https://tradingbot.readthedocs.io/en/latest" 10 | packages = [ 11 | { include = "tradingbot" } 12 | ] 13 | include = ["config/trading_bot.toml"] 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.8" 17 | alpha-vantage = "^2.3.1" 18 | govuk-bank-holidays = "^0.13" 19 | pytz = "^2023.3" 20 | requests = "^2.31.0" 21 | yfinance = "^0.1.90" 22 | toml = "^0.10.2" 23 | pandas = "^1.5.2" 24 | scipy = "^1.7.3" 25 | 26 | [tool.poetry.scripts] 27 | trading_bot = 'tradingbot:main' 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | sphinx = "^5.3.0" 31 | sphinx-rtd-theme = "^1.0.0" 32 | requests-mock = "^1.8.0" 33 | pytest = "^7.2.0" 34 | black = "^22.10.0" 35 | flake8 = "^5.0.4" 36 | isort = "^5.7.0" 37 | mypy = "^1.7" 38 | types-toml = "^0.10.8.1" 39 | types-pytz = "^2022.6.0.1" 40 | types-requests = "^2.28.11.5" 41 | 42 | [tool.black] 43 | line-length = 88 44 | include = '\.pyi?$' 45 | exclude = ''' 46 | /( 47 | \.git 48 | | \.hg 49 | | \.mypy_cache 50 | | \.tox 51 | | \.venv 52 | | _build 53 | | buck-out 54 | | build 55 | | dist 56 | )/ 57 | ''' 58 | 59 | [tool.isort] 60 | multi_line_output=3 61 | include_trailing_comma="True" 62 | force_grid_wrap=0 63 | use_parentheses="True" 64 | line_length=88 65 | 66 | [build-system] 67 | requires = ["poetry_core>=1.0.0"] 68 | build-backend = "poetry.core.masonry.api" 69 | -------------------------------------------------------------------------------- /test/common/MockRequests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from enum import Enum 4 | 5 | from tradingbot.components.broker import IG_API_URL 6 | 7 | # Set global variables used in fixtures 8 | TEST_DATA_IG = "test/test_data/ig" 9 | TEST_DATA_AV = "test/test_data/alpha_vantage" 10 | TEST_DATA_YF = "test/test_data/yfinance" 11 | IG_BASE_URI = IG_API_URL.BASE_URI.value.replace("@", IG_API_URL.DEMO_PREFIX.value) 12 | 13 | 14 | class AV_API_URL(Enum): 15 | """AlphaVantage API URLs""" 16 | 17 | BASE_URI = "https://www.alphavantage.co/query?" 18 | MACD_EXT = "MACDEXT" 19 | TS_DAILY = "TIME_SERIES_DAILY" 20 | 21 | 22 | class YF_API_URL(Enum): 23 | """YFinance API URLs""" 24 | 25 | BASE_URI = "https://query2.finance.yahoo.com/v8/finance/chart" 26 | 27 | 28 | def read_json(filepath): 29 | """Read a JSON file""" 30 | try: 31 | with open(filepath, "r") as file: 32 | return json.load(file) 33 | except IOError: 34 | exit() 35 | 36 | 37 | def ig_request_login(mock, data="mock_login.json", fail=False): 38 | """Mock login response""" 39 | mock.post( 40 | "{}/{}".format(IG_BASE_URI, IG_API_URL.SESSION.value), 41 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 42 | headers={"CST": "mock", "X-SECURITY-TOKEN": "mock"}, 43 | status_code=401 if fail else 200, 44 | ) 45 | 46 | 47 | def ig_request_set_account(mock, data="mock_set_account.json", fail=False): 48 | """Mock set account response""" 49 | mock.put( 50 | "{}/{}".format(IG_BASE_URI, IG_API_URL.SESSION.value), 51 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 52 | status_code=401 if fail else 200, 53 | ) 54 | 55 | 56 | def ig_request_account_details(mock, data="mock_account_details.json", fail=False): 57 | """Mock account details""" 58 | mock.get( 59 | "{}/{}".format(IG_BASE_URI, IG_API_URL.ACCOUNTS.value), 60 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 61 | status_code=401 if fail else 200, 62 | ) 63 | 64 | 65 | def ig_request_open_positions(mock, data="mock_positions.json", fail=False): 66 | """Mock open positions call""" 67 | mock.get( 68 | "{}/{}".format(IG_BASE_URI, IG_API_URL.POSITIONS.value), 69 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 70 | status_code=401 if fail else 200, 71 | ) 72 | 73 | 74 | def ig_request_market_info(mock, args="", data="mock_market_info.json", fail=False): 75 | """Mock market info call""" 76 | mock.get( 77 | re.compile("{}/{}/{}".format(IG_BASE_URI, IG_API_URL.MARKETS.value, args)), 78 | json=data 79 | if isinstance(data, dict) 80 | else read_json("{}/{}".format(TEST_DATA_IG, data)), 81 | status_code=401 if fail else 200, 82 | ) 83 | 84 | 85 | def ig_request_search_market(mock, args="", data="mock_market_search.json", fail=False): 86 | """Mock market search call""" 87 | mock.get( 88 | re.compile( 89 | re.escape( 90 | "{}/{}?searchTerm={}".format( 91 | IG_BASE_URI, IG_API_URL.MARKETS.value, args 92 | ) 93 | ) 94 | ), 95 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 96 | status_code=401 if fail else 200, 97 | ) 98 | 99 | 100 | def ig_request_prices(mock, args="", data="mock_historic_price.json", fail=False): 101 | """Mock prices call""" 102 | mock.get( 103 | re.compile("{}/{}/{}".format(IG_BASE_URI, IG_API_URL.PRICES.value, args)), 104 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 105 | status_code=401 if fail else 200, 106 | ) 107 | 108 | 109 | def ig_request_trade(mock, data={"dealReference": "123456789"}, fail=False): 110 | """Mock trade call""" 111 | mock.post( 112 | "{}/{}".format(IG_BASE_URI, IG_API_URL.POSITIONS_OTC.value), 113 | json=data, 114 | status_code=401 if fail else 200, 115 | ) 116 | 117 | 118 | def ig_request_confirm_trade( 119 | mock, 120 | data={"dealId": "123456789", "dealStatus": "SUCCESS", "reason": "SUCCESS"}, 121 | fail=False, 122 | ): 123 | """Mock confirm trade call""" 124 | mock.get( 125 | "{}/{}/{}".format(IG_BASE_URI, IG_API_URL.CONFIRMS.value, data["dealId"]), 126 | json=data, 127 | status_code=401 if fail else 200, 128 | ) 129 | 130 | 131 | def ig_request_navigate_market( 132 | mock, args="", data="mock_navigate_markets_nodes.json", fail=False 133 | ): 134 | """Mock navigate market call""" 135 | mock.get( 136 | re.compile("{}/{}/{}".format(IG_BASE_URI, IG_API_URL.MARKET_NAV.value, args)), 137 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 138 | status_code=401 if fail else 200, 139 | ) 140 | 141 | 142 | def ig_request_watchlist(mock, args="", data="mock_watchlist_list.json", fail=False): 143 | """Mock watchlist call""" 144 | mock.get( 145 | re.compile("{}/{}/{}".format(IG_BASE_URI, IG_API_URL.WATCHLISTS.value, args)), 146 | json=read_json("{}/{}".format(TEST_DATA_IG, data)), 147 | status_code=401 if fail else 200, 148 | ) 149 | 150 | 151 | ################################################################### 152 | # Alpha Vantage mock requests 153 | ################################################################### 154 | 155 | 156 | def av_request_macd_ext(mock, args="", data="mock_macd_ext_buy.json", fail=False): 157 | """Mock MACD EXT call""" 158 | mock.get( 159 | re.compile( 160 | re.escape( 161 | "{}function={}&symbol={}".format( 162 | AV_API_URL.BASE_URI.value, AV_API_URL.MACD_EXT.value, args 163 | ) 164 | ) 165 | ), 166 | json=data 167 | if isinstance(data, dict) 168 | else read_json("{}/{}".format(TEST_DATA_AV, data)), 169 | status_code=401 if fail else 200, 170 | ) 171 | 172 | 173 | def av_request_prices(mock, args="", data="mock_av_daily.json", fail=False): 174 | """Mock AV prices""" 175 | mock.get( 176 | re.compile( 177 | re.escape( 178 | "{}function={}&symbol={}".format( 179 | AV_API_URL.BASE_URI.value, AV_API_URL.TS_DAILY.value, args 180 | ) 181 | ) 182 | ), 183 | json=data 184 | if isinstance(data, dict) 185 | else read_json("{}/{}".format(TEST_DATA_AV, data)), 186 | status_code=401 if fail else 200, 187 | ) 188 | 189 | 190 | ################################################################### 191 | # Yahoo Finance mock requests 192 | ################################################################### 193 | 194 | 195 | def yf_request_prices(mock, args="", data="mock_history_day_max.json", fail=False): 196 | """Mock YF prices""" 197 | mock.get( 198 | re.compile(re.escape("{}/{}".format(YF_API_URL.BASE_URI.value, args))), 199 | json=data 200 | if isinstance(data, dict) 201 | else read_json("{}/{}".format(TEST_DATA_YF, data)), 202 | status_code=401 if fail else 200, 203 | ) 204 | -------------------------------------------------------------------------------- /test/test_broker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import toml 3 | from common.MockRequests import ( 4 | av_request_macd_ext, 5 | av_request_prices, 6 | ig_request_account_details, 7 | ig_request_confirm_trade, 8 | ig_request_login, 9 | ig_request_market_info, 10 | ig_request_navigate_market, 11 | ig_request_open_positions, 12 | ig_request_prices, 13 | ig_request_search_market, 14 | ig_request_set_account, 15 | ig_request_trade, 16 | ig_request_watchlist, 17 | yf_request_prices, 18 | ) 19 | 20 | from tradingbot.components import Configuration, Interval, TradeDirection 21 | from tradingbot.components.broker import Broker, BrokerFactory, InterfaceNames 22 | from tradingbot.interfaces import Market, MarketHistory, MarketMACD, Position 23 | 24 | 25 | @pytest.fixture( 26 | params=[ 27 | InterfaceNames.IG_INDEX.value, 28 | InterfaceNames.ALPHA_VANTAGE.value, 29 | InterfaceNames.YAHOO_FINANCE.value, 30 | ] 31 | ) 32 | def config(request): 33 | with open("test/test_data/trading_bot.toml", "r") as f: 34 | config = toml.load(f) 35 | # Inject the fixture parameter in the configuration 36 | config["stocks_interface"]["active"] = request.param 37 | # To speed up the tests, reduce the timeout of all interfaces 38 | config["stocks_interface"][InterfaceNames.YAHOO_FINANCE.value][ 39 | "api_timeout" 40 | ] = 0 41 | config["stocks_interface"][InterfaceNames.ALPHA_VANTAGE.value][ 42 | "api_timeout" 43 | ] = 0 44 | return Configuration(config) 45 | 46 | 47 | @pytest.fixture 48 | def mock_http_calls(requests_mock): 49 | ig_request_login(requests_mock) 50 | ig_request_set_account(requests_mock) 51 | ig_request_account_details(requests_mock) 52 | ig_request_open_positions(requests_mock) 53 | ig_request_market_info(requests_mock) 54 | ig_request_search_market(requests_mock) 55 | ig_request_prices(requests_mock) 56 | ig_request_trade(requests_mock) 57 | ig_request_confirm_trade(requests_mock) 58 | ig_request_navigate_market(requests_mock) 59 | ig_request_navigate_market( 60 | requests_mock, args="668394", data="mock_navigate_markets_markets.json" 61 | ) 62 | ig_request_navigate_market( 63 | requests_mock, args="77976799", data="mock_navigate_markets_markets.json" 64 | ) 65 | ig_request_navigate_market( 66 | requests_mock, args="89291253", data="mock_navigate_markets_markets.json" 67 | ) 68 | ig_request_watchlist(requests_mock) 69 | ig_request_watchlist(requests_mock, args="12345678", data="mock_watchlist.json") 70 | av_request_prices(requests_mock) 71 | av_request_macd_ext(requests_mock) 72 | yf_request_prices(requests_mock) 73 | 74 | 75 | @pytest.fixture 76 | def broker(mock_http_calls, config): 77 | return Broker(BrokerFactory(config)) 78 | 79 | 80 | def test_get_open_positions(broker): 81 | p = broker.get_open_positions() 82 | assert p is not None 83 | assert isinstance(p, list) 84 | assert len(p) > 0 85 | assert isinstance(p[0], Position) 86 | 87 | 88 | def test_get_markets_from_watchlist(broker): 89 | m = broker.get_markets_from_watchlist("My Watchlist") 90 | assert m is not None 91 | assert isinstance(m, list) 92 | assert len(m) > 0 93 | assert isinstance(m[0], Market) 94 | 95 | 96 | def test_navigate_market_node(broker): 97 | # TODO 98 | # Root node 99 | node = broker.navigate_market_node("") 100 | assert node is not None 101 | # Specific node 102 | node = broker.navigate_market_node("12345") 103 | assert node is not None 104 | 105 | 106 | def test_get_account_used_perc(broker): 107 | perc = broker.get_account_used_perc() 108 | assert perc == 62.138354775208285 109 | 110 | 111 | def test_close_all_positions(broker): 112 | assert broker.close_all_positions() 113 | 114 | 115 | def test_close_position(broker): 116 | pos = broker.get_open_positions() 117 | for p in pos: 118 | assert broker.close_position(p) 119 | 120 | 121 | def test_trade(broker): 122 | assert broker.trade("mock", TradeDirection.BUY, 100, 50) 123 | 124 | 125 | def test_get_market_info(broker): 126 | market = broker.get_market_info("mock") 127 | assert market is not None 128 | assert isinstance(market, Market) 129 | 130 | 131 | def test_search_market(broker): 132 | markets = broker.search_market("mock") 133 | assert markets is not None 134 | assert isinstance(markets, list) 135 | assert isinstance(markets[0], Market) 136 | 137 | 138 | def test_get_prices(broker): 139 | hist = broker.get_prices(broker.get_market_info("mock"), Interval.DAY, 10) 140 | assert hist is not None 141 | assert isinstance(hist, MarketHistory) 142 | assert MarketHistory.DATE_COLUMN in hist.dataframe 143 | assert len(hist.dataframe[MarketHistory.DATE_COLUMN]) > 0 144 | assert MarketHistory.HIGH_COLUMN in hist.dataframe 145 | assert len(hist.dataframe[MarketHistory.HIGH_COLUMN]) > 0 146 | assert MarketHistory.LOW_COLUMN in hist.dataframe 147 | assert len(hist.dataframe[MarketHistory.LOW_COLUMN]) > 0 148 | assert MarketHistory.CLOSE_COLUMN in hist.dataframe 149 | assert len(hist.dataframe[MarketHistory.CLOSE_COLUMN]) > 0 150 | assert MarketHistory.VOLUME_COLUMN in hist.dataframe 151 | assert len(hist.dataframe[MarketHistory.VOLUME_COLUMN]) > 0 152 | 153 | 154 | def test_get_macd(broker): 155 | market = broker.get_market_info("mock") 156 | interval = Interval.HOUR 157 | macd = broker.get_macd(market, interval, 10) 158 | 159 | assert macd is not None 160 | assert isinstance(macd, MarketMACD) 161 | assert MarketMACD.DATE_COLUMN in macd.dataframe 162 | assert len(macd.dataframe[MarketMACD.DATE_COLUMN]) > 0 163 | assert MarketMACD.MACD_COLUMN in macd.dataframe 164 | assert len(macd.dataframe[MarketMACD.MACD_COLUMN]) > 0 165 | assert MarketMACD.SIGNAL_COLUMN in macd.dataframe 166 | assert len(macd.dataframe[MarketMACD.SIGNAL_COLUMN]) > 0 167 | assert MarketMACD.HIST_COLUMN in macd.dataframe 168 | assert len(macd.dataframe[MarketMACD.HIST_COLUMN]) > 0 169 | -------------------------------------------------------------------------------- /test/test_configuration.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from tradingbot.components import Configuration 6 | 7 | 8 | def test_init(): 9 | config = Configuration({}) 10 | assert config is not None 11 | with pytest.raises(ValueError): 12 | config = Configuration([1]) 13 | with pytest.raises(ValueError): 14 | config = Configuration((1, 1)) 15 | with pytest.raises(ValueError): 16 | config = Configuration("mock") 17 | with pytest.raises(ValueError): 18 | config = Configuration(1.0) 19 | with pytest.raises(ValueError): 20 | config = Configuration(1) 21 | 22 | 23 | def test_from_filepath(): 24 | with pytest.raises(FileNotFoundError): 25 | config = Configuration.from_filepath(Path("wrong/path")) 26 | config = Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 27 | assert config is not None 28 | 29 | 30 | def test_get_raw_config(): 31 | mock_dict = { 32 | "key_string": "string1", 33 | "key_list": ["string1", "string2"], 34 | "key_dict": { 35 | "subkey1": "subvalue1", 36 | "subkey2": [1, 2, 3], 37 | "subkey3": {"subsubkey1": 1.0, "subsubkey2": "subsubvalue"}, 38 | }, 39 | } 40 | config = Configuration(mock_dict) 41 | assert config is not None 42 | raw = config.get_raw_config() 43 | assert raw == mock_dict 44 | 45 | 46 | def test_value_getters(): 47 | config = Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 48 | assert config is not None 49 | assert config.get_max_account_usable() == 90 50 | assert config.get_time_zone() == "Europe/London" 51 | assert config.get_credentials_filepath() == "test/test_data/credentials.json" 52 | credentials = config.get_credentials() 53 | assert type(credentials) is dict 54 | assert "username" in credentials 55 | assert credentials["username"] == "username" 56 | assert "password" in credentials 57 | assert credentials["password"] == "password" 58 | assert "api_key" in credentials 59 | assert credentials["api_key"] == "key" 60 | assert "account_id" in credentials 61 | assert credentials["account_id"] == "abcd" 62 | assert "av_api_key" in credentials 63 | assert credentials["av_api_key"] == "qwerty" 64 | assert config.get_spin_interval() == 3600 65 | assert config.is_logging_enabled() 66 | assert "/tmp/trading_bot" in config.get_log_filepath() 67 | assert "{timestamp}" not in config.get_log_filepath() 68 | assert not config.is_logging_debug_enabled() 69 | assert config.get_active_market_source() == "watchlist" 70 | assert config.get_market_source_values() == ["list", "api", "watchlist"] 71 | assert config.get_epic_ids_filepath() == "test/test_data/epic_ids.txt" 72 | assert config.get_watchlist_name() == "trading_bot" 73 | assert config.get_active_stocks_interface() == "ig_interface" 74 | assert config.get_stocks_interface_values() == [ 75 | "yfinance", 76 | "alpha_vantage", 77 | "ig_interface", 78 | ] 79 | assert config.get_ig_order_type() == "MARKET" 80 | assert config.get_ig_order_size() == 1 81 | assert config.get_ig_order_expiry() == "DFB" 82 | assert config.get_ig_order_currency() == "GBP" 83 | assert config.get_ig_order_force_open() 84 | assert not config.get_ig_use_g_stop() 85 | assert config.get_ig_use_demo_account() 86 | assert not config.get_ig_controlled_risk() 87 | assert config.get_ig_api_timeout() == 0 88 | assert not config.is_paper_trading_enabled() 89 | assert config.get_alphavantage_api_timeout() == 12 90 | assert config.get_yfinance_api_timeout() == 0.5 91 | assert config.get_active_account_interface() == "ig_interface" 92 | assert config.get_account_interface_values() == ["ig_interface"] 93 | assert config.get_active_strategy() == "simple_macd" 94 | assert config.get_strategies_values() == [ 95 | "simple_macd", 96 | "weighted_avg_peak", 97 | "simple_boll_bands", 98 | ] 99 | 100 | 101 | def test_replace_placeholders(): 102 | mock_dict = { 103 | "key_string": "string1", 104 | "key_list": ["string1", "string2"], 105 | "key_dict": { 106 | "subkey1": "{home}/path/file", 107 | "subkey2": [1, 2, 3], 108 | "subkey3": {"subsubkey1": 1.0, "subsubkey2": "file_{timestamp}"}, 109 | }, 110 | } 111 | config = Configuration(mock_dict) 112 | assert config is not None 113 | raw = config.get_raw_config() 114 | assert "{home}" not in raw["key_dict"]["subkey1"] 115 | assert "{timestamp}" not in raw["key_dict"]["subkey3"]["subsubkey2"] 116 | -------------------------------------------------------------------------------- /test/test_data/alpha_vantage/av_daily_boll_bands_buy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Meta Data": { 3 | "1. Information": "Daily Prices (open, high, low, close) and Volumes", 4 | "2. Symbol": "MSFT", 5 | "3. Last Refreshed": "2019-09-16", 6 | "4. Output Size": "Full size", 7 | "5. Time Zone": "US/Eastern" 8 | }, 9 | "Time Series (Daily)": { 10 | "2019-09-16": { 11 | "1. open": "135.8300", 12 | "2. high": "136.7000", 13 | "3. low": "135.6600", 14 | "4. close": "117.3300", 15 | "5. volume": "14785072" 16 | }, 17 | "2019-09-13": { 18 | "1. open": "137.7800", 19 | "2. high": "138.0600", 20 | "3. low": "136.5700", 21 | "4. close": "120.3200", 22 | "5. volume": "22902300" 23 | }, 24 | "2019-09-12": { 25 | "1. open": "137.8500", 26 | "2. high": "138.4200", 27 | "3. low": "136.8700", 28 | "4. close": "123.5200", 29 | "5. volume": "27010000" 30 | }, 31 | "2019-09-11": { 32 | "1. open": "135.9100", 33 | "2. high": "136.2700", 34 | "3. low": "135.0900", 35 | "4. close": "124.1200", 36 | "5. volume": "24726100" 37 | }, 38 | "2019-09-10": { 39 | "1. open": "136.8000", 40 | "2. high": "136.8900", 41 | "3. low": "134.5100", 42 | "4. close": "125.0800", 43 | "5. volume": "28903400" 44 | }, 45 | "2019-09-09": { 46 | "1. open": "139.5900", 47 | "2. high": "139.7500", 48 | "3. low": "136.4600", 49 | "4. close": "130.5200", 50 | "5. volume": "25773900" 51 | }, 52 | "2019-09-06": { 53 | "1. open": "140.0300", 54 | "2. high": "140.1800", 55 | "3. low": "138.2000", 56 | "4. close": "135.1000", 57 | "5. volume": "20824500" 58 | }, 59 | "2019-09-05": { 60 | "1. open": "139.1100", 61 | "2. high": "140.3800", 62 | "3. low": "138.7600", 63 | "4. close": "140.0500", 64 | "5. volume": "26101800" 65 | }, 66 | "2019-09-04": { 67 | "1. open": "137.3000", 68 | "2. high": "137.6900", 69 | "3. low": "136.4800", 70 | "4. close": "137.6300", 71 | "5. volume": "17995900" 72 | }, 73 | "2019-09-03": { 74 | "1. open": "136.6100", 75 | "2. high": "137.2000", 76 | "3. low": "135.7000", 77 | "4. close": "136.0400", 78 | "5. volume": "18869300" 79 | }, 80 | "2019-08-30": { 81 | "1. open": "139.1500", 82 | "2. high": "139.1800", 83 | "3. low": "136.2700", 84 | "4. close": "137.8600", 85 | "5. volume": "23940100" 86 | }, 87 | "2019-08-29": { 88 | "1. open": "137.2500", 89 | "2. high": "138.4400", 90 | "3. low": "136.9100", 91 | "4. close": "138.1200", 92 | "5. volume": "20168700" 93 | }, 94 | "2019-08-28": { 95 | "1. open": "134.8800", 96 | "2. high": "135.7600", 97 | "3. low": "133.5500", 98 | "4. close": "135.5600", 99 | "5. volume": "17393300" 100 | }, 101 | "2019-08-27": { 102 | "1. open": "136.3900", 103 | "2. high": "136.7200", 104 | "3. low": "134.6600", 105 | "4. close": "135.7400", 106 | "5. volume": "23102100" 107 | }, 108 | "2019-08-26": { 109 | "1. open": "134.9900", 110 | "2. high": "135.5600", 111 | "3. low": "133.9000", 112 | "4. close": "135.4500", 113 | "5. volume": "20312600" 114 | }, 115 | "2019-08-23": { 116 | "1. open": "137.1900", 117 | "2. high": "138.3500", 118 | "3. low": "132.8000", 119 | "4. close": "133.3900", 120 | "5. volume": "38508600" 121 | }, 122 | "2019-08-22": { 123 | "1. open": "138.6600", 124 | "2. high": "139.2000", 125 | "3. low": "136.2900", 126 | "4. close": "137.7800", 127 | "5. volume": "18697000" 128 | }, 129 | "2019-08-21": { 130 | "1. open": "138.5500", 131 | "2. high": "139.4900", 132 | "3. low": "138.0000", 133 | "4. close": "138.7900", 134 | "5. volume": "14970300" 135 | }, 136 | "2019-08-20": { 137 | "1. open": "138.2100", 138 | "2. high": "138.7100", 139 | "3. low": "137.2400", 140 | "4. close": "137.2600", 141 | "5. volume": "21170800" 142 | }, 143 | "2019-08-19": { 144 | "1. open": "137.8500", 145 | "2. high": "138.5500", 146 | "3. low": "136.8900", 147 | "4. close": "138.4100", 148 | "5. volume": "24355700" 149 | }, 150 | "2019-08-16": { 151 | "1. open": "134.8800", 152 | "2. high": "136.4600", 153 | "3. low": "134.7200", 154 | "4. close": "136.1300", 155 | "5. volume": "24449100" 156 | }, 157 | "2019-08-15": { 158 | "1. open": "134.3900", 159 | "2. high": "134.5800", 160 | "3. low": "132.2500", 161 | "4. close": "133.6800", 162 | "5. volume": "28074400" 163 | }, 164 | "2019-08-14": { 165 | "1. open": "136.3600", 166 | "2. high": "136.9200", 167 | "3. low": "133.6700", 168 | "4. close": "133.9800", 169 | "5. volume": "32527300" 170 | }, 171 | "2019-08-13": { 172 | "1. open": "136.0500", 173 | "2. high": "138.8000", 174 | "3. low": "135.0000", 175 | "4. close": "138.6000", 176 | "5. volume": "25154600" 177 | }, 178 | "2019-08-12": { 179 | "1. open": "137.0700", 180 | "2. high": "137.8600", 181 | "3. low": "135.2400", 182 | "4. close": "135.7900", 183 | "5. volume": "20476600" 184 | }, 185 | "2019-08-09": { 186 | "1. open": "138.6100", 187 | "2. high": "139.3800", 188 | "3. low": "136.4600", 189 | "4. close": "137.7100", 190 | "5. volume": "23466700" 191 | }, 192 | "2019-08-08": { 193 | "1. open": "136.6000", 194 | "2. high": "138.9900", 195 | "3. low": "135.9300", 196 | "4. close": "138.8900", 197 | "5. volume": "27496500" 198 | }, 199 | "2019-08-07": { 200 | "1. open": "133.7900", 201 | "2. high": "135.6500", 202 | "3. low": "131.8280", 203 | "4. close": "135.2800", 204 | "5. volume": "33414500" 205 | }, 206 | "2019-08-06": { 207 | "1. open": "133.8000", 208 | "2. high": "135.6800", 209 | "3. low": "133.2100", 210 | "4. close": "134.6900", 211 | "5. volume": "32696700" 212 | }, 213 | "2019-08-05": { 214 | "1. open": "133.3000", 215 | "2. high": "133.9300", 216 | "3. low": "130.7800", 217 | "4. close": "132.2100", 218 | "5. volume": "42749600" 219 | }, 220 | "2019-08-02": { 221 | "1. open": "138.0900", 222 | "2. high": "138.3200", 223 | "3. low": "135.2600", 224 | "4. close": "136.9000", 225 | "5. volume": "30791600" 226 | }, 227 | "2019-08-01": { 228 | "1. open": "137.0000", 229 | "2. high": "140.9400", 230 | "3. low": "136.9300", 231 | "4. close": "138.0600", 232 | "5. volume": "40557500" 233 | }, 234 | "2019-07-31": { 235 | "1. open": "140.3300", 236 | "2. high": "140.4900", 237 | "3. low": "135.0800", 238 | "4. close": "136.2700", 239 | "5. volume": "38598800" 240 | }, 241 | "2019-07-30": { 242 | "1. open": "140.1400", 243 | "2. high": "141.2200", 244 | "3. low": "139.8000", 245 | "4. close": "140.3500", 246 | "5. volume": "16846500" 247 | }, 248 | "2019-07-29": { 249 | "1. open": "141.5000", 250 | "2. high": "141.5100", 251 | "3. low": "139.3700", 252 | "4. close": "141.0300", 253 | "5. volume": "16605900" 254 | }, 255 | "2019-07-26": { 256 | "1. open": "140.3700", 257 | "2. high": "141.6800", 258 | "3. low": "140.3000", 259 | "4. close": "141.3400", 260 | "5. volume": "19037600" 261 | }, 262 | "2019-07-25": { 263 | "1. open": "140.4300", 264 | "2. high": "140.6100", 265 | "3. low": "139.3200", 266 | "4. close": "140.1900", 267 | "5. volume": "18356900" 268 | }, 269 | "2019-07-24": { 270 | "1. open": "138.8968", 271 | "2. high": "140.7400", 272 | "3. low": "138.8500", 273 | "4. close": "140.7200", 274 | "5. volume": "20738300" 275 | }, 276 | "2019-07-23": { 277 | "1. open": "139.7600", 278 | "2. high": "139.9900", 279 | "3. low": "138.0300", 280 | "4. close": "139.2900", 281 | "5. volume": "18034600" 282 | }, 283 | "2019-07-22": { 284 | "1. open": "137.4100", 285 | "2. high": "139.1900", 286 | "3. low": "137.3300", 287 | "4. close": "138.4300", 288 | "5. volume": "25074900" 289 | }, 290 | "2019-07-19": { 291 | "1. open": "140.2200", 292 | "2. high": "140.6700", 293 | "3. low": "136.4500", 294 | "4. close": "136.6200", 295 | "5. volume": "48992400" 296 | }, 297 | "2019-07-18": { 298 | "1. open": "135.5500", 299 | "2. high": "136.6200", 300 | "3. low": "134.6700", 301 | "4. close": "136.4200", 302 | "5. volume": "30808700" 303 | }, 304 | "2019-07-17": { 305 | "1. open": "137.7000", 306 | "2. high": "137.9300", 307 | "3. low": "136.2200", 308 | "4. close": "136.2700", 309 | "5. volume": "20211000" 310 | }, 311 | "2019-07-16": { 312 | "1. open": "138.9600", 313 | "2. high": "139.0500", 314 | "3. low": "136.5200", 315 | "4. close": "137.0800", 316 | "5. volume": "22726100" 317 | } 318 | } 319 | } -------------------------------------------------------------------------------- /test/test_data/credentials.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "username", 3 | "password": "password", 4 | "api_key": "key", 5 | "account_id": "abcd", 6 | "av_api_key": "qwerty" 7 | } 8 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_account_details.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": [{ 3 | "accountId": "AAAAA", 4 | "accountName": "Demo-cfd", 5 | "accountAlias": null, 6 | "status": "ENABLED", 7 | "accountType": "CFD", 8 | "preferred": false, 9 | "balance": { 10 | "balance": 5385.28, 11 | "deposit": 0.0, 12 | "profitLoss": 0.0, 13 | "available": 5385.28 14 | }, 15 | "currency": "GBP", 16 | "canTransferFrom": true, 17 | "canTransferTo": true 18 | }, { 19 | "accountId": "AAAAA", 20 | "accountName": "Demo-SpreadBet", 21 | "accountAlias": null, 22 | "status": "ENABLED", 23 | "accountType": "SPREADBET", 24 | "preferred": true, 25 | "balance": { 26 | "balance": 16093.12, 27 | "deposit": 10000.0, 28 | "profitLoss": 0.0, 29 | "available": 6093.12 30 | }, 31 | "currency": "GBP", 32 | "canTransferFrom": true, 33 | "canTransferTo": true 34 | }] 35 | } 36 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "errorCode": "123" 3 | } 4 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_login.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountType": "SPREADBET", 3 | "accountInfo": { 4 | "balance": 16093.12, 5 | "deposit": 0.0, 6 | "profitLoss": 0.0, 7 | "available": 16093.12 8 | }, 9 | "currencyIsoCode": "GBP", 10 | "currencySymbol": "£", 11 | "currentAccountId": "AAAAA", 12 | "lightstreamerEndpoint": "https://demo-apd.marketdatasystems.com", 13 | "accounts": [{ 14 | "accountId": "AAAAA", 15 | "accountName": "Demo-cfd", 16 | "preferred": false, 17 | "accountType": "CFD" 18 | }, { 19 | "accountId": "AAAAAA", 20 | "accountName": "Demo-SpreadBet", 21 | "preferred": true, 22 | "accountType": "SPREADBET" 23 | }], 24 | "clientId": "123456789", 25 | "timezoneOffset": 0, 26 | "hasActiveDemoAccounts": true, 27 | "hasActiveLiveAccounts": true, 28 | "trailingStopsEnabled": false, 29 | "reroutingEnvironment": null, 30 | "dealingEnabled": true 31 | } 32 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_market_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "instrument": { 3 | "epic": "KA.D.GSK.DAILY.IP", 4 | "expiry": "DFB", 5 | "name": "GlaxoSmithKline PLC", 6 | "forceOpenAllowed": true, 7 | "stopsLimitsAllowed": true, 8 | "lotSize": 1.0, 9 | "unit": "AMOUNT", 10 | "type": "SHARES", 11 | "controlledRiskAllowed": true, 12 | "streamingPricesAvailable": false, 13 | "marketId": "GSK-UK", 14 | "currencies": [{ 15 | "code": "GBP", 16 | "symbol": "£", 17 | "baseExchangeRate": 1.0, 18 | "exchangeRate": 1.0, 19 | "isDefault": true 20 | }], 21 | "sprintMarketsMinimumExpiryTime": null, 22 | "sprintMarketsMaximumExpiryTime": null, 23 | "marginDepositBands": [{ 24 | "min": 0, 25 | "max": 800, 26 | "margin": 20, 27 | "currency": "GBP" 28 | }, { 29 | "min": 800, 30 | "max": 3800, 31 | "margin": 20, 32 | "currency": "GBP" 33 | }, { 34 | "min": 3800, 35 | "max": 15200, 36 | "margin": 40, 37 | "currency": "GBP" 38 | }, { 39 | "min": 15200, 40 | "max": null, 41 | "margin": 75, 42 | "currency": "GBP" 43 | }], 44 | "marginFactor": 20, 45 | "marginFactorUnit": "PERCENTAGE", 46 | "slippageFactor": { 47 | "unit": "pct", 48 | "value": 100.0 49 | }, 50 | "limitedRiskPremium": { 51 | "value": 1, 52 | "unit": "PERCENTAGE" 53 | }, 54 | "openingHours": null, 55 | "expiryDetails": { 56 | "lastDealingDate": "2029-04-06T15:30", 57 | "settlementInfo": "DFBs settle on the Last Dealing Day at the closing market bid/offer price of the share, plus or minus half the IG spread. " 58 | }, 59 | "rolloverDetails": null, 60 | "newsCode": "GSK.L", 61 | "chartCode": "GSK", 62 | "country": "GB", 63 | "valueOfOnePip": null, 64 | "onePipMeans": null, 65 | "contractSize": null, 66 | "specialInfo": ["DEFAULT KNOCK OUT LEVEL DISTANCE", "MAX KNOCK OUT LEVEL DISTANCE"] 67 | }, 68 | "dealingRules": { 69 | "minStepDistance": { 70 | "unit": "PERCENTAGE", 71 | "value": 1.0 72 | }, 73 | "minDealSize": { 74 | "unit": "POINTS", 75 | "value": 0.5 76 | }, 77 | "minControlledRiskStopDistance": { 78 | "unit": "PERCENTAGE", 79 | "value": 5.0 80 | }, 81 | "minNormalStopOrLimitDistance": { 82 | "unit": "PERCENTAGE", 83 | "value": 2.0 84 | }, 85 | "maxStopOrLimitDistance": { 86 | "unit": "PERCENTAGE", 87 | "value": 75.0 88 | }, 89 | "marketOrderPreference": "AVAILABLE_DEFAULT_OFF", 90 | "trailingStopsPreference": "AVAILABLE" 91 | }, 92 | "snapshot": { 93 | "marketStatus": "EDITS_ONLY", 94 | "netChange": -0.6, 95 | "percentageChange": -0.04, 96 | "updateTime": "16:30:00", 97 | "delayTime": 0, 98 | "bid": 1562.0, 99 | "offer": 1565.8, 100 | "high": 1580.0, 101 | "low": 1541.1, 102 | "binaryOdds": null, 103 | "decimalPlacesFactor": 1, 104 | "scalingFactor": 1, 105 | "controlledRiskExtraSpread": 0 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_market_search.json: -------------------------------------------------------------------------------- 1 | { 2 | "markets": [{ 3 | "epic": "EL.D.PRSNO.DAILY.IP", 4 | "instrumentName": "Prosafe SE", 5 | "instrumentType": "SHARES", 6 | "expiry": "DFB", 7 | "high": 949.9, 8 | "low": 914.1, 9 | "percentageChange": 2.82, 10 | "netChange": 26.0, 11 | "updateTime": "15:25:22", 12 | "updateTimeUTC": "14:25:22", 13 | "bid": 946.1, 14 | "offer": 947.9, 15 | "delayTime": 0, 16 | "streamingPricesAvailable": false, 17 | "marketStatus": "EDITS_ONLY", 18 | "scalingFactor": 1 19 | }, { 20 | "epic": "EL.D.PRSNO.SEP.IP", 21 | "instrumentName": "Prosafe SE", 22 | "instrumentType": "SHARES", 23 | "expiry": "SEP-19", 24 | "high": 953.0, 25 | "low": 912.5, 26 | "percentageChange": 2.82, 27 | "netChange": 26.0, 28 | "updateTime": "15:25:22", 29 | "updateTimeUTC": "14:25:22", 30 | "bid": 944.4, 31 | "offer": 951.0, 32 | "delayTime": 0, 33 | "streamingPricesAvailable": false, 34 | "marketStatus": "EDITS_ONLY", 35 | "scalingFactor": 1 36 | }, { 37 | "epic": "EL.D.PRSNO.DEC.IP", 38 | "instrumentName": "Prosafe SE", 39 | "instrumentType": "SHARES", 40 | "expiry": "DEC-19", 41 | "high": 958.6, 42 | "low": 916.0, 43 | "percentageChange": 2.81, 44 | "netChange": 26.0, 45 | "updateTime": "15:25:22", 46 | "updateTimeUTC": "14:25:22", 47 | "bid": 948.0, 48 | "offer": 956.6, 49 | "delayTime": 0, 50 | "streamingPricesAvailable": false, 51 | "marketStatus": "EDITS_ONLY", 52 | "scalingFactor": 1 53 | }, { 54 | "epic": "EL.D.PRSNO.MAR.IP", 55 | "instrumentName": "Prosafe SE", 56 | "instrumentType": "SHARES", 57 | "expiry": "MAR-20", 58 | "high": 964.6, 59 | "low": 919.0, 60 | "percentageChange": 2.79, 61 | "netChange": 26.0, 62 | "updateTime": "15:25:22", 63 | "updateTimeUTC": "14:25:22", 64 | "bid": 951.1, 65 | "offer": 962.6, 66 | "delayTime": 0, 67 | "streamingPricesAvailable": false, 68 | "marketStatus": "EDITS_ONLY", 69 | "scalingFactor": 1 70 | }, { 71 | "epic": "KC.D.PRSRLN.DAILY.IP", 72 | "instrumentName": "The PRS REIT PLC", 73 | "instrumentType": "SHARES", 74 | "expiry": "DFB", 75 | "high": 90.0, 76 | "low": 87.0, 77 | "percentageChange": -1.12, 78 | "netChange": -1.0, 79 | "updateTime": "16:35:13", 80 | "updateTimeUTC": "15:35:13", 81 | "bid": 88.0, 82 | "offer": 88.0, 83 | "delayTime": 0, 84 | "streamingPricesAvailable": false, 85 | "marketStatus": "EDITS_ONLY", 86 | "scalingFactor": 1 87 | }, { 88 | "epic": "KC.D.PRSRLN.SEP.IP", 89 | "instrumentName": "The PRS REIT PLC", 90 | "instrumentType": "SHARES", 91 | "expiry": "SEP-19", 92 | "high": 90.04, 93 | "low": 87.04, 94 | "percentageChange": -1.12, 95 | "netChange": -1.0, 96 | "updateTime": "16:35:13", 97 | "updateTimeUTC": "15:35:13", 98 | "bid": 88.04, 99 | "offer": 88.04, 100 | "delayTime": 0, 101 | "streamingPricesAvailable": false, 102 | "marketStatus": "EDITS_ONLY", 103 | "scalingFactor": 1 104 | }, { 105 | "epic": "KC.D.PRSRLN.DEC.IP", 106 | "instrumentName": "The PRS REIT PLC", 107 | "instrumentType": "SHARES", 108 | "expiry": "DEC-19", 109 | "high": 90.31, 110 | "low": 87.31, 111 | "percentageChange": -1.12, 112 | "netChange": -1.0, 113 | "updateTime": "16:35:13", 114 | "updateTimeUTC": "15:35:13", 115 | "bid": 88.31, 116 | "offer": 88.31, 117 | "delayTime": 0, 118 | "streamingPricesAvailable": false, 119 | "marketStatus": "EDITS_ONLY", 120 | "scalingFactor": 1 121 | }, { 122 | "epic": "KC.D.PRSRLN.MAR.IP", 123 | "instrumentName": "The PRS REIT PLC", 124 | "instrumentType": "SHARES", 125 | "expiry": "MAR-20", 126 | "high": 90.58, 127 | "low": 87.57, 128 | "percentageChange": -1.12, 129 | "netChange": -1.0, 130 | "updateTime": "16:35:13", 131 | "updateTimeUTC": "15:35:13", 132 | "bid": 88.57, 133 | "offer": 88.57, 134 | "delayTime": 0, 135 | "streamingPricesAvailable": false, 136 | "marketStatus": "EDITS_ONLY", 137 | "scalingFactor": 1 138 | }] 139 | } -------------------------------------------------------------------------------- /test/test_data/ig/mock_navigate_markets_markets.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [], 3 | "markets": [ 4 | { 5 | "delayTime": 0, 6 | "epic": "KC.D.AVLN8875P.DEC.IP", 7 | "netChange": 0.01, 8 | "lotSize": 0, 9 | "expiry": "DEC-18", 10 | "instrumentType": "SHARES", 11 | "instrumentName": "General Accident PLC 8.875 Pfd", 12 | "high": 134.55, 13 | "low": 129.5, 14 | "percentageChange": 0.01, 15 | "updateTime": "4461000", 16 | "updateTimeUTC": "02:14:21", 17 | "bid": 131.49, 18 | "offer": 132.54, 19 | "otcTradeable": true, 20 | "streamingPricesAvailable": false, 21 | "marketStatus": "EDITS_ONLY", 22 | "scalingFactor": 1 23 | }, 24 | { 25 | "delayTime": 0, 26 | "epic": "KC.D.AVLN8875P.MAR.IP", 27 | "netChange": 0.0, 28 | "lotSize": 0, 29 | "expiry": "MAR-19", 30 | "instrumentType": "SHARES", 31 | "instrumentName": "General Accident PLC 8.875 Pfd", 32 | "high": 135.03, 33 | "low": 129.84, 34 | "percentageChange": 0.0, 35 | "updateTime": "4461000", 36 | "updateTimeUTC": "02:14:21", 37 | "bid": 131.82, 38 | "offer": 133.01, 39 | "otcTradeable": true, 40 | "streamingPricesAvailable": false, 41 | "marketStatus": "EDITS_ONLY", 42 | "scalingFactor": 1 43 | }, 44 | { 45 | "delayTime": 0, 46 | "epic": "KC.D.AVLN8875P.JUN.IP", 47 | "netChange": 0.01, 48 | "lotSize": 0, 49 | "expiry": "JUN-19", 50 | "instrumentType": "SHARES", 51 | "instrumentName": "General Accident PLC 8.875 Pfd", 52 | "high": 135.64, 53 | "low": 130.04, 54 | "percentageChange": 0.0, 55 | "updateTime": "4461000", 56 | "updateTimeUTC": "02:14:21", 57 | "bid": 132.03, 58 | "offer": 133.62, 59 | "otcTradeable": true, 60 | "streamingPricesAvailable": false, 61 | "marketStatus": "EDITS_ONLY", 62 | "scalingFactor": 1 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_navigate_markets_nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "id": "668394", 5 | "name": "Cryptocurrency" 6 | }, 7 | { 8 | "id": "77976799", 9 | "name": "Options (Australia 200)" 10 | }, 11 | { 12 | "id": "89291253", 13 | "name": "Options (US Tech 100)" 14 | } 15 | ], 16 | "markets": [] 17 | } 18 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_set_account.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingStopsEnabled": false, 3 | "dealingEnabled": true, 4 | "hasActiveDemoAccounts": true, 5 | "hasActiveLiveAccounts": true 6 | } 7 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_watchlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "markets": [ 3 | { 4 | "instrumentName": "Bitcoin", 5 | "expiry": "DFB", 6 | "epic": "CS.D.BITCOIN.TODAY.IP", 7 | "instrumentType": "CURRENCIES", 8 | "marketStatus": "TRADEABLE", 9 | "lotSize": 1.0, 10 | "high": 3247.4, 11 | "low": 3106.7, 12 | "percentageChange": -0.25, 13 | "netChange": -7.87, 14 | "bid": 3129.72, 15 | "offer": 3169.72, 16 | "updateTime": "18:11:51", 17 | "updateTimeUTC": "18:11:51", 18 | "delayTime": 0, 19 | "streamingPricesAvailable": true, 20 | "scalingFactor": 1 21 | }, 22 | { 23 | "instrumentName": "FTSE 100", 24 | "expiry": "DFB", 25 | "epic": "IX.D.FTSE.DAILY.IP", 26 | "instrumentType": "INDICES", 27 | "marketStatus": "EDITS_ONLY", 28 | "lotSize": 10.0, 29 | "high": 6847.1, 30 | "low": 6788.2, 31 | "percentageChange": -0.59, 32 | "netChange": -40.5, 33 | "bid": 6790.8, 34 | "offer": 6794.8, 35 | "updateTime": "02:14:21", 36 | "updateTimeUTC": "02:14:21", 37 | "delayTime": 0, 38 | "streamingPricesAvailable": true, 39 | "scalingFactor": 1 40 | }, 41 | { 42 | "instrumentName": "Germany 30", 43 | "expiry": "DFB", 44 | "epic": "IX.D.DAX.DAILY.IP", 45 | "instrumentType": "INDICES", 46 | "marketStatus": "EDITS_ONLY", 47 | "lotSize": 25.0, 48 | "high": 10875.8, 49 | "low": 10804.0, 50 | "percentageChange": -0.37, 51 | "netChange": -40.4, 52 | "bid": 10809.4, 53 | "offer": 10814.4, 54 | "updateTime": "02:14:21", 55 | "updateTimeUTC": "02:14:21", 56 | "delayTime": 0, 57 | "streamingPricesAvailable": true, 58 | "scalingFactor": 1 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /test/test_data/ig/mock_watchlist_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "watchlists": [ 3 | { 4 | "id": "12345678", 5 | "name": "My Watchlist", 6 | "editable": true, 7 | "deleteable": false, 8 | "defaultSystemWatchlist": false 9 | }, 10 | { 11 | "id": "Popular Markets", 12 | "name": "Popular Markets", 13 | "editable": false, 14 | "deleteable": false, 15 | "defaultSystemWatchlist": true 16 | }, 17 | { 18 | "id": "Major Indices", 19 | "name": "Major Indices", 20 | "editable": false, 21 | "deleteable": false, 22 | "defaultSystemWatchlist": false 23 | }, 24 | { 25 | "id": "6817448", 26 | "name": "Major FX", 27 | "editable": false, 28 | "deleteable": false, 29 | "defaultSystemWatchlist": false 30 | }, 31 | { 32 | "id": "Major Commodities", 33 | "name": "Major Commodities", 34 | "editable": false, 35 | "deleteable": false, 36 | "defaultSystemWatchlist": false 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /test/test_data/trading_bot.toml: -------------------------------------------------------------------------------- 1 | # Percentage of account value to use 2 | max_account_usable = 90 3 | time_zone = "Europe/London" 4 | # Available placeholders: {home} = user home directory 5 | credentials_filepath = "test/test_data/credentials.json" 6 | # Seconds to wait for between each spin of the bot 7 | spin_interval = 3600 8 | # Enable paper trading 9 | paper_trading = false 10 | 11 | [logging] 12 | enable = true 13 | log_filepath = "/tmp/trading_bot_{timestamp}.log" 14 | debug = false 15 | 16 | [market_source] 17 | active = "watchlist" 18 | values = ["list", "api", "watchlist"] 19 | [market_source.epic_id_list] 20 | filepath = "test/test_data/epic_ids.txt" 21 | [market_source.watchlist] 22 | name = "trading_bot" 23 | 24 | [stocks_interface] 25 | active = "ig_interface" 26 | values = ["yfinance", "alpha_vantage", "ig_interface"] 27 | [stocks_interface.ig_interface] 28 | order_type = "MARKET" 29 | order_size = 1 30 | order_expiry = "DFB" 31 | order_currency = "GBP" 32 | order_force_open = true 33 | use_g_stop = false 34 | use_demo_account = true 35 | controlled_risk = false 36 | api_timeout = 0 37 | [stocks_interface.alpha_vantage] 38 | api_timeout = 12 39 | [stocks_interface.yfinance] 40 | api_timeout = 0.5 41 | 42 | [account_interface] 43 | active = "ig_interface" 44 | values = ["ig_interface"] 45 | 46 | [strategies] 47 | active = "simple_macd" 48 | values = ["simple_macd", "weighted_avg_peak", "simple_boll_bands"] 49 | [strategies.simple_macd] 50 | max_spread_perc = 5 51 | limit_perc = 10 52 | stop_perc = 5 53 | [strategies.weighted_avg_peak] 54 | max_spread = 3 55 | limit_perc = 10 56 | stop_perc = 5 57 | [strategies.simple_boll_bands] 58 | window = 20 59 | limit_perc = 10 60 | stop_perc = 5 61 | -------------------------------------------------------------------------------- /test/test_ig_interface.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import toml 3 | from common.MockRequests import ( 4 | ig_request_account_details, 5 | ig_request_confirm_trade, 6 | ig_request_login, 7 | ig_request_market_info, 8 | ig_request_navigate_market, 9 | ig_request_open_positions, 10 | ig_request_prices, 11 | ig_request_search_market, 12 | ig_request_set_account, 13 | ig_request_trade, 14 | ig_request_watchlist, 15 | ) 16 | 17 | from tradingbot.components import Configuration, Interval, TradeDirection 18 | from tradingbot.components.broker import IGInterface, InterfaceNames 19 | from tradingbot.interfaces import Market, MarketHistory, Position 20 | 21 | 22 | @pytest.fixture 23 | def config(): 24 | with open("test/test_data/trading_bot.toml", "r") as f: 25 | config = toml.load(f) 26 | # Inject ig_interface as active interface in the config file 27 | config["stocks_interface"]["active"] = InterfaceNames.IG_INDEX.value 28 | config["account_interface"]["active"] = InterfaceNames.IG_INDEX.value 29 | return Configuration(config) 30 | 31 | 32 | @pytest.fixture 33 | def ig(requests_mock, config): 34 | """ 35 | Returns a instance of IGInterface 36 | """ 37 | ig_request_login(requests_mock) 38 | ig_request_set_account(requests_mock) 39 | return IGInterface(config) 40 | 41 | 42 | # No need to use requests_mock as "ig" fixture already does that 43 | def test_authenticate(ig): 44 | # Call function to test 45 | result = ig.authenticate() 46 | # Assert results 47 | assert ig.authenticated_headers["CST"] == "mock" 48 | assert ig.authenticated_headers["X-SECURITY-TOKEN"] == "mock" 49 | assert result is True 50 | 51 | 52 | def test_authenticate_fail(requests_mock, ig): 53 | ig_request_login(requests_mock, fail=True) 54 | ig_request_set_account(requests_mock, fail=True) 55 | # Call function to test 56 | result = ig.authenticate() 57 | # Assert results 58 | assert result is False 59 | 60 | 61 | # No need to use requests_mock fixture 62 | def test_set_default_account(ig): 63 | result = ig.set_default_account("mock") 64 | assert result is True 65 | 66 | 67 | def test_set_default_account_fail(requests_mock, ig): 68 | ig_request_set_account(requests_mock, fail=True) 69 | result = ig.set_default_account("mock") 70 | assert result is False 71 | 72 | 73 | def test_get_account_balances(requests_mock, ig): 74 | ig_request_account_details(requests_mock) 75 | balance, deposit = ig.get_account_balances() 76 | assert balance is not None 77 | assert deposit is not None 78 | assert balance == 16093.12 79 | assert deposit == 10000.0 80 | 81 | 82 | def test_get_account_balances_fail(requests_mock, ig): 83 | ig_request_account_details(requests_mock, fail=True) 84 | with pytest.raises(RuntimeError): 85 | balance, deposit = ig.get_account_balances() 86 | 87 | 88 | def test_get_open_positions(ig, requests_mock): 89 | ig_request_open_positions(requests_mock) 90 | 91 | positions = ig.get_open_positions() 92 | 93 | assert positions is not None 94 | assert isinstance(positions, list) 95 | assert len(positions) > 0 96 | assert isinstance(positions[0], Position) 97 | 98 | 99 | def test_get_open_positions_fail(ig, requests_mock): 100 | ig_request_open_positions(requests_mock, fail=True) 101 | with pytest.raises(RuntimeError): 102 | _ = ig.get_open_positions() 103 | 104 | 105 | def test_get_market_info(ig, requests_mock): 106 | ig_request_market_info(requests_mock) 107 | info = ig.get_market_info("mock") 108 | 109 | assert info is not None 110 | assert isinstance(info, Market) 111 | 112 | 113 | def test_get_market_info_fail(ig, requests_mock): 114 | ig_request_market_info(requests_mock, fail=True) 115 | with pytest.raises(RuntimeError): 116 | _ = ig.get_market_info("mock") 117 | 118 | 119 | def test_search_market(ig, requests_mock): 120 | ig_request_market_info(requests_mock) 121 | ig_request_search_market(requests_mock) 122 | markets = ig.search_market("mock") 123 | 124 | assert markets is not None 125 | assert isinstance(markets, list) 126 | assert len(markets) == 8 127 | assert isinstance(markets[0], Market) 128 | 129 | 130 | def test_search_market_fail(ig, requests_mock): 131 | ig_request_search_market(requests_mock, fail=True) 132 | with pytest.raises(RuntimeError): 133 | _ = ig.search_market("mock") 134 | 135 | 136 | def test_get_prices(ig, requests_mock): 137 | ig_request_market_info(requests_mock) 138 | ig_request_prices(requests_mock) 139 | p = ig.get_prices(ig.get_market_info("mock"), Interval.HOUR, 10) 140 | assert p is not None 141 | assert isinstance(p, MarketHistory) 142 | assert len(p.dataframe) > 0 143 | 144 | 145 | def test_get_prices_fail(ig, requests_mock): 146 | ig_request_market_info(requests_mock) 147 | ig_request_prices(requests_mock, fail=True) 148 | with pytest.raises(RuntimeError): 149 | _ = ig.get_prices(ig.get_market_info("mock"), Interval.HOUR, 10) 150 | 151 | 152 | def test_trade(ig, requests_mock): 153 | ig_request_trade(requests_mock) 154 | ig_request_confirm_trade(requests_mock) 155 | result = ig.trade("mock", TradeDirection.BUY, 0, 0) 156 | assert result 157 | 158 | 159 | def test_trade_fail(ig, requests_mock): 160 | ig_request_trade(requests_mock, fail=True) 161 | ig_request_confirm_trade(requests_mock, fail=True) 162 | result = ig.trade("mock", TradeDirection.BUY, 0, 0) 163 | assert result is False 164 | 165 | 166 | def test_confirm_order(ig, requests_mock): 167 | ig_request_confirm_trade(requests_mock) 168 | result = ig.confirm_order("123456789") 169 | assert result 170 | 171 | 172 | def test_confirm_order_fail(ig, requests_mock): 173 | ig_request_confirm_trade( 174 | requests_mock, 175 | data={"dealId": "123456789", "dealStatus": "REJECTED", "reason": "FAIL"}, 176 | ) 177 | result = ig.confirm_order("123456789") 178 | assert result is False 179 | 180 | ig_request_confirm_trade(requests_mock, fail=True) 181 | with pytest.raises(RuntimeError): 182 | result = ig.confirm_order("123456789") 183 | 184 | 185 | def test_close_position(ig, requests_mock): 186 | ig_request_trade(requests_mock) 187 | ig_request_confirm_trade(requests_mock) 188 | pos = Position( 189 | deal_id="123456789", 190 | size=1, 191 | create_date="mock", 192 | direction=TradeDirection.BUY, 193 | level=100, 194 | limit=110, 195 | stop=90, 196 | currency="GBP", 197 | epic="mock", 198 | market_id=None, 199 | ) 200 | result = ig.close_position(pos) 201 | assert result 202 | 203 | 204 | def test_close_position_fail(ig, requests_mock): 205 | ig_request_trade(requests_mock, fail=True) 206 | ig_request_confirm_trade(requests_mock, fail=True) 207 | pos = Position( 208 | deal_id="123456789", 209 | size=1, 210 | create_date="mock", 211 | direction=TradeDirection.BUY, 212 | level=100, 213 | limit=110, 214 | stop=90, 215 | currency="GBP", 216 | epic="mock", 217 | market_id=None, 218 | ) 219 | result = ig.close_position(pos) 220 | assert result is False 221 | 222 | 223 | def test_close_all_positions(ig, requests_mock): 224 | ig_request_open_positions(requests_mock) 225 | ig_request_trade(requests_mock) 226 | ig_request_confirm_trade(requests_mock) 227 | result = ig.close_all_positions() 228 | assert result 229 | 230 | 231 | def test_close_all_positions_fail(ig, requests_mock): 232 | ig_request_open_positions(requests_mock) 233 | ig_request_trade(requests_mock, fail=True) 234 | ig_request_confirm_trade(requests_mock) 235 | 236 | result = ig.close_all_positions() 237 | assert result is False 238 | 239 | ig_request_open_positions(requests_mock) 240 | ig_request_trade(requests_mock) 241 | ig_request_confirm_trade( 242 | requests_mock, 243 | data={"dealId": "123456789", "dealStatus": "FAIL", "reason": "FAIL"}, 244 | ) 245 | 246 | result = ig.close_all_positions() 247 | assert result is False 248 | 249 | 250 | def test_get_account_used_perc(ig, requests_mock): 251 | ig_request_account_details(requests_mock) 252 | perc = ig.get_account_used_perc() 253 | 254 | assert perc is not None 255 | assert perc == 62.138354775208285 256 | 257 | 258 | def test_get_account_used_perc_fail(ig, requests_mock): 259 | ig_request_account_details(requests_mock, fail=True) 260 | with pytest.raises(RuntimeError): 261 | _ = ig.get_account_used_perc() 262 | 263 | 264 | def test_navigate_market_node_nodes(ig, requests_mock): 265 | ig_request_navigate_market(requests_mock) 266 | data = ig.navigate_market_node("") 267 | assert "nodes" in data 268 | assert len(data["nodes"]) == 3 269 | assert data["nodes"][0]["id"] == "668394" 270 | assert data["nodes"][0]["name"] == "Cryptocurrency" 271 | assert data["nodes"][1]["id"] == "77976799" 272 | assert data["nodes"][1]["name"] == "Options (Australia 200)" 273 | assert data["nodes"][2]["id"] == "89291253" 274 | assert data["nodes"][2]["name"] == "Options (US Tech 100)" 275 | assert len(data["markets"]) == 0 276 | 277 | ig_request_navigate_market(requests_mock, fail=True) 278 | with pytest.raises(RuntimeError): 279 | data = ig.navigate_market_node("") 280 | 281 | 282 | def test_navigate_market_node_markets(ig, requests_mock): 283 | ig_request_navigate_market( 284 | requests_mock, data="mock_navigate_markets_markets.json", args="12345678" 285 | ) 286 | data = ig.navigate_market_node("12345678") 287 | assert "nodes" in data 288 | assert len(data["nodes"]) == 0 289 | assert "markets" in data 290 | assert len(data["markets"]) == 3 291 | assert data["markets"][0]["epic"] == "KC.D.AVLN8875P.DEC.IP" 292 | assert data["markets"][1]["epic"] == "KC.D.AVLN8875P.MAR.IP" 293 | assert data["markets"][2]["epic"] == "KC.D.AVLN8875P.JUN.IP" 294 | 295 | 296 | def test_get_watchlist_markets(ig, requests_mock): 297 | ig_request_market_info(requests_mock) 298 | ig_request_watchlist(requests_mock, data="mock_watchlist_list.json") 299 | ig_request_watchlist(requests_mock, args="12345678", data="mock_watchlist.json") 300 | 301 | data = ig.get_markets_from_watchlist("My Watchlist") 302 | assert isinstance(data, list) 303 | assert len(data) == 3 304 | assert isinstance(data[0], Market) 305 | 306 | data = ig.get_markets_from_watchlist("wrong_name") 307 | assert len(data) == 0 308 | 309 | ig_request_watchlist( 310 | requests_mock, args="12345678", data="mock_watchlist.json", fail=True 311 | ) 312 | data = ig.get_markets_from_watchlist("wrong_name") 313 | assert len(data) == 0 314 | -------------------------------------------------------------------------------- /test/test_market_provider.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from common.MockRequests import ( 5 | ig_request_login, 6 | ig_request_market_info, 7 | ig_request_search_market, 8 | ig_request_set_account, 9 | ig_request_watchlist, 10 | ) 11 | 12 | from tradingbot.components import Configuration, MarketProvider 13 | from tradingbot.components.broker import Broker, BrokerFactory 14 | 15 | 16 | @pytest.fixture 17 | def config(): 18 | """ 19 | Returns a dict with config parameter for strategy and simpleMACD 20 | """ 21 | return Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 22 | 23 | 24 | @pytest.fixture 25 | def broker(requests_mock, config): 26 | ig_request_login(requests_mock) 27 | ig_request_set_account(requests_mock) 28 | ig_request_market_info(requests_mock) 29 | ig_request_search_market(requests_mock) 30 | ig_request_watchlist(requests_mock, data="mock_watchlist_list.json") 31 | ig_request_watchlist(requests_mock, args="12345678", data="mock_watchlist.json") 32 | return Broker(BrokerFactory(config)) 33 | 34 | 35 | def test_market_provider_epics_list(config, broker): 36 | """ 37 | Test the MarketProvider configured to fetch markets from an epics list 38 | """ 39 | # Configure TradingBot to use an epic list 40 | config.config["market_source"]["active"] = "list" 41 | config.config["market_source"]["epic_id_list"][ 42 | "filepath" 43 | ] = "test/test_data/epics_list.txt" 44 | 45 | # Create the class to test 46 | mp = MarketProvider(config, broker) 47 | 48 | # Run the test several times resetting the market provider 49 | for _ in range(4): 50 | # Read the test epic list and create a local list of the expected epics 51 | expected_list = [] 52 | with open("test/test_data/epics_list.txt", "r") as epics_list: 53 | for cnt, line in enumerate(epics_list): 54 | epic = line.rstrip() 55 | expected_list += [epic] 56 | 57 | # Keep caling the test function building a list of returned epics 58 | actual_list = [] 59 | try: 60 | while True: 61 | actual_list.append(mp.next().epic) 62 | except StopIteration: 63 | # Verify we read all epics in the list 64 | assert len(expected_list) == len(actual_list) 65 | # Verify reading the next raise another exception 66 | with pytest.raises(StopIteration): 67 | mp.next() 68 | mp.reset() 69 | continue 70 | # If we get here it means that next did not raise an exception at the end of the list 71 | assert False 72 | 73 | 74 | def test_market_provider_watchlist(config, broker): 75 | """ 76 | Test the MarketProvider configured to fetch markets from an IG watchlist 77 | """ 78 | # Define configuration for this test 79 | config.config["market_source"]["active"] = "watchlist" 80 | # Watchlist name depending on test data json 81 | config.config["market_source"]["watchlist"]["name"] = "My Watchlist" 82 | 83 | # Create class to test 84 | mp = MarketProvider(config, broker) 85 | 86 | # The test data for market_info return always the same epic id, but the test 87 | # data for the watchlist contains 3 markets 88 | # Run the test several times resetting the market provider 89 | for _ in range(4): 90 | assert mp.next().epic == "KA.D.GSK.DAILY.IP" 91 | assert mp.next().epic == "KA.D.GSK.DAILY.IP" 92 | assert mp.next().epic == "KA.D.GSK.DAILY.IP" 93 | 94 | with pytest.raises(StopIteration): 95 | mp.next() 96 | mp.reset() 97 | 98 | 99 | def test_market_provider_api(config, broker): 100 | """ 101 | Test the MarketProvider configured to fetch markets from IG nodes 102 | """ 103 | # Define configuration for this test 104 | config.config["market_source"]["active"] = "api" 105 | 106 | # TODO 107 | # Create class to test 108 | # mp = MarketProvider(config, broker) 109 | assert True 110 | 111 | 112 | def test_market_provider_market_from_epic(config, broker): 113 | """ 114 | Test the MarketProvider get_market_from_epic() function 115 | """ 116 | # Define configuration for this test 117 | config.config["market_source"]["active"] = "list" 118 | config.config["market_source"]["epic_id_list"][ 119 | "filepath" 120 | ] = "test/test_data/epics_list.txt" 121 | 122 | # Create class to test 123 | mp = MarketProvider(config, broker) 124 | market = mp.get_market_from_epic("mock") 125 | assert market is not None 126 | assert market.epic == "KA.D.GSK.DAILY.IP" 127 | 128 | 129 | def test_search_market(config, broker, requests_mock): 130 | """ 131 | Test the MarketProvider search_market() function 132 | """ 133 | # Define configuration for this test 134 | config.config["market_source"]["active"] = "list" 135 | config.config["market_source"]["epic_id_list"][ 136 | "filepath" 137 | ] = "test/test_data/epics_list.txt" 138 | 139 | mp = MarketProvider(config, broker) 140 | 141 | # The mock search data contains multiple markets 142 | ig_request_search_market(requests_mock, data="mock_error.json") 143 | with pytest.raises(RuntimeError): 144 | _ = mp.search_market("mock") 145 | # TODO test with single market mock data and verify no exception 146 | -------------------------------------------------------------------------------- /test/test_simple_boll_bands.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from common.MockRequests import ( 5 | av_request_macd_ext, 6 | av_request_prices, 7 | ig_request_account_details, 8 | ig_request_confirm_trade, 9 | ig_request_login, 10 | ig_request_market_info, 11 | ig_request_navigate_market, 12 | ig_request_open_positions, 13 | ig_request_prices, 14 | ig_request_search_market, 15 | ig_request_set_account, 16 | ig_request_trade, 17 | ig_request_watchlist, 18 | ) 19 | 20 | from tradingbot.components import Configuration, TradeDirection 21 | from tradingbot.components.broker import Broker, BrokerFactory 22 | from tradingbot.strategies import SimpleBollingerBands 23 | 24 | 25 | @pytest.fixture 26 | def mock_http_calls(requests_mock): 27 | ig_request_login(requests_mock) 28 | ig_request_set_account(requests_mock) 29 | ig_request_account_details(requests_mock) 30 | ig_request_open_positions(requests_mock) 31 | ig_request_market_info(requests_mock) 32 | ig_request_search_market(requests_mock) 33 | ig_request_prices(requests_mock) 34 | ig_request_trade(requests_mock) 35 | ig_request_confirm_trade(requests_mock) 36 | ig_request_navigate_market(requests_mock) 37 | ig_request_navigate_market( 38 | requests_mock, args="668394", data="mock_navigate_markets_markets.json" 39 | ) 40 | ig_request_navigate_market( 41 | requests_mock, args="77976799", data="mock_navigate_markets_markets.json" 42 | ) 43 | ig_request_navigate_market( 44 | requests_mock, args="89291253", data="mock_navigate_markets_markets.json" 45 | ) 46 | ig_request_watchlist(requests_mock) 47 | ig_request_watchlist(requests_mock, args="12345678", data="mock_watchlist.json") 48 | av_request_prices(requests_mock) 49 | av_request_macd_ext(requests_mock) 50 | 51 | 52 | @pytest.fixture 53 | def config(): 54 | config = Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 55 | config.config["strategies"]["active"] = "simple_boll_bands" 56 | config.config["stocks_interface"]["active"] = "alpha_vantage" 57 | config.config["stocks_interface"]["alpha_vantage"]["api_timeout"] = 0 58 | return config 59 | 60 | 61 | @pytest.fixture 62 | def broker(config, mock_http_calls): 63 | """ 64 | Initialise the strategy with mock services 65 | """ 66 | return Broker(BrokerFactory(config)) 67 | 68 | 69 | def create_mock_market(broker): 70 | return broker.get_market_info("mock") 71 | 72 | 73 | def test_find_trade_signal_buy(config, broker, requests_mock): 74 | av_request_prices(requests_mock, data="av_daily_boll_bands_buy.json") 75 | strategy = SimpleBollingerBands(config, broker) 76 | # Create a mock market data from the json file 77 | market = create_mock_market(broker) 78 | # Call function to test 79 | data = strategy.fetch_datapoints(market) 80 | tradeDir, limit, stop = strategy.find_trade_signal(market, data) 81 | 82 | assert tradeDir is not None 83 | assert limit is not None 84 | assert stop is not None 85 | 86 | assert tradeDir == TradeDirection.BUY 87 | assert ( 88 | limit 89 | == market.offer 90 | + market.offer 91 | * config.config["strategies"]["simple_boll_bands"]["limit_perc"] 92 | / 100 93 | ) 94 | assert ( 95 | stop 96 | == market.bid 97 | - market.bid 98 | * config.config["strategies"]["simple_boll_bands"]["stop_perc"] 99 | / 100 100 | ) 101 | 102 | 103 | def test_find_trade_signal_sell(config, broker, requests_mock): 104 | av_request_prices(requests_mock, data="av_daily_boll_bands_sell.json") 105 | strategy = SimpleBollingerBands(config, broker) 106 | # Create a mock market data from the json file 107 | market = create_mock_market(broker) 108 | data = strategy.fetch_datapoints(market) 109 | tradeDir, limit, stop = strategy.find_trade_signal(market, data) 110 | 111 | assert tradeDir is TradeDirection.NONE 112 | assert limit is None 113 | assert stop is None 114 | 115 | 116 | def test_find_trade_signal_hold(config, broker, requests_mock): 117 | av_request_prices(requests_mock) 118 | strategy = SimpleBollingerBands(config, broker) 119 | # Create a mock market data from the json file 120 | market = create_mock_market(broker) 121 | data = strategy.fetch_datapoints(market) 122 | tradeDir, limit, stop = strategy.find_trade_signal(market, data) 123 | 124 | assert tradeDir is not None 125 | assert limit is None 126 | assert stop is None 127 | 128 | assert tradeDir == TradeDirection.NONE 129 | -------------------------------------------------------------------------------- /test/test_simple_macd.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from common.MockRequests import ( 5 | av_request_macd_ext, 6 | av_request_prices, 7 | ig_request_account_details, 8 | ig_request_confirm_trade, 9 | ig_request_login, 10 | ig_request_market_info, 11 | ig_request_navigate_market, 12 | ig_request_open_positions, 13 | ig_request_prices, 14 | ig_request_search_market, 15 | ig_request_set_account, 16 | ig_request_trade, 17 | ig_request_watchlist, 18 | ) 19 | 20 | from tradingbot.components import Configuration, TradeDirection 21 | from tradingbot.components.broker import Broker, BrokerFactory 22 | from tradingbot.strategies import SimpleMACD 23 | 24 | 25 | @pytest.fixture 26 | def mock_http_calls(requests_mock): 27 | ig_request_login(requests_mock) 28 | ig_request_set_account(requests_mock) 29 | ig_request_account_details(requests_mock) 30 | ig_request_open_positions(requests_mock) 31 | ig_request_market_info(requests_mock) 32 | ig_request_search_market(requests_mock) 33 | ig_request_prices(requests_mock) 34 | ig_request_trade(requests_mock) 35 | ig_request_confirm_trade(requests_mock) 36 | ig_request_navigate_market(requests_mock) 37 | ig_request_navigate_market( 38 | requests_mock, args="668394", data="mock_navigate_markets_markets.json" 39 | ) 40 | ig_request_navigate_market( 41 | requests_mock, args="77976799", data="mock_navigate_markets_markets.json" 42 | ) 43 | ig_request_navigate_market( 44 | requests_mock, args="89291253", data="mock_navigate_markets_markets.json" 45 | ) 46 | ig_request_watchlist(requests_mock) 47 | ig_request_watchlist(requests_mock, args="12345678", data="mock_watchlist.json") 48 | av_request_prices(requests_mock) 49 | av_request_macd_ext(requests_mock) 50 | 51 | 52 | @pytest.fixture 53 | def config(): 54 | config = Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 55 | config.config["strategies"]["active"] = "simple_macd" 56 | config.config["stocks_interface"]["active"] = "alpha_vantage" 57 | config.config["stocks_interface"]["alpha_vantage"]["api_timeout"] = 0 58 | return config 59 | 60 | 61 | @pytest.fixture 62 | def broker(config, mock_http_calls): 63 | """ 64 | Initialise the strategy with mock services 65 | """ 66 | return Broker(BrokerFactory(config)) 67 | 68 | 69 | def create_mock_market(broker): 70 | return broker.get_market_info("mock") 71 | 72 | 73 | def test_find_trade_signal_buy(config, broker, requests_mock): 74 | av_request_macd_ext(requests_mock, data="mock_macd_ext_buy.json") 75 | strategy = SimpleMACD(config, broker) 76 | # Create a mock market data from the json file 77 | market = create_mock_market(broker) 78 | # Call function to test 79 | data = strategy.fetch_datapoints(market) 80 | tradeDir, limit, stop = strategy.find_trade_signal(market, data) 81 | 82 | assert tradeDir is not None 83 | assert limit is not None 84 | assert stop is not None 85 | 86 | assert tradeDir == TradeDirection.BUY 87 | 88 | 89 | def test_find_trade_signal_sell(config, broker, requests_mock): 90 | av_request_macd_ext(requests_mock, data="mock_macd_ext_sell.json") 91 | strategy = SimpleMACD(config, broker) 92 | # Create a mock market data from the json file 93 | market = create_mock_market(broker) 94 | data = strategy.fetch_datapoints(market) 95 | tradeDir, limit, stop = strategy.find_trade_signal(market, data) 96 | 97 | assert tradeDir is not None 98 | assert limit is not None 99 | assert stop is not None 100 | 101 | assert tradeDir == TradeDirection.SELL 102 | 103 | 104 | def test_find_trade_signal_hold(config, broker, requests_mock): 105 | av_request_macd_ext(requests_mock, data="mock_macd_ext_hold.json") 106 | strategy = SimpleMACD(config, broker) 107 | # Create a mock market data from the json file 108 | market = create_mock_market(broker) 109 | data = strategy.fetch_datapoints(market) 110 | tradeDir, limit, stop = strategy.find_trade_signal(market, data) 111 | 112 | assert tradeDir is not None 113 | assert limit is None 114 | assert stop is None 115 | 116 | assert tradeDir == TradeDirection.NONE 117 | 118 | 119 | def test_find_trade_signal_exception(config): 120 | # TODO provide wrong data and assert exception thrown 121 | assert True 122 | 123 | 124 | def test_calculate_stop_limit(config, broker): 125 | strategy = SimpleMACD(config, broker) 126 | limit, stop = strategy.calculate_stop_limit(TradeDirection.BUY, 100, 100, 10, 10) 127 | assert limit == 110 128 | assert stop == 90 129 | 130 | limit, stop = strategy.calculate_stop_limit(TradeDirection.SELL, 100, 100, 10, 10) 131 | assert limit == 90 132 | assert stop == 110 133 | 134 | with pytest.raises(ValueError): 135 | limit, stop = strategy.calculate_stop_limit( 136 | TradeDirection.NONE, 100, 100, 10, 10 137 | ) 138 | 139 | 140 | def test_generate_signals_from_dataframe(config, broker, requests_mock): 141 | av_request_macd_ext(requests_mock, data="mock_macd_ext_hold.json") 142 | strategy = SimpleMACD(config, broker) 143 | data = strategy.fetch_datapoints(create_mock_market(broker)) 144 | px = strategy.generate_signals_from_dataframe(data.dataframe) 145 | 146 | assert "positions" in px 147 | assert len(px) > 26 148 | # TODO add more checks 149 | 150 | 151 | def test_get_trade_direction_from_signals(config, broker, requests_mock): 152 | av_request_macd_ext(requests_mock, data="mock_macd_ext_buy.json") 153 | strategy = SimpleMACD(config, broker) 154 | data = strategy.fetch_datapoints(create_mock_market(broker)) 155 | dataframe = strategy.generate_signals_from_dataframe(data.dataframe) 156 | tradeDir = strategy.get_trade_direction_from_signals(dataframe) 157 | 158 | # BUY becasue the mock response loads the buy test json 159 | assert tradeDir == TradeDirection.BUY 160 | 161 | 162 | # TODO 163 | # def test_backtest(config, broker, requests_mock): 164 | # ig_request_market_info(requests_mock) 165 | # ig_request_prices(requests_mock) 166 | # # av_request_macd_ext(requests_mock, data="mock_macd_ext_buy.json") 167 | # # av_request_prices(requests_mock) 168 | 169 | # strategy = SimpleMACD(config, broker) 170 | 171 | # # Create a mock market data from the json file 172 | # market = create_mock_market(broker, requests_mock) 173 | 174 | # result = strategy.backtest( 175 | # market, 176 | # dt.strptime("2018-01-01", "%Y-%m-%d"), 177 | # dt.strptime("2018-06-01", "%Y-%m-%d"), 178 | # ) 179 | 180 | # assert "balance" in result 181 | # assert result["balance"] is not None 182 | # assert result["balance"] == 997.9299999999998 183 | # assert "trades" in result 184 | # assert len(result["trades"]) == 8 185 | -------------------------------------------------------------------------------- /test/test_strategy_factory.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from tradingbot.components import Configuration 6 | from tradingbot.strategies import SimpleMACD, StrategyFactory, WeightedAvgPeak 7 | 8 | 9 | @pytest.fixture 10 | def config(): 11 | return Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 12 | 13 | 14 | @pytest.fixture 15 | def broker(): 16 | return "mock" 17 | 18 | 19 | def test_make_strategy_fail(config, broker): 20 | sf = StrategyFactory(config, broker) 21 | with pytest.raises(ValueError): 22 | _ = sf.make_strategy("") 23 | 24 | with pytest.raises(ValueError): 25 | _ = sf.make_strategy("wrong") 26 | 27 | 28 | def test_make_strategy(config, broker): 29 | sf = StrategyFactory(config, broker) 30 | strategy = sf.make_strategy("simple_macd") 31 | assert isinstance(strategy, SimpleMACD) 32 | 33 | strategy = sf.make_strategy("weighted_avg_peak") 34 | assert isinstance(strategy, WeightedAvgPeak) 35 | -------------------------------------------------------------------------------- /test/test_time_provider.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | import pytz 5 | from govuk_bank_holidays.bank_holidays import BankHolidays 6 | 7 | from tradingbot.components import TimeAmount, TimeProvider, Utils 8 | 9 | 10 | def test_get_seconds_to_market_opening(): 11 | tp = TimeProvider() 12 | now = datetime.now() 13 | seconds = tp.get_seconds_to_market_opening(now) 14 | assert seconds > 0 15 | assert seconds is not None 16 | opening = now + timedelta(seconds=seconds) 17 | assert 0 <= opening.weekday() < 5 18 | expected = opening.replace(hour=8, minute=0, second=2, microsecond=0) 19 | diff = expected - opening 20 | assert diff.seconds < 10 21 | # Test function if called after midnight but before market opening 22 | mock = datetime.now().replace(hour=3, minute=30, second=0, microsecond=0) 23 | seconds = tp.get_seconds_to_market_opening(mock) 24 | assert seconds > 0 25 | assert seconds is not None 26 | opening = mock + timedelta(seconds=seconds) 27 | # assert opening.day == mock.day 28 | assert opening.hour == 8 29 | assert opening.minute == 0 30 | 31 | 32 | def test_is_market_open(): 33 | tp = TimeProvider() 34 | timezone = "Europe/London" 35 | tz = pytz.timezone(timezone) 36 | now_time = datetime.now(tz=tz).strftime("%H:%M") 37 | expected = Utils.is_between( 38 | str(now_time), ("07:55", "16:35") 39 | ) and BankHolidays().is_work_day(datetime.now(tz=tz)) 40 | 41 | result = tp.is_market_open(timezone) 42 | 43 | assert result == expected 44 | 45 | 46 | def test_wait_for(): 47 | tp = TimeProvider() 48 | # Invalid seconds 49 | with pytest.raises(ValueError): 50 | tp.wait_for(TimeAmount.SECONDS) 51 | with pytest.raises(ValueError): 52 | tp.wait_for(TimeAmount.SECONDS, -100) 53 | # Wait for 3 seconds 54 | now = datetime.now() 55 | tp.wait_for(TimeAmount.SECONDS, 3) 56 | new = datetime.now() 57 | delta = new - now 58 | assert delta.seconds == 3 59 | -------------------------------------------------------------------------------- /test/test_trading_bot.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from common.MockRequests import ( 5 | av_request_macd_ext, 6 | av_request_prices, 7 | ig_request_account_details, 8 | ig_request_confirm_trade, 9 | ig_request_login, 10 | ig_request_market_info, 11 | ig_request_navigate_market, 12 | ig_request_open_positions, 13 | ig_request_prices, 14 | ig_request_search_market, 15 | ig_request_set_account, 16 | ig_request_trade, 17 | ig_request_watchlist, 18 | yf_request_prices, 19 | ) 20 | 21 | from tradingbot import TradingBot 22 | from tradingbot.components import TimeProvider 23 | 24 | 25 | class MockTimeProvider(TimeProvider): 26 | def __init__(self): 27 | pass 28 | 29 | def is_market_open(self, timezone): 30 | return True 31 | 32 | def get_seconds_to_market_opening(self, from_time): 33 | raise Exception 34 | 35 | def wait_for(self, time_amount_type, amount=-1): 36 | pass 37 | 38 | 39 | @pytest.fixture 40 | def mock_http_calls(requests_mock): 41 | ig_request_login(requests_mock) 42 | ig_request_set_account(requests_mock) 43 | ig_request_account_details(requests_mock) 44 | ig_request_open_positions(requests_mock) 45 | ig_request_market_info(requests_mock) 46 | ig_request_search_market(requests_mock) 47 | ig_request_prices(requests_mock) 48 | ig_request_trade(requests_mock) 49 | ig_request_confirm_trade(requests_mock) 50 | ig_request_navigate_market(requests_mock) 51 | ig_request_navigate_market( 52 | requests_mock, args="668394", data="mock_navigate_markets_markets.json" 53 | ) 54 | ig_request_navigate_market( 55 | requests_mock, args="77976799", data="mock_navigate_markets_markets.json" 56 | ) 57 | ig_request_navigate_market( 58 | requests_mock, args="89291253", data="mock_navigate_markets_markets.json" 59 | ) 60 | ig_request_watchlist(requests_mock) 61 | ig_request_watchlist(requests_mock, args="12345678", data="mock_watchlist.json") 62 | av_request_prices(requests_mock) 63 | av_request_macd_ext(requests_mock) 64 | yf_request_prices(requests_mock) 65 | 66 | 67 | def test_trading_bot(mock_http_calls): 68 | """ 69 | Test trading bot main functions 70 | """ 71 | config = Path("test/test_data/trading_bot.toml") 72 | tb = TradingBot(MockTimeProvider(), config_filepath=config) 73 | assert tb is not None 74 | # This is a hack because we are testing the functions used within 75 | # the start() method. We can't call start() because it gets into 76 | # an endless while loop 77 | tb.process_open_positions() 78 | with pytest.raises(StopIteration): 79 | tb.process_market_source() 80 | tb.close_open_positions() 81 | # TODO assert somehow that the http calls have been done 82 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | from tradingbot.components import Utils 2 | 3 | 4 | def test_midpoint(): 5 | assert Utils.midpoint(0, 10) == 5 6 | assert Utils.midpoint(-10, 10) == 0 7 | assert Utils.midpoint(10, -10) == 0 8 | assert Utils.midpoint(0, 0) == 0 9 | assert Utils.midpoint(1, 2) == 1.5 10 | 11 | 12 | def test_percentage_of(): 13 | assert Utils.percentage_of(1, 100) == 1 14 | assert Utils.percentage_of(0, 100) == 0 15 | assert Utils.percentage_of(1, 1) == 0.01 16 | 17 | 18 | def test_percentage(): 19 | assert Utils.percentage(1, 100) == 1 20 | assert Utils.percentage(0, 100) == 0 21 | assert Utils.percentage(200, 100) == 200 22 | # with pytest.raises(Exception): 23 | # assert Utils.percentage(-1,100) 24 | # with pytest.raises(Exception): 25 | # assert Utils.percentage(1,-100) 26 | 27 | 28 | def test_is_between(): 29 | mock = "10:10" 30 | assert Utils.is_between(mock, ("10:09", "10:11")) 31 | mock = "00:00" 32 | assert Utils.is_between(mock, ("23:59", "00:01")) 33 | 34 | 35 | def test_humanize_time(): 36 | assert Utils.humanize_time(3600) == "01:00:00" 37 | assert Utils.humanize_time(4800) == "01:20:00" 38 | assert Utils.humanize_time(4811) == "01:20:11" 39 | -------------------------------------------------------------------------------- /test/test_weighted_avg_peak.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from common.MockRequests import ( 5 | ig_request_confirm_trade, 6 | ig_request_login, 7 | ig_request_market_info, 8 | ig_request_prices, 9 | ig_request_set_account, 10 | ig_request_trade, 11 | ) 12 | 13 | from tradingbot.components import Configuration, TradeDirection 14 | from tradingbot.components.broker import Broker, BrokerFactory 15 | from tradingbot.strategies import WeightedAvgPeak 16 | 17 | 18 | @pytest.fixture 19 | def config(): 20 | config = Configuration.from_filepath(Path("test/test_data/trading_bot.toml")) 21 | config.config["strategies"]["active"] = "weighted_avg_peak" 22 | return config 23 | 24 | 25 | @pytest.fixture 26 | def broker(config, requests_mock): 27 | """ 28 | Initialise the strategy with mock services 29 | """ 30 | ig_request_login(requests_mock) 31 | ig_request_set_account(requests_mock) 32 | return Broker(BrokerFactory(config)) 33 | 34 | 35 | def test_find_trade_signal(config, broker, requests_mock): 36 | ig_request_login(requests_mock) 37 | ig_request_set_account(requests_mock) 38 | ig_request_prices(requests_mock) 39 | ig_request_trade(requests_mock) 40 | ig_request_confirm_trade(requests_mock) 41 | ig_request_market_info(requests_mock) 42 | 43 | strategy = WeightedAvgPeak(config, broker) 44 | 45 | # Need to use a mock enum as the requests_mock expect "mock" as interval 46 | market = broker.get_market_info("mock") 47 | prices = strategy.fetch_datapoints(market) 48 | tradeDir, limit, stop = strategy.find_trade_signal(market, prices) 49 | 50 | assert tradeDir is not None 51 | assert limit is None 52 | assert stop is None 53 | 54 | assert tradeDir == TradeDirection.NONE 55 | -------------------------------------------------------------------------------- /tradingbot/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from pathlib import Path 4 | 5 | from .components import TimeProvider 6 | from .trading_bot import TradingBot 7 | 8 | 9 | def get_menu_parser() -> argparse.Namespace: 10 | VERSION = "2.0.0" 11 | parser = argparse.ArgumentParser(prog="TradingBot") 12 | parser.add_argument( 13 | "-f", 14 | "--config", 15 | help="Configuration file path", 16 | metavar="FILEPATH", 17 | type=Path, 18 | ) 19 | main_group = parser.add_mutually_exclusive_group() 20 | main_group.add_argument( 21 | "-v", "--version", action="version", version="%(prog)s {}".format(VERSION) 22 | ) 23 | main_group.add_argument( 24 | "-c", 25 | "--close-positions", 26 | help="Close all the open positions", 27 | action="store_true", 28 | ) 29 | main_group.add_argument( 30 | "-b", 31 | "--backtest", 32 | help="Backtest the market related to the specified id", 33 | nargs=1, 34 | metavar="MARKET_ID", 35 | ) 36 | main_group.add_argument( 37 | "-s", 38 | "--single-pass", 39 | help="Run a single iteration on the market source", 40 | action="store_true", 41 | ) 42 | backtest_group = parser.add_argument_group("Backtesting") 43 | backtest_group.add_argument( 44 | "--epic", 45 | help="IG epic of the market to backtest. MARKET_ID will be ignored", 46 | nargs=1, 47 | metavar="EPIC_ID", 48 | default=None, 49 | ) 50 | backtest_group.add_argument( 51 | "--start", 52 | help="Start date for the strategy backtest", 53 | nargs=1, 54 | metavar="YYYY-MM-DD", 55 | required="--backtest" in sys.argv, 56 | ) 57 | backtest_group.add_argument( 58 | "--end", 59 | help="End date for the strategy backtest", 60 | nargs=1, 61 | metavar="YYYY-MM-DD", 62 | required="--backtest" in sys.argv, 63 | ) 64 | return parser.parse_args() 65 | 66 | 67 | def main() -> None: 68 | args = get_menu_parser() 69 | bot = TradingBot(time_provider=TimeProvider(), config_filepath=args.config) 70 | if args.close_positions: 71 | bot.close_open_positions() 72 | elif args.backtest and args.start and args.end: 73 | epic = args.epic[0] if args.epic else None 74 | bot.backtest(args.backtest[0], args.start[0], args.end[0], epic) 75 | else: 76 | bot.start(single_pass=args.single_pass) 77 | -------------------------------------------------------------------------------- /tradingbot/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from . import main 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /tradingbot/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .configuration import ( # NOQA # isort:skip 2 | Configuration, 3 | ConfigDict, 4 | CredentialDict, 5 | DEFAULT_CONFIGURATION_PATH, 6 | ) 7 | from .utils import ( # NOQA # isort:skip 8 | Interval, 9 | MarketClosedException, 10 | NotSafeToTradeException, 11 | Singleton, 12 | SynchSingleton, 13 | TradeDirection, 14 | Utils, 15 | ) 16 | from .backtester import Backtester # NOQA # isort:skip 17 | from .market_provider import MarketProvider, MarketSource # NOQA # isort:skip 18 | from .time_provider import TimeProvider, TimeAmount # NOQA # isort:skip 19 | -------------------------------------------------------------------------------- /tradingbot/components/backtester.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | from ..interfaces import Market 6 | from ..strategies import BacktestResult, StrategyImpl 7 | from .broker import Broker 8 | 9 | 10 | class Backtester: 11 | """ 12 | Provides capability to backtest markets on a defined range of time 13 | """ 14 | 15 | broker: Broker 16 | strategy: StrategyImpl 17 | result: Optional[BacktestResult] 18 | 19 | def __init__(self, broker: Broker, strategy: StrategyImpl) -> None: 20 | logging.info("Backtester created") 21 | self.broker = broker 22 | self.strategy = strategy 23 | self.result = None 24 | 25 | def start(self, market: Market, start_dt: datetime, end_dt: datetime) -> None: 26 | """Backtest the given market within the specified range""" 27 | logging.info( 28 | "Backtester started for market id {} from {} to {}".format( 29 | market.id, start_dt.date(), end_dt.date() 30 | ) 31 | ) 32 | self.result = self.strategy.backtest(market, start_dt, end_dt) 33 | 34 | def print_results(self) -> None: 35 | """Print backtest result in log file""" 36 | logging.info("Backtest result:") 37 | logging.info(self.result) 38 | -------------------------------------------------------------------------------- /tradingbot/components/broker/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_interfaces import ( # NOQA # isort:skip 2 | AbstractInterface, 3 | AccountBalances, 4 | StocksInterface, 5 | AccountInterface, 6 | ) 7 | from .av_interface import AVInterface, AVInterval # NOQA # isort:skip 8 | from .ig_interface import IGInterface, IG_API_URL # NOQA # isort:skip 9 | from .yf_interface import YFinanceInterface, YFInterval # NOQA # isort:skip 10 | from .factories import BrokerFactory, InterfaceNames # NOQA # isort:skip 11 | from .broker import Broker # NOQA # isort:skip 12 | -------------------------------------------------------------------------------- /tradingbot/components/broker/abstract_interfaces.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import time 3 | from abc import abstractmethod 4 | from typing import Any, Dict, List, Optional, Tuple 5 | 6 | from ...interfaces import Market, MarketHistory, MarketMACD, Position 7 | from .. import Configuration, Interval, SynchSingleton, TradeDirection 8 | 9 | AccountBalances = Tuple[Optional[float], Optional[float]] 10 | 11 | 12 | # TODO ABC can't be used anymore as base class if we define the metaclass 13 | class AbstractInterface(metaclass=SynchSingleton): 14 | def __init__(self, config: Configuration) -> None: 15 | self._config = config 16 | self._last_call_ts = dt.datetime.now() 17 | self.initialise() 18 | 19 | def _wait_before_call(self, timeout: float) -> None: 20 | """ 21 | Wait between API calls to not overload the server 22 | """ 23 | while (dt.datetime.now() - self._last_call_ts) <= dt.timedelta(seconds=timeout): 24 | time.sleep(0.5) 25 | self._last_call_ts = dt.datetime.now() 26 | 27 | @abstractmethod 28 | def initialise(self) -> None: 29 | pass 30 | 31 | 32 | class AccountInterface(AbstractInterface): 33 | # This MUST not be overwritten. Use the "initialise()" to init a children interface 34 | def __init__(self, config: Configuration) -> None: 35 | super().__init__(config) 36 | 37 | @abstractmethod 38 | def authenticate(self) -> bool: 39 | pass 40 | 41 | @abstractmethod 42 | def set_default_account(self, account_id: str) -> bool: 43 | pass 44 | 45 | @abstractmethod 46 | def get_account_balances(self) -> AccountBalances: 47 | pass 48 | 49 | @abstractmethod 50 | def get_open_positions(self) -> List[Position]: 51 | pass 52 | 53 | @abstractmethod 54 | def get_positions_map(self) -> Dict[str, int]: 55 | pass 56 | 57 | @abstractmethod 58 | def get_market_info(self, market_ticker: str) -> Market: 59 | pass 60 | 61 | @abstractmethod 62 | def search_market(self, search_string: str) -> List[Market]: 63 | pass 64 | 65 | @abstractmethod 66 | def trade( 67 | self, ticker: str, direction: TradeDirection, limit: float, stop: float 68 | ) -> bool: 69 | pass 70 | 71 | @abstractmethod 72 | def close_position(self, position: Position) -> bool: 73 | pass 74 | 75 | @abstractmethod 76 | def close_all_positions(self) -> bool: 77 | pass 78 | 79 | @abstractmethod 80 | def get_account_used_perc(self) -> Optional[float]: 81 | pass 82 | 83 | @abstractmethod 84 | def get_markets_from_watchlist(self, watchlist_id: str) -> List[Market]: 85 | pass 86 | 87 | @abstractmethod 88 | def navigate_market_node(self, node_id: str) -> Dict[str, Any]: 89 | pass 90 | 91 | 92 | class StocksInterface(AbstractInterface): 93 | # This MUST not be overwritten. Use the "initialise()" to init a children interface 94 | def __init__(self, config: Configuration) -> None: 95 | super().__init__(config) 96 | 97 | @abstractmethod 98 | def get_prices( 99 | self, market: Market, interval: Interval, data_range: int 100 | ) -> MarketHistory: 101 | pass 102 | 103 | @abstractmethod 104 | def get_macd( 105 | self, market: Market, interval: Interval, data_range: int 106 | ) -> MarketMACD: 107 | pass 108 | -------------------------------------------------------------------------------- /tradingbot/components/broker/av_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import traceback 4 | from enum import Enum 5 | 6 | import pandas 7 | from alpha_vantage.techindicators import TechIndicators 8 | from alpha_vantage.timeseries import TimeSeries 9 | 10 | from ...interfaces import Market, MarketHistory, MarketMACD 11 | from .. import Interval 12 | from . import StocksInterface 13 | 14 | 15 | class AVInterval(Enum): 16 | """ 17 | AlphaVantage interval types: '1min', '5min', '15min', '30min', '60min' 18 | """ 19 | 20 | MIN_1 = "1min" 21 | MIN_5 = "5min" 22 | MIN_15 = "15min" 23 | MIN_30 = "30min" 24 | MIN_60 = "60min" 25 | DAILY = "daily" 26 | WEEKLY = "weekly" 27 | MONTHLY = "monthly" 28 | 29 | 30 | class AVInterface(StocksInterface): 31 | """ 32 | AlphaVantage interface class, provides methods to call AlphaVantage API 33 | and return the result in useful format handling possible errors. 34 | """ 35 | 36 | def initialise(self) -> None: 37 | logging.info("Initialising AVInterface...") 38 | api_key = self._config.get_credentials()["av_api_key"] 39 | self.TS = TimeSeries( 40 | key=api_key, output_format="pandas", treat_info_as_error=True 41 | ) 42 | self.TI = TechIndicators( 43 | key=api_key, output_format="pandas", treat_info_as_error=True 44 | ) 45 | 46 | def _to_av_interval(self, interval: Interval) -> AVInterval: 47 | """ 48 | Convert the Broker Interval to AlphaVantage compatible intervals. 49 | Return the converted interval or None if a conversion is not available 50 | """ 51 | if interval == Interval.MINUTE_1: 52 | return AVInterval.MIN_1 53 | elif interval == Interval.MINUTE_5: 54 | return AVInterval.MIN_5 55 | elif interval == Interval.MINUTE_15: 56 | return AVInterval.MIN_15 57 | elif interval == Interval.MINUTE_30: 58 | return AVInterval.MIN_30 59 | elif interval == Interval.HOUR: 60 | return AVInterval.MIN_60 61 | elif interval == Interval.DAY: 62 | return AVInterval.DAILY 63 | elif interval == Interval.WEEK: 64 | return AVInterval.WEEKLY 65 | elif interval == Interval.MONTH: 66 | return AVInterval.MONTHLY 67 | else: 68 | logging.error( 69 | "Unable to convert interval {} to AlphaVantage equivalent".format( 70 | interval.value 71 | ) 72 | ) 73 | raise ValueError("Unsupported Interval value: {}".format(interval)) 74 | 75 | def get_prices( 76 | self, market: Market, interval: Interval, data_range: int 77 | ) -> MarketHistory: 78 | data = None 79 | av_interval = self._to_av_interval(interval) 80 | if ( 81 | av_interval == AVInterval.MIN_1 82 | or av_interval == AVInterval.MIN_5 83 | or av_interval == AVInterval.MIN_15 84 | or av_interval == AVInterval.MIN_30 85 | or av_interval == AVInterval.MIN_60 86 | ): 87 | data = self.intraday(market.id, av_interval) 88 | elif av_interval == AVInterval.DAILY: 89 | data = self.daily(market.id) 90 | elif av_interval == AVInterval.WEEKLY: 91 | data = self.weekly(market.id) 92 | # TODO implement monthly call 93 | else: 94 | raise ValueError("Unsupported Interval.{}".format(interval.name)) 95 | history = MarketHistory( 96 | market, 97 | data.index, 98 | data["2. high"].values, 99 | data["3. low"].values, 100 | data["4. close"].values, 101 | data["5. volume"].values, 102 | ) 103 | return history 104 | 105 | def daily(self, marketId: str) -> pandas.DataFrame: 106 | """ 107 | Calls AlphaVantage API and return the Daily time series for the given market 108 | 109 | - **marketId**: string representing an AlphaVantage compatible market id 110 | - Returns **None** if an error occurs otherwise the pandas dataframe 111 | """ 112 | self._wait_before_call(self._config.get_alphavantage_api_timeout()) 113 | market = self._format_market_id(marketId) 114 | try: 115 | data, meta_data = self.TS.get_daily(symbol=market, outputsize="full") 116 | return data 117 | except Exception as e: 118 | print(e) 119 | logging.error("AlphaVantage wrong api call for {}".format(market)) 120 | logging.debug(e) 121 | logging.debug(traceback.format_exc()) 122 | logging.debug(sys.exc_info()[0]) 123 | return None 124 | 125 | def intraday(self, marketId: str, interval: AVInterval) -> pandas.DataFrame: 126 | """ 127 | Calls AlphaVantage API and return the Intraday time series for the given market 128 | 129 | - **marketId**: string representing an AlphaVantage compatible market id 130 | - **interval**: string representing an AlphaVantage interval type 131 | - Returns **None** if an error occurs otherwise the pandas dataframe 132 | """ 133 | self._wait_before_call(self._config.get_alphavantage_api_timeout()) 134 | market = self._format_market_id(marketId) 135 | try: 136 | data, meta_data = self.TS.get_intraday( 137 | symbol=market, interval=interval.value, outputsize="full" 138 | ) 139 | return data 140 | except Exception as e: 141 | logging.error("AlphaVantage wrong api call for {}".format(market)) 142 | logging.debug(e) 143 | logging.debug(traceback.format_exc()) 144 | logging.debug(sys.exc_info()[0]) 145 | return None 146 | 147 | def weekly(self, marketId: str) -> pandas.DataFrame: 148 | """ 149 | Calls AlphaVantage API and return the Weekly time series for the given market 150 | 151 | - **marketId**: string representing an AlphaVantage compatible market id 152 | - Returns **None** if an error occurs otherwise the pandas dataframe 153 | """ 154 | self._wait_before_call(self._config.get_alphavantage_api_timeout()) 155 | market = self._format_market_id(marketId) 156 | try: 157 | data, meta_data = self.TS.get_weekly(symbol=market) 158 | return data 159 | except Exception as e: 160 | logging.error("AlphaVantage wrong api call for {}".format(market)) 161 | logging.debug(e) 162 | logging.debug(traceback.format_exc()) 163 | logging.debug(sys.exc_info()[0]) 164 | return None 165 | 166 | def quote_endpoint(self, market_id: str) -> pandas.DataFrame: 167 | """ 168 | Calls AlphaVantage API and return the Quote Endpoint data for the given market 169 | 170 | - **market_id**: string representing the market id to fetch data of 171 | - Returns **None** if an error occurs otherwise the pandas dataframe 172 | """ 173 | self._wait_before_call(self._config.get_alphavantage_api_timeout()) 174 | market = self._format_market_id(market_id) 175 | try: 176 | data, meta_data = self.TS.get_quote_endpoint( 177 | symbol=market, outputsize="full" 178 | ) 179 | return data 180 | except Exception: 181 | logging.error("AlphaVantage wrong api call for {}".format(market)) 182 | return None 183 | 184 | # Technical indicators 185 | 186 | def get_macd( 187 | self, market: Market, interval: Interval, datapoints_range: int 188 | ) -> MarketMACD: 189 | av_interval = self._to_av_interval(interval) 190 | data = self.macdext(market.id, av_interval) 191 | macd = MarketMACD( 192 | market, 193 | data.index, 194 | data["MACD"].values, 195 | data["MACD_Signal"].values, 196 | data["MACD_Hist"].values, 197 | ) 198 | return macd 199 | 200 | def macdext(self, marketId: str, interval: AVInterval) -> pandas.DataFrame: 201 | """ 202 | Calls AlphaVantage API and return the MACDEXT tech indicator series for the given market 203 | 204 | - **marketId**: string representing an AlphaVantage compatible market id 205 | - **interval**: string representing an AlphaVantage interval type 206 | - Returns **None** if an error occurs otherwise the pandas dataframe 207 | """ 208 | self._wait_before_call(self._config.get_alphavantage_api_timeout()) 209 | market = self._format_market_id(marketId) 210 | data, meta_data = self.TI.get_macdext( 211 | market, 212 | interval=interval.value, 213 | series_type="close", 214 | fastperiod=12, 215 | slowperiod=26, 216 | signalperiod=9, 217 | fastmatype=2, 218 | slowmatype=1, 219 | signalmatype=0, 220 | ) 221 | return data 222 | 223 | def macd(self, marketId: str, interval: AVInterval) -> pandas.DataFrame: 224 | """ 225 | Calls AlphaVantage API and return the MACDEXT tech indicator series for the given market 226 | 227 | - **marketId**: string representing an AlphaVantage compatible market id 228 | - **interval**: string representing an AlphaVantage interval type 229 | - Returns **None** if an error occurs otherwise the pandas dataframe 230 | """ 231 | self._wait_before_call(self._config.get_alphavantage_api_timeout()) 232 | market = self._format_market_id(marketId) 233 | data, meta_data = self.TI.get_macd( 234 | market, 235 | interval=interval.value, 236 | series_type="close", 237 | fastperiod=12, 238 | slowperiod=26, 239 | signalperiod=9, 240 | ) 241 | return data 242 | 243 | # Utils functions 244 | 245 | def _format_market_id(self, marketId: str) -> str: 246 | """ 247 | Convert a standard market id to be compatible with AlphaVantage API. 248 | Adds the market exchange prefix (i.e. London is LON:) 249 | """ 250 | # TODO MarketProvider/IGInterface should return marketId without "-UK" 251 | return "{}:{}".format("LON", marketId.split("-")[0]) 252 | -------------------------------------------------------------------------------- /tradingbot/components/broker/broker.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | from ...interfaces import Market, MarketHistory, MarketMACD, Position 4 | from .. import Interval, TradeDirection 5 | from . import AccountInterface, BrokerFactory, StocksInterface 6 | 7 | 8 | class Broker: 9 | """ 10 | This class provides a template interface for all those broker related 11 | actions/tasks wrapping the actual implementation class internally 12 | """ 13 | 14 | factory: BrokerFactory 15 | stocks_ifc: StocksInterface 16 | account_ifc: AccountInterface 17 | 18 | def __init__(self, factory: BrokerFactory) -> None: 19 | self.factory = factory 20 | self.stocks_ifc = self.factory.make_stock_interface_from_config() 21 | self.account_ifc = self.factory.make_account_interface_from_config() 22 | 23 | def get_open_positions(self) -> List[Position]: 24 | """ 25 | Returns the current open positions 26 | """ 27 | return self.account_ifc.get_open_positions() 28 | 29 | def get_markets_from_watchlist(self, watchlist_name: str) -> List[Market]: 30 | """ 31 | Return a name list of the markets in the required watchlist 32 | """ 33 | return self.account_ifc.get_markets_from_watchlist(watchlist_name) 34 | 35 | def navigate_market_node(self, node_id: str) -> Dict[str, Any]: 36 | """ 37 | Return the children nodes of the requested node 38 | """ 39 | return self.account_ifc.navigate_market_node(node_id) 40 | 41 | def get_account_used_perc(self) -> Optional[float]: 42 | """ 43 | Returns the account used value in percentage 44 | """ 45 | return self.account_ifc.get_account_used_perc() 46 | 47 | def close_all_positions(self) -> bool: 48 | """ 49 | Attempt to close all the current open positions 50 | """ 51 | return self.account_ifc.close_all_positions() 52 | 53 | def close_position(self, position: Position) -> bool: 54 | """ 55 | Attempt to close the requested open position 56 | """ 57 | return self.account_ifc.close_position(position) 58 | 59 | def trade( 60 | self, market_id: str, trade_direction: TradeDirection, limit: float, stop: float 61 | ): 62 | """ 63 | Request a trade of the given market 64 | """ 65 | return self.account_ifc.trade(market_id, trade_direction, limit, stop) 66 | 67 | def get_market_info(self, market_id: str) -> Market: 68 | """ 69 | Return the last available snapshot of the requested market 70 | """ 71 | return self.account_ifc.get_market_info(market_id) 72 | 73 | def search_market(self, search: str) -> List[Market]: 74 | """ 75 | Search for a market from a search string 76 | """ 77 | return self.account_ifc.search_market(search) 78 | 79 | def get_macd( 80 | self, market: Market, interval: Interval, datapoints_range: int 81 | ) -> MarketMACD: 82 | """ 83 | Return a pandas dataframe containing MACD technical indicator 84 | for the requested market with requested interval 85 | """ 86 | return self.stocks_ifc.get_macd(market, interval, datapoints_range) 87 | 88 | def get_prices( 89 | self, market: Market, interval: Interval, data_range: int 90 | ) -> MarketHistory: 91 | """ 92 | Returns past prices for the given market 93 | 94 | - market: market to query prices for 95 | - interval: resolution of the time series: minute, hours, etc. 96 | - data_range: amount of past datapoint to fetch 97 | - Returns the MarketHistory instance 98 | """ 99 | return self.stocks_ifc.get_prices(market, interval, data_range) 100 | -------------------------------------------------------------------------------- /tradingbot/components/broker/factories.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import TypeVar, Union 3 | 4 | from .. import Configuration 5 | from . import ( 6 | AccountInterface, 7 | AVInterface, 8 | IGInterface, 9 | StocksInterface, 10 | YFinanceInterface, 11 | ) 12 | 13 | AccountInterfaceImpl = TypeVar("AccountInterfaceImpl", bound=AccountInterface) 14 | StocksInterfaceImpl = TypeVar("StocksInterfaceImpl", bound=StocksInterface) 15 | BrokerInterfaces = Union[AccountInterfaceImpl, StocksInterfaceImpl] 16 | 17 | 18 | class InterfaceNames(Enum): 19 | IG_INDEX = "ig_interface" 20 | ALPHA_VANTAGE = "alpha_vantage" 21 | YAHOO_FINANCE = "yfinance" 22 | 23 | 24 | class BrokerFactory: 25 | config: Configuration 26 | 27 | def __init__(self, config: Configuration) -> None: 28 | self.config = config 29 | 30 | def make(self, name: str) -> BrokerInterfaces: 31 | if name == InterfaceNames.IG_INDEX.value: 32 | return IGInterface(self.config) 33 | elif name == InterfaceNames.ALPHA_VANTAGE.value: 34 | return AVInterface(self.config) 35 | elif name == InterfaceNames.YAHOO_FINANCE.value: 36 | return YFinanceInterface(self.config) 37 | else: 38 | raise ValueError("Interface {} not supported".format(name)) 39 | 40 | def make_stock_interface_from_config( 41 | self, 42 | ) -> BrokerInterfaces: 43 | return self.make(self.config.get_active_stocks_interface()) 44 | 45 | def make_account_interface_from_config( 46 | self, 47 | ) -> BrokerInterfaces: 48 | return self.make(self.config.get_active_account_interface()) 49 | -------------------------------------------------------------------------------- /tradingbot/components/broker/yf_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | 4 | import yfinance as yf 5 | 6 | from ...interfaces import Market, MarketHistory, MarketMACD 7 | from .. import Interval, Utils 8 | from . import StocksInterface 9 | 10 | 11 | class YFInterval(Enum): 12 | MIN_1 = "1m" 13 | MIN_2 = "2m" 14 | MIN_5 = "5m" 15 | MIN_15 = "15" 16 | MIN_30 = "30m" 17 | MIN_60 = "60m" 18 | MIN_90 = "90m" 19 | HOUR = "1h" 20 | DAY_1 = "1d" 21 | DAY_5 = "5d" 22 | WEEK_1 = "1wk" 23 | MONTH_1 = "1mo" 24 | MONTH_3 = "3mo" 25 | 26 | 27 | class YFinanceInterface(StocksInterface): 28 | def initialise(self) -> None: 29 | logging.info("Initialising YFinanceInterface...") 30 | 31 | def get_prices( 32 | self, market: Market, interval: Interval, data_range: int 33 | ) -> MarketHistory: 34 | self._wait_before_call(self._config.get_yfinance_api_timeout()) 35 | 36 | ticker = yf.Ticker(self._format_market_id(market.id)) 37 | data = ticker.history( 38 | period=self._to_yf_data_range(data_range), 39 | interval=self._to_yf_interval(interval).value, 40 | ) 41 | # Reverse dataframe to have most recent data at the top 42 | data = data.iloc[::-1] 43 | history = MarketHistory( 44 | market, 45 | data.index, 46 | data["High"].values, 47 | data["Low"].values, 48 | data["Close"].values, 49 | data["Volume"].values, 50 | ) 51 | return history 52 | 53 | def get_macd( 54 | self, market: Market, interval: Interval, data_range: int 55 | ) -> MarketMACD: 56 | self._wait_before_call(self._config.get_yfinance_api_timeout()) 57 | # Fetch prices with at least 26 data points 58 | prices = self.get_prices(market, interval, 30) 59 | data = Utils.macd_df_from_list( 60 | prices.dataframe[MarketHistory.CLOSE_COLUMN].values 61 | ) 62 | # TODO use dates instead of index 63 | return MarketMACD( 64 | market, 65 | data.index, 66 | data["MACD"].values, 67 | data["Signal"].values, 68 | data["Hist"].values, 69 | ) 70 | 71 | def _format_market_id(self, market_id: str) -> str: 72 | market_id = market_id.replace("-UK", "") 73 | return "{}.L".format(market_id) 74 | 75 | def _to_yf_interval(self, interval: Interval) -> YFInterval: 76 | if interval == Interval.MINUTE_1: 77 | return YFInterval.MIN_1 78 | elif interval == Interval.MINUTE_2: 79 | return YFInterval.MIN_2 80 | elif interval == Interval.MINUTE_3: 81 | raise ValueError("Interval.MINUTE_3 not supported") 82 | elif interval == Interval.MINUTE_5: 83 | return YFInterval.MIN_5 84 | elif interval == Interval.MINUTE_10: 85 | raise ValueError("Interval.MINUTE_10 not supported") 86 | elif interval == Interval.MINUTE_15: 87 | return YFInterval.MIN_15 88 | elif interval == Interval.MINUTE_30: 89 | return YFInterval.MIN_30 90 | elif interval == Interval.HOUR: 91 | return YFInterval.HOUR 92 | elif interval == Interval.HOUR_2: 93 | raise ValueError("Interval.HOUR_2 not supported") 94 | elif interval == Interval.HOUR_3: 95 | raise ValueError("Interval.HOUR_3 not supported") 96 | elif interval == Interval.HOUR_4: 97 | raise ValueError("Interval.HOUR_4 not supported") 98 | elif interval == Interval.DAY: 99 | return YFInterval.DAY_1 100 | elif interval == Interval.WEEK: 101 | return YFInterval.DAY_5 102 | elif interval == Interval.MONTH: 103 | return YFInterval.MONTH_1 104 | raise ValueError("Unsupported interval {}".format(interval.name)) 105 | 106 | def _to_yf_data_range(self, days: int) -> str: 107 | # Values: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max 108 | if days < 2: 109 | return "1d" 110 | elif days < 6: 111 | return "5d" 112 | elif days < 32: 113 | return "1mo" 114 | elif days < 93: 115 | return "3mo" 116 | elif days < 186: 117 | return "6mo" 118 | elif days < 366: 119 | return "1y" 120 | elif days < 732: 121 | return "2y" 122 | elif days < 1830: 123 | return "5y" 124 | elif days < 3660: 125 | return "10y" 126 | else: 127 | return "max" 128 | -------------------------------------------------------------------------------- /tradingbot/components/configuration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import datetime 4 | from pathlib import Path 5 | from typing import Any, Dict, List, MutableMapping, Optional, Union 6 | 7 | import toml 8 | 9 | DEFAULT_CONFIGURATION_PATH = Path.home() / ".TradingBot" / "config" / "trading_bot.toml" 10 | CONFIGURATION_ROOT = "trading_bot_root" 11 | 12 | # FIXME Property should be of type JSON byt it requires typing to accepts recursive types 13 | Property = Any 14 | ConfigDict = MutableMapping[str, Property] 15 | CredentialDict = Dict[str, str] 16 | 17 | 18 | class Configuration: 19 | config: ConfigDict 20 | 21 | def __init__(self, dictionary: ConfigDict) -> None: 22 | if not isinstance(dictionary, dict): 23 | raise ValueError("argument must be a dict") 24 | self.config = self._parse_raw_config(dictionary) 25 | logging.info("Configuration loaded") 26 | 27 | @staticmethod 28 | def from_filepath(filepath: Optional[Path]) -> "Configuration": 29 | filepath = filepath if filepath else DEFAULT_CONFIGURATION_PATH 30 | logging.debug("Loading configuration: {}".format(filepath)) 31 | with filepath.open(mode="r") as f: 32 | return Configuration(toml.load(f)) 33 | 34 | def _find_property(self, fields: List[str]) -> Union[ConfigDict, Property]: 35 | if CONFIGURATION_ROOT in fields: 36 | return self.config 37 | if type(fields) is not list or len(fields) < 1: 38 | raise ValueError("Can't find properties {} in configuration".format(fields)) 39 | value = self.config[fields[0]] 40 | for f in fields[1:]: 41 | value = value[f] 42 | return value 43 | 44 | def _parse_raw_config(self, config_dict: ConfigDict) -> ConfigDict: 45 | config_copy = config_dict 46 | for key, value in config_copy.items(): 47 | if type(value) is dict: 48 | config_dict[key] = self._parse_raw_config(value) 49 | elif type(value) is list: 50 | for i in range(len(value)): 51 | config_dict[key][i] = ( 52 | self._replace_placeholders(config_dict[key][i]) 53 | if type(config_dict[key][i]) is str 54 | else config_dict[key][i] 55 | ) 56 | elif type(value) is str: 57 | config_dict[key] = self._replace_placeholders(config_dict[key]) 58 | return config_dict 59 | 60 | def _replace_placeholders(self, string: str) -> str: 61 | string = string.replace("{home}", str(Path.home())) 62 | string = string.replace( 63 | "{timestamp}", 64 | datetime.now().isoformat().replace(":", "_").replace(".", "_"), 65 | ) 66 | return string 67 | 68 | def get_raw_config(self) -> ConfigDict: 69 | return self._find_property([CONFIGURATION_ROOT]) 70 | 71 | def get_max_account_usable(self) -> Property: 72 | return self._find_property(["max_account_usable"]) 73 | 74 | def get_time_zone(self) -> Property: 75 | return self._find_property(["time_zone"]) 76 | 77 | def get_credentials_filepath(self) -> Property: 78 | return self._find_property(["credentials_filepath"]) 79 | 80 | def get_credentials(self) -> CredentialDict: 81 | with Path(self.get_credentials_filepath()).open(mode="r") as f: 82 | return json.load(f) 83 | 84 | def get_spin_interval(self) -> Property: 85 | return self._find_property(["spin_interval"]) 86 | 87 | def is_logging_enabled(self) -> Property: 88 | return self._find_property(["logging", "enable"]) 89 | 90 | def get_log_filepath(self) -> Property: 91 | return self._find_property(["logging", "log_filepath"]) 92 | 93 | def is_logging_debug_enabled(self) -> Property: 94 | return self._find_property(["logging", "debug"]) 95 | 96 | def get_active_market_source(self) -> Property: 97 | return self._find_property(["market_source", "active"]) 98 | 99 | def get_market_source_values(self) -> Property: 100 | return self._find_property(["market_source", "values"]) 101 | 102 | def get_epic_ids_filepath(self) -> Property: 103 | return self._find_property(["market_source", "epic_id_list", "filepath"]) 104 | 105 | def get_watchlist_name(self) -> Property: 106 | return self._find_property(["market_source", "watchlist", "name"]) 107 | 108 | def get_active_stocks_interface(self) -> Property: 109 | return self._find_property(["stocks_interface", "active"]) 110 | 111 | def get_stocks_interface_values(self) -> Property: 112 | return self._find_property(["stocks_interface", "values"]) 113 | 114 | def get_ig_order_type(self) -> Property: 115 | return self._find_property(["stocks_interface", "ig_interface", "order_type"]) 116 | 117 | def get_ig_order_size(self) -> Property: 118 | return self._find_property(["stocks_interface", "ig_interface", "order_size"]) 119 | 120 | def get_ig_order_expiry(self) -> Property: 121 | return self._find_property(["stocks_interface", "ig_interface", "order_expiry"]) 122 | 123 | def get_ig_order_currency(self) -> Property: 124 | return self._find_property( 125 | ["stocks_interface", "ig_interface", "order_currency"] 126 | ) 127 | 128 | def get_ig_order_force_open(self) -> Property: 129 | return self._find_property( 130 | ["stocks_interface", "ig_interface", "order_force_open"] 131 | ) 132 | 133 | def get_ig_use_g_stop(self) -> Property: 134 | return self._find_property(["stocks_interface", "ig_interface", "use_g_stop"]) 135 | 136 | def get_ig_use_demo_account(self) -> Property: 137 | return self._find_property( 138 | ["stocks_interface", "ig_interface", "use_demo_account"] 139 | ) 140 | 141 | def get_ig_controlled_risk(self): 142 | return self._find_property( 143 | ["stocks_interface", "ig_interface", "controlled_risk"] 144 | ) 145 | 146 | def get_ig_api_timeout(self) -> Property: 147 | return self._find_property(["stocks_interface", "ig_interface", "api_timeout"]) 148 | 149 | def is_paper_trading_enabled(self) -> Property: 150 | return self._find_property(["paper_trading"]) 151 | 152 | def get_alphavantage_api_timeout(self) -> Property: 153 | return self._find_property(["stocks_interface", "alpha_vantage", "api_timeout"]) 154 | 155 | def get_yfinance_api_timeout(self) -> Property: 156 | return self._find_property(["stocks_interface", "yfinance", "api_timeout"]) 157 | 158 | def get_active_account_interface(self) -> Property: 159 | return self._find_property(["account_interface", "active"]) 160 | 161 | def get_account_interface_values(self) -> Property: 162 | return self._find_property(["account_interface", "values"]) 163 | 164 | def get_active_strategy(self) -> Property: 165 | return self._find_property(["strategies", "active"]) 166 | 167 | def get_strategies_values(self) -> Property: 168 | return self._find_property(["strategies", "values"]) 169 | -------------------------------------------------------------------------------- /tradingbot/components/market_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import deque 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import Deque, Iterator, List 6 | 7 | from ..interfaces import Market 8 | from . import Configuration 9 | from .broker import Broker 10 | 11 | 12 | class MarketSource(Enum): 13 | """ 14 | Available market sources: local file list, watch list, market navigation 15 | through API, etc. 16 | """ 17 | 18 | LIST = "list" 19 | WATCHLIST = "watchlist" 20 | API = "api" 21 | 22 | 23 | class MarketProvider: 24 | """ 25 | Provide markets from different sources based on configuration. Supports 26 | market lists, dynamic market exploration or watchlists 27 | """ 28 | 29 | config: Configuration 30 | broker: Broker 31 | epic_list: List[str] = [] 32 | epic_list_iter: Iterator[str] 33 | market_list_iter: Iterator[Market] 34 | node_stack: Deque[str] 35 | 36 | def __init__(self, config: Configuration, broker: Broker) -> None: 37 | self.config = config 38 | self.broker = broker 39 | self._initialise() 40 | 41 | def next(self) -> Market: 42 | """ 43 | Return the next market from the configured source 44 | """ 45 | source = self.config.get_active_market_source() 46 | if source == MarketSource.LIST.value: 47 | return self._next_from_epic_list() 48 | elif source == MarketSource.WATCHLIST.value: 49 | return self._next_from_market_list() 50 | elif source == MarketSource.API.value: 51 | return self._next_from_api() 52 | else: 53 | raise RuntimeError("ERROR: invalid market_source configuration") 54 | 55 | def reset(self) -> None: 56 | """ 57 | Reset internal market pointer to the beginning 58 | """ 59 | logging.info("Resetting MarketProvider") 60 | self._initialise() 61 | 62 | def get_market_from_epic(self, epic: str) -> Market: 63 | """ 64 | Given a market epic id returns the related market snapshot 65 | """ 66 | return self._create_market(epic) 67 | 68 | def search_market(self, search: str) -> Market: 69 | """ 70 | Tries to find the market which id matches the given search string. 71 | If successful return the market snapshot. 72 | Raise an exception when multiple markets match the search string 73 | """ 74 | markets = self.broker.search_market(search) 75 | if markets is None or len(markets) < 1: 76 | raise RuntimeError( 77 | "ERROR: Unable to find market matching: {}".format(search) 78 | ) 79 | else: 80 | # Iterate through the list and use a set to verify that the results are all the same market 81 | epic_set = set() 82 | for m in markets: 83 | # Epic are in format: KC.D.PRSMLN.DAILY.IP. Extract third element 84 | market_id = m.epic.split(".")[2] 85 | # Store the DFB epic 86 | if "DFB" in m.expiry and "DAILY" in m.epic: 87 | epic_set.add(market_id) 88 | if not len(epic_set) == 1: 89 | raise RuntimeError( 90 | "ERROR: Multiple markets match the search string: {}".format(search) 91 | ) 92 | # Good, it means the result are all the same market 93 | return markets[0] 94 | 95 | def _initialise(self) -> None: 96 | # Initialise epic list 97 | self.epic_list = [] 98 | self.epic_list_iter = iter([]) 99 | self.market_list_iter = iter([]) 100 | # Initialise API members 101 | self.node_stack = deque() 102 | source = self.config.get_active_market_source() 103 | if source == MarketSource.LIST.value: 104 | self.epic_list = self._load_epic_ids_from_local_file( 105 | Path(self.config.get_epic_ids_filepath()) 106 | ) 107 | elif source == MarketSource.WATCHLIST.value: 108 | market_list = self._load_markets_from_watchlist( 109 | self.config.get_watchlist_name() 110 | ) 111 | self.market_list_iter = iter(market_list) 112 | elif source == MarketSource.API.value: 113 | self.epic_list = self._load_epic_ids_from_api_node("180500") 114 | else: 115 | raise RuntimeError("ERROR: invalid market_source configuration") 116 | self.epic_list_iter = iter(self.epic_list) 117 | 118 | def _load_epic_ids_from_local_file(self, filepath: Path) -> List[str]: 119 | """ 120 | Read a file from filesystem containing a list of epic ids. 121 | The filepath is defined in the configuration file 122 | Returns a 'list' of strings where each string is a market epic 123 | """ 124 | # define empty list 125 | epic_ids = [] 126 | try: 127 | # open file and read the content in a list 128 | with filepath.open(mode="r") as f: 129 | filecontents = f.readlines() 130 | for line in filecontents: 131 | # remove linebreak which is the last character of the string 132 | current_epic_id = line[:-1] 133 | epic_ids.append(current_epic_id) 134 | except IOError: 135 | # Create the file empty 136 | logging.error("{} does not exist!".format(filepath)) 137 | if len(epic_ids) < 1: 138 | logging.error("Epic list is empty!") 139 | return epic_ids 140 | 141 | def _next_from_epic_list(self) -> Market: 142 | try: 143 | epic = next(self.epic_list_iter) 144 | return self._create_market(epic) 145 | except Exception: 146 | raise StopIteration 147 | 148 | def _next_from_market_list(self) -> Market: 149 | try: 150 | return next(self.market_list_iter) 151 | except Exception: 152 | raise StopIteration 153 | 154 | def _load_markets_from_watchlist(self, watchlist_name: str) -> List[Market]: 155 | markets = self.broker.get_markets_from_watchlist( 156 | self.config.get_watchlist_name() 157 | ) 158 | if markets is None: 159 | message = "Watchlist {} not found!".format(watchlist_name) 160 | logging.error(message) 161 | raise RuntimeError(message) 162 | return markets 163 | 164 | def _load_epic_ids_from_api_node(self, node_id: str) -> List[str]: 165 | node = self.broker.navigate_market_node(node_id) 166 | if "nodes" in node and isinstance(node["nodes"], list): 167 | for node in node["nodes"]: 168 | self.node_stack.append(node["id"]) 169 | return self._load_epic_ids_from_api_node(self.node_stack.pop()) 170 | if "markets" in node and isinstance(node["markets"], list): 171 | return [ 172 | market["epic"] 173 | for market in node["markets"] 174 | if any( 175 | [ 176 | "DFB" in str(market["epic"]), 177 | "TODAY" in str(market["epic"]), 178 | "DAILY" in str(market["epic"]), 179 | ] 180 | ) 181 | ] 182 | return [] 183 | 184 | def _next_from_api(self) -> Market: 185 | # Return the next item in the epic_list, but if the list is finished 186 | # navigate the next node in the stack and return a new list 187 | try: 188 | return self._next_from_epic_list() 189 | except Exception: 190 | self.epic_list = self._load_epic_ids_from_api_node(self.node_stack.pop()) 191 | self.epic_list_iter = iter(self.epic_list) 192 | return self._next_from_epic_list() 193 | 194 | def _create_market(self, epic_id: str) -> Market: 195 | market = self.broker.get_market_info(epic_id) 196 | if market is None: 197 | raise RuntimeError("Unable to fetch data for {}".format(epic_id)) 198 | return market 199 | -------------------------------------------------------------------------------- /tradingbot/components/time_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import datetime 4 | from enum import Enum 5 | 6 | import pytz 7 | from govuk_bank_holidays.bank_holidays import BankHolidays 8 | 9 | from . import Utils 10 | 11 | 12 | class TimeAmount(Enum): 13 | """Types of amount of time to wait for""" 14 | 15 | SECONDS = 0 16 | NEXT_MARKET_OPENING = 1 17 | 18 | 19 | class TimeProvider: 20 | """Class that handle functions dependents on actual time 21 | such as wait, sleep or compute date/time operations 22 | """ 23 | 24 | def __init__(self) -> None: 25 | logging.debug("TimeProvider __init__") 26 | 27 | def is_market_open(self, timezone: str) -> bool: 28 | """ 29 | Return True if the market is open, false otherwise 30 | 31 | - **timezone**: string representing the timezone 32 | """ 33 | tz = pytz.timezone(timezone) 34 | now_time = datetime.now(tz=tz).strftime("%H:%M") 35 | return BankHolidays().is_work_day(datetime.now(tz=tz)) and Utils.is_between( 36 | str(now_time), ("07:55", "16:35") 37 | ) 38 | 39 | def get_seconds_to_market_opening(self, from_time: datetime) -> float: 40 | """Return the amount of seconds from now to the next market opening, 41 | taking into account UK bank holidays and weekends""" 42 | today_opening = datetime( 43 | year=from_time.year, 44 | month=from_time.month, 45 | day=from_time.day, 46 | hour=8, 47 | minute=0, 48 | second=0, 49 | microsecond=0, 50 | ) 51 | 52 | if from_time < today_opening and BankHolidays().is_work_day(from_time.date()): 53 | nextMarketOpening = today_opening 54 | else: 55 | # Get next working day 56 | nextWorkDate = BankHolidays().get_next_work_day(date=from_time.date()) 57 | nextMarketOpening = datetime( 58 | year=nextWorkDate.year, 59 | month=nextWorkDate.month, 60 | day=nextWorkDate.day, 61 | hour=8, 62 | minute=0, 63 | second=0, 64 | microsecond=0, 65 | ) 66 | # Calculate the delta from from_time to the next market opening 67 | return (nextMarketOpening - from_time).total_seconds() 68 | 69 | def wait_for(self, time_amount_type: TimeAmount, amount: float = -1.0) -> None: 70 | """Wait for the specified amount of time. 71 | An TimeAmount type can be specified 72 | """ 73 | if time_amount_type is TimeAmount.NEXT_MARKET_OPENING: 74 | amount = self.get_seconds_to_market_opening(datetime.now()) 75 | elif time_amount_type is TimeAmount.SECONDS: 76 | if amount < 0: 77 | raise ValueError("Invalid amount of time to wait for") 78 | logging.info("Wait for {0:.2f} hours...".format(amount / 3600)) 79 | time.sleep(amount) 80 | -------------------------------------------------------------------------------- /tradingbot/components/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import threading 3 | from enum import Enum 4 | from typing import Any, Dict, List, Tuple, Union 5 | 6 | import pandas 7 | 8 | 9 | class TradeDirection(Enum): 10 | """ 11 | Enumeration that represents the trade direction in the market: NONE means 12 | no action to take. 13 | """ 14 | 15 | NONE = "NONE" 16 | BUY = "BUY" 17 | SELL = "SELL" 18 | 19 | 20 | class Interval(Enum): 21 | """ 22 | Time intervals for price and technical indicators requests 23 | """ 24 | 25 | MINUTE_1 = "MINUTE_1" 26 | MINUTE_2 = "MINUTE_2" 27 | MINUTE_3 = "MINUTE_3" 28 | MINUTE_5 = "MINUTE_5" 29 | MINUTE_10 = "MINUTE_10" 30 | MINUTE_15 = "MINUTE_15" 31 | MINUTE_30 = "MINUTE_30" 32 | HOUR = "HOUR" 33 | HOUR_2 = "HOUR_2" 34 | HOUR_3 = "HOUR_3" 35 | HOUR_4 = "HOUR_4" 36 | DAY = "DAY" 37 | WEEK = "WEEK" 38 | MONTH = "MONTH" 39 | 40 | 41 | class MarketClosedException(Exception): 42 | """Error to notify that the market is currently closed""" 43 | 44 | pass 45 | 46 | 47 | class NotSafeToTradeException(Exception): 48 | """Error to notify that it is not safe to trade""" 49 | 50 | pass 51 | 52 | 53 | # Mutex used for thread synchronisation 54 | lock: threading.Lock = threading.Lock() 55 | 56 | 57 | def synchronised(lock: threading.Lock) -> Any: 58 | """Thread synchronization decorator""" 59 | 60 | def wrapper(f: Any) -> Any: 61 | @functools.wraps(f) 62 | def inner_wrapper(*args: Any, **kw: Any) -> Any: 63 | with lock: 64 | return f(*args, **kw) 65 | 66 | return inner_wrapper 67 | 68 | return wrapper 69 | 70 | 71 | class SynchSingleton(type): 72 | """Metaclass to implement the Singleton desing pattern""" 73 | 74 | _instances: Dict[Any, Any] = {} 75 | 76 | @synchronised(lock) 77 | def __call__(cls, *args: Any, **kwargs: Any) -> Any: 78 | if cls not in cls._instances: 79 | cls._instances[cls] = super().__call__(*args, **kwargs) 80 | return cls._instances[cls] 81 | 82 | 83 | class Singleton(type): 84 | """Metaclass to implement the Singleton desing pattern""" 85 | 86 | _instances: Dict[Any, Any] = {} 87 | 88 | def __call__(cls, *args: Any, **kwargs: Any) -> Any: 89 | if cls not in cls._instances: 90 | cls._instances[cls] = super().__call__(*args, **kwargs) 91 | return cls._instances[cls] 92 | 93 | 94 | class Utils: 95 | """ 96 | Utility class containing static methods to perform simple general actions 97 | """ 98 | 99 | def __init__(self) -> None: 100 | pass 101 | 102 | @staticmethod 103 | def midpoint(p1: Union[int, float], p2: Union[int, float]) -> Union[int, float]: 104 | """Return the midpoint""" 105 | return (p1 + p2) / 2 106 | 107 | @staticmethod 108 | def percentage_of( 109 | percent: Union[int, float], whole: Union[int, float] 110 | ) -> Union[int, float]: 111 | """Return the value of the percentage on the whole""" 112 | return (percent * whole) / 100.0 113 | 114 | @staticmethod 115 | def percentage( 116 | part: Union[int, float], whole: Union[int, float] 117 | ) -> Union[int, float]: 118 | """Return the percentage value of the part on the whole""" 119 | return 100 * float(part) / float(whole) 120 | 121 | @staticmethod 122 | def is_between(time: str, time_range: Tuple[str, str]): 123 | """Return True if time is between the time_range. time must be a string. 124 | time_range must be a tuple (a,b) where a and b are strings in format 'HH:MM'""" 125 | if time_range[1] < time_range[0]: 126 | return time >= time_range[0] or time <= time_range[1] 127 | return time_range[0] <= time <= time_range[1] 128 | 129 | @staticmethod 130 | def humanize_time(secs: Union[int, float]) -> str: 131 | """Convert the given time (in seconds) into a readable format hh:mm:ss""" 132 | mins, secs = divmod(secs, 60) 133 | hours, mins = divmod(mins, 60) 134 | return "%02d:%02d:%02d" % (hours, mins, secs) 135 | 136 | @staticmethod 137 | def macd_df_from_list(price_list: List[float]) -> pandas.DataFrame: 138 | """Return a MACD pandas dataframe with columns "MACD", "Signal" and "Hist""" 139 | px = pandas.DataFrame({"close": price_list}) 140 | px["26_ema"] = pandas.DataFrame.ewm(px["close"], span=26).mean() 141 | px["12_ema"] = pandas.DataFrame.ewm(px["close"], span=12).mean() 142 | px["MACD"] = px["12_ema"] - px["26_ema"] 143 | px["Signal"] = px["MACD"].rolling(9).mean() 144 | px["Hist"] = px["MACD"] - px["Signal"] 145 | return px 146 | -------------------------------------------------------------------------------- /tradingbot/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | from .market import Market # NOQA # isort:skip 2 | from .market_history import MarketHistory # NOQA # isort:skip 3 | from .market_macd import MarketMACD # NOQA # isort:skip 4 | from .position import Position # NOQA # isort:skip 5 | -------------------------------------------------------------------------------- /tradingbot/interfaces/market.py: -------------------------------------------------------------------------------- 1 | class Market: 2 | """ 3 | Represent a tradable market with latest price information 4 | """ 5 | 6 | epic: str = "unknown" 7 | id: str = "unknown" 8 | name: str = "unknown" 9 | bid: float = 0.0 10 | offer: float = 0.0 11 | high: float = 0.0 12 | low: float = 0.0 13 | stop_distance_min: float = 0.0 14 | expiry: str = "unknown" 15 | 16 | def __init__(self) -> None: 17 | pass 18 | -------------------------------------------------------------------------------- /tradingbot/interfaces/market_history.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pandas 4 | 5 | from . import Market 6 | 7 | 8 | class MarketHistory: 9 | DATE_COLUMN: str = "date" 10 | HIGH_COLUMN: str = "high" 11 | LOW_COLUMN: str = "low" 12 | CLOSE_COLUMN: str = "close" 13 | VOLUME_COLUMN: str = "volume" 14 | 15 | market: Market 16 | dataframe: pandas.DataFrame 17 | 18 | def __init__( 19 | self, 20 | market: Market, 21 | date: List[str], 22 | high: List[float], 23 | low: List[float], 24 | close: List[float], 25 | volume: List[float], 26 | ) -> None: 27 | self.market = market 28 | self.dataframe = pandas.DataFrame( 29 | columns=[ 30 | self.DATE_COLUMN, 31 | self.HIGH_COLUMN, 32 | self.LOW_COLUMN, 33 | self.CLOSE_COLUMN, 34 | self.VOLUME_COLUMN, 35 | ] 36 | ) 37 | # TODO if date is None or empty use index 38 | self.dataframe[self.DATE_COLUMN] = date 39 | self.dataframe[self.HIGH_COLUMN] = high 40 | self.dataframe[self.LOW_COLUMN] = low 41 | self.dataframe[self.CLOSE_COLUMN] = close 42 | self.dataframe[self.VOLUME_COLUMN] = volume 43 | -------------------------------------------------------------------------------- /tradingbot/interfaces/market_macd.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pandas 4 | 5 | from . import Market 6 | 7 | 8 | class MarketMACD: 9 | DATE_COLUMN: str = "Date" 10 | MACD_COLUMN: str = "MACD" 11 | SIGNAL_COLUMN: str = "Signal" 12 | HIST_COLUMN: str = "Hist" 13 | 14 | market: Market 15 | dataframe: pandas.DataFrame 16 | 17 | def __init__( 18 | self, 19 | market: Market, 20 | date: List[str], 21 | macd: List[float], 22 | signal: List[float], 23 | hist: List[float], 24 | ) -> None: 25 | self.market = market 26 | self.dataframe = pandas.DataFrame( 27 | columns=[ 28 | self.DATE_COLUMN, 29 | self.MACD_COLUMN, 30 | self.SIGNAL_COLUMN, 31 | self.HIST_COLUMN, 32 | ] 33 | ) 34 | # TODO if date is None or empty use index 35 | self.dataframe[self.DATE_COLUMN] = date 36 | self.dataframe[self.MACD_COLUMN] = macd 37 | self.dataframe[self.SIGNAL_COLUMN] = signal 38 | self.dataframe[self.HIST_COLUMN] = hist 39 | -------------------------------------------------------------------------------- /tradingbot/interfaces/position.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ..components import TradeDirection 4 | 5 | 6 | class Position: 7 | 8 | deal_id: str 9 | size: int 10 | create_date: str 11 | direction: TradeDirection 12 | level: float 13 | limit: float 14 | stop: float 15 | currency: str 16 | epic: str 17 | market_id: str 18 | 19 | def __init__(self, **kargs: Any) -> None: 20 | self.deal_id = kargs["deal_id"] 21 | self.size = kargs["size"] 22 | self.create_date = kargs["create_date"] 23 | self.direction = kargs["direction"] 24 | self.level = kargs["level"] 25 | self.limit = kargs["limit"] 26 | self.stop = kargs["stop"] 27 | self.currency = kargs["currency"] 28 | self.epic = kargs["epic"] 29 | self.market_id = kargs["market_id"] 30 | -------------------------------------------------------------------------------- /tradingbot/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( # NOQA # isort:skip 2 | BacktestResult, 3 | DataPoints, 4 | Strategy, 5 | TradeSignal, 6 | ) 7 | from .simple_macd import SimpleMACD # NOQA # isort:skip 8 | from .weighted_avg_peak import WeightedAvgPeak # NOQA # isort:skip 9 | from .simple_bollinger_bands import SimpleBollingerBands # NOQA # isort:skip 10 | from .factories import ( # NOQA # isort:skip 11 | StrategyFactory, 12 | StrategyNames, 13 | StrategyImpl, 14 | ) 15 | -------------------------------------------------------------------------------- /tradingbot/strategies/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | from datetime import datetime 4 | from typing import Any, Dict, List, Optional, Tuple, Union 5 | 6 | from ..components import Configuration, TradeDirection 7 | from ..components.broker import Broker 8 | from ..interfaces import Market, Position 9 | 10 | DataPoints = Any 11 | BacktestResult = Dict[str, Union[float, List[Tuple[str, TradeDirection, float]]]] 12 | TradeSignal = Tuple[TradeDirection, Optional[float], Optional[float]] 13 | 14 | 15 | class Strategy(ABC): 16 | """ 17 | Generic strategy template to use as a parent class for custom strategies. 18 | """ 19 | 20 | positions: Optional[List[Position]] = None 21 | broker: Broker 22 | 23 | def __init__(self, config: Configuration, broker: Broker) -> None: 24 | self.positions = None 25 | self.broker = broker 26 | # Read configuration of derived Strategy 27 | self.read_configuration(config) 28 | # Initialise derived Strategy 29 | self.initialise() 30 | 31 | def set_open_positions(self, positions: List[Position]) -> None: 32 | """ 33 | Set the account open positions 34 | """ 35 | self.positions = positions 36 | 37 | def run(self, market: Market) -> TradeSignal: 38 | """ 39 | Run the strategy against the specified market 40 | """ 41 | datapoints = self.fetch_datapoints(market) 42 | logging.debug("Strategy datapoints: {}".format(datapoints)) 43 | if datapoints is None: 44 | logging.debug("Unable to fetch market datapoints") 45 | return TradeDirection.NONE, None, None 46 | return self.find_trade_signal(market, datapoints) 47 | 48 | ############################################################# 49 | # OVERRIDE THESE FUNCTIONS IN STRATEGY IMPLEMENTATION 50 | ############################################################# 51 | 52 | @abstractmethod 53 | def initialise(self) -> None: 54 | pass 55 | 56 | @abstractmethod 57 | def read_configuration(self, config: Configuration) -> None: 58 | pass 59 | 60 | @abstractmethod 61 | def fetch_datapoints(self, market: Market) -> DataPoints: 62 | pass 63 | 64 | @abstractmethod 65 | def find_trade_signal(self, market: Market, datapoints: DataPoints) -> TradeSignal: 66 | pass 67 | 68 | @abstractmethod 69 | def backtest( 70 | self, market: Market, start_date: datetime, end_date: datetime 71 | ) -> BacktestResult: 72 | pass 73 | -------------------------------------------------------------------------------- /tradingbot/strategies/factories.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union 3 | 4 | from ..components import Configuration 5 | from ..components.broker import Broker 6 | from . import SimpleBollingerBands, SimpleMACD, WeightedAvgPeak 7 | 8 | StrategyImpl = Union[SimpleMACD, WeightedAvgPeak, SimpleBollingerBands] 9 | 10 | 11 | class StrategyNames(Enum): 12 | SIMPLE_MACD = "simple_macd" 13 | WEIGHTED_AVG_PEAK = "weighted_avg_peak" 14 | SIMPLE_BOLL_BANDS = "simple_boll_bands" 15 | 16 | 17 | class StrategyFactory: 18 | """ 19 | Factory class to create instances of Strategies. The class provide an 20 | interface to instantiate new objects of a given Strategy name 21 | """ 22 | 23 | config: Configuration 24 | broker: Broker 25 | 26 | def __init__(self, config: Configuration, broker: Broker) -> None: 27 | """ 28 | Constructor of the StrategyFactory 29 | 30 | - **config**: config json used to initialise Strategies 31 | - **broker**: instance of Broker class 32 | Strategies 33 | - Return the instance of the StrategyFactory 34 | """ 35 | self.config = config 36 | self.broker = broker 37 | 38 | def make_strategy(self, strategy_name: str) -> StrategyImpl: 39 | """ 40 | Create and return an instance of the Strategy class specified by 41 | the strategy_name 42 | 43 | - **strategy_name**: name of the strategy as defined in the json 44 | config file 45 | - Returns an instance of the requested Strategy or None if an 46 | error occurres 47 | """ 48 | if strategy_name == StrategyNames.SIMPLE_MACD.value: 49 | return SimpleMACD(self.config, self.broker) 50 | elif strategy_name == StrategyNames.WEIGHTED_AVG_PEAK.value: 51 | return WeightedAvgPeak(self.config, self.broker) 52 | elif strategy_name == StrategyNames.SIMPLE_BOLL_BANDS.value: 53 | return SimpleBollingerBands(self.config, self.broker) 54 | else: 55 | raise ValueError("Strategy {} does not exist".format(strategy_name)) 56 | 57 | def make_from_configuration(self) -> StrategyImpl: 58 | """ 59 | Create and return an instance of the Strategy class as configured in the 60 | configuration file 61 | """ 62 | return self.make_strategy(self.config.get_active_strategy()) 63 | -------------------------------------------------------------------------------- /tradingbot/strategies/simple_bollinger_bands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | # import matplotlib.pyplot as plt 5 | import pandas 6 | 7 | from ..components import Configuration, Interval, TradeDirection, Utils 8 | from ..components.broker import Broker 9 | from ..interfaces import Market, MarketHistory 10 | from . import BacktestResult, Strategy, TradeSignal 11 | 12 | 13 | class SimpleBollingerBands(Strategy): 14 | """Simple strategy that calculate the Bollinger Bands of the given market using 15 | the price moving average and triggering BUY or SELL signals when the last closed 16 | price crosses the upper or lower bands 17 | """ 18 | 19 | def __init__(self, config: Configuration, broker: Broker) -> None: 20 | super().__init__(config, broker) 21 | logging.info("Simple Bollinger Bands strategy created") 22 | 23 | def read_configuration(self, config: Configuration) -> None: 24 | """ 25 | Read the json configuration 26 | """ 27 | raw = config.get_raw_config() 28 | self.window = raw["strategies"]["simple_boll_bands"]["window"] 29 | self.limit_p = raw["strategies"]["simple_boll_bands"]["limit_perc"] 30 | self.stop_p = raw["strategies"]["simple_boll_bands"]["stop_perc"] 31 | 32 | def initialise(self) -> None: 33 | """ 34 | Initialise the strategy 35 | """ 36 | logging.info("Simple Bollinger Bands strategy initialised") 37 | 38 | def fetch_datapoints(self, market: Market) -> MarketHistory: 39 | """ 40 | Fetch historic prices 41 | """ 42 | return self.broker.get_prices(market, Interval.DAY, self.window * 2) 43 | 44 | def find_trade_signal( 45 | self, market: Market, datapoints: MarketHistory 46 | ) -> TradeSignal: 47 | # Copy only the required amount of data 48 | df = datapoints.dataframe[: self.window * 2].copy() 49 | indexer = pandas.api.indexers.FixedForwardWindowIndexer(window_size=self.window) 50 | # Compute the price moving averate 51 | df["MA"] = df[MarketHistory.CLOSE_COLUMN].rolling(window=indexer).mean() 52 | # Compute the prices standard deviation 53 | # set .std(ddof=0) for population std instead of sample 54 | df["STD"] = df[MarketHistory.CLOSE_COLUMN].rolling(window=indexer).std() 55 | # Compute upper band 56 | df["Upper_Band"] = df["MA"] + (df["STD"] * 2) 57 | # Compute lower band 58 | df["Lower_Band"] = df["MA"] - (df["STD"] * 2) 59 | 60 | # self._plot(df) 61 | 62 | # Compare the last price with the band boundaries and trigger signals 63 | cross_lower_band_and_back = ( 64 | df[MarketHistory.CLOSE_COLUMN].iloc[0] > df["Lower_Band"].iloc[0] 65 | ) and (df[MarketHistory.CLOSE_COLUMN].iloc[1] <= df["Lower_Band"].iloc[1]) 66 | stable_below_ma = ( 67 | df[MarketHistory.CLOSE_COLUMN].iloc[0:5] < df["MA"].iloc[0:5] 68 | ).all() 69 | 70 | if any([cross_lower_band_and_back, stable_below_ma]): 71 | return self._buy_signal(market) 72 | return TradeDirection.NONE, None, None 73 | 74 | def _buy_signal(self, market: Market) -> TradeSignal: 75 | direction = TradeDirection.BUY 76 | limit = market.offer + Utils.percentage_of(self.limit_p, market.offer) 77 | stop = market.bid - Utils.percentage_of(self.stop_p, market.bid) 78 | return direction, limit, stop 79 | 80 | def _sell_signal(self, market: Market) -> TradeSignal: 81 | direction = TradeDirection.SELL 82 | limit = market.bid - Utils.percentage_of(self.limit_p, market.bid) 83 | stop = market.offer + Utils.percentage_of(self.stop_p, market.offer) 84 | return direction, limit, stop 85 | 86 | # def _plot(self, dataframe: pandas.DataFrame): 87 | # ax = plt.gca() 88 | # dataframe.plot( 89 | # kind="line", 90 | # x=MarketHistory.DATE_COLUMN, 91 | # y=MarketHistory.CLOSE_COLUMN, 92 | # ax=ax, 93 | # color="blue", 94 | # ) 95 | # dataframe.plot( 96 | # kind="line", x=MarketHistory.DATE_COLUMN, y="Lower_Band", ax=ax, color="red" 97 | # ) 98 | # dataframe.plot( 99 | # kind="line", x=MarketHistory.DATE_COLUMN, y="Upper_Band", ax=ax, color="red" 100 | # ) 101 | # dataframe.plot( 102 | # kind="line", x=MarketHistory.DATE_COLUMN, y="MA", ax=ax, color="green" 103 | # ) 104 | # plt.show() 105 | 106 | def backtest( 107 | self, market: Market, start_date: datetime, end_date: datetime 108 | ) -> BacktestResult: 109 | return BacktestResult() 110 | -------------------------------------------------------------------------------- /tradingbot/strategies/simple_macd.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from typing import Tuple 4 | 5 | import numpy as np 6 | import pandas 7 | 8 | from ..components import Configuration, Interval, TradeDirection, Utils 9 | from ..components.broker import Broker 10 | from ..interfaces import Market, MarketMACD 11 | from . import BacktestResult, Strategy, TradeSignal 12 | 13 | 14 | class SimpleMACD(Strategy): 15 | """ 16 | Strategy that use the MACD technical indicator of a market to decide whether 17 | to buy, sell or hold. 18 | Buy when the MACD cross over the MACD signal. 19 | Sell when the MACD cross below the MACD signal. 20 | """ 21 | 22 | def __init__(self, config: Configuration, broker: Broker) -> None: 23 | super().__init__(config, broker) 24 | logging.info("Simple MACD strategy initialised.") 25 | 26 | def read_configuration(self, config: Configuration) -> None: 27 | """ 28 | Read the json configuration 29 | """ 30 | raw = config.get_raw_config() 31 | self.max_spread_perc = raw["strategies"]["simple_macd"]["max_spread_perc"] 32 | self.limit_p = raw["strategies"]["simple_macd"]["limit_perc"] 33 | self.stop_p = raw["strategies"]["simple_macd"]["stop_perc"] 34 | 35 | def initialise(self) -> None: 36 | """ 37 | Initialise SimpleMACD strategy 38 | """ 39 | pass 40 | 41 | def fetch_datapoints(self, market: Market) -> MarketMACD: 42 | """ 43 | Fetch historic MACD data 44 | """ 45 | return self.broker.get_macd(market, Interval.DAY, 30) 46 | 47 | def find_trade_signal(self, market: Market, datapoints: MarketMACD) -> TradeSignal: 48 | """ 49 | Calculate the MACD of the previous days and find a cross between MACD 50 | and MACD signal 51 | 52 | - **market**: Market object 53 | - **datapoints**: datapoints used to analyse the market 54 | - Returns TradeDirection, limit_level, stop_level or TradeDirection.NONE, None, None 55 | """ 56 | limit_perc = self.limit_p 57 | stop_perc = max(market.stop_distance_min, self.stop_p) 58 | 59 | # Spread constraint 60 | if market.bid - market.offer > self.max_spread_perc: 61 | return TradeDirection.NONE, None, None 62 | 63 | # Find where macd and signal cross each other 64 | macd = datapoints 65 | px = self.generate_signals_from_dataframe(macd.dataframe) 66 | 67 | # Identify the trade direction looking at the last signal 68 | tradeDirection = self.get_trade_direction_from_signals(px) 69 | # Log only tradable epics 70 | if tradeDirection is not TradeDirection.NONE: 71 | logging.info( 72 | "SimpleMACD says: {} {}".format(tradeDirection.name, market.id) 73 | ) 74 | else: 75 | return TradeDirection.NONE, None, None 76 | 77 | # Calculate stop and limit distances 78 | limit, stop = self.calculate_stop_limit( 79 | tradeDirection, market.offer, market.bid, limit_perc, stop_perc 80 | ) 81 | return tradeDirection, limit, stop 82 | 83 | def calculate_stop_limit( 84 | self, 85 | tradeDirection: TradeDirection, 86 | current_offer: float, 87 | current_bid: float, 88 | limit_perc: float, 89 | stop_perc: float, 90 | ) -> Tuple[float, float]: 91 | """ 92 | Calculate the stop and limit levels from the given percentages 93 | """ 94 | limit = None 95 | stop = None 96 | if tradeDirection == TradeDirection.BUY: 97 | limit = current_offer + Utils.percentage_of(limit_perc, current_offer) 98 | stop = current_bid - Utils.percentage_of(stop_perc, current_bid) 99 | elif tradeDirection == TradeDirection.SELL: 100 | limit = current_bid - Utils.percentage_of(limit_perc, current_bid) 101 | stop = current_offer + Utils.percentage_of(stop_perc, current_offer) 102 | else: 103 | raise ValueError("Trade direction cannot be NONE") 104 | return limit, stop 105 | 106 | def generate_signals_from_dataframe( 107 | self, dataframe: pandas.DataFrame 108 | ) -> pandas.DataFrame: 109 | dataframe.loc[:, "positions"] = 0 110 | dataframe.loc[:, "positions"] = np.where( 111 | dataframe[MarketMACD.HIST_COLUMN] >= 0, 1, 0 112 | ) 113 | dataframe.loc[:, "signals"] = dataframe["positions"].diff() 114 | return dataframe 115 | 116 | def get_trade_direction_from_signals( 117 | self, dataframe: pandas.DataFrame 118 | ) -> TradeDirection: 119 | tradeDirection = TradeDirection.NONE 120 | if len(dataframe["signals"]) > 0: 121 | if dataframe["signals"].iloc[1] < 0: 122 | tradeDirection = TradeDirection.BUY 123 | elif dataframe["signals"].iloc[1] > 0: 124 | tradeDirection = TradeDirection.SELL 125 | return tradeDirection 126 | 127 | def backtest( 128 | self, market: Market, start_date: datetime.datetime, end_date: datetime.datetime 129 | ) -> BacktestResult: 130 | """Backtest the strategy""" 131 | # TODO 132 | raise NotImplementedError("Work in progress") 133 | # Generic initialisations 134 | trades = [] 135 | # - Get price data for market 136 | prices = self.broker.get_prices(market, Interval.DAY, None) 137 | # - Get macd data from broker 138 | data = self.fetch_datapoints(market) 139 | # - Simulate time passing by starting with N rows (from the bottom) 140 | # and adding the next row (on the top) one by one, calling the strategy with 141 | # the intermediate data and recording its output 142 | datapoint_used = 26 143 | while len(data.dataframe) > datapoint_used: 144 | current_data = data.dataframe.tail(datapoint_used).copy() 145 | datapoint_used += 1 146 | # Get trade date 147 | trade_dt = current_data.index.values[0].astype("M8[ms]").astype("O") 148 | if start_date <= trade_dt <= end_date: 149 | trade, limit, stop = self.find_trade_signal(market, current_data) 150 | if trade is not TradeDirection.NONE: 151 | try: 152 | price = prices.loc[trade_dt.strftime("%Y-%m-%d"), "4. close"] 153 | trades.append( 154 | # [trade_dt.strftime("%Y-%m-%d"), trade, float(price)] 155 | (trade_dt.strftime("%Y-%m-%d"), trade, float(price)) 156 | ) 157 | except Exception as e: 158 | logging.debug(e) 159 | continue 160 | if len(trades) < 2: 161 | raise Exception("Not enough trades for the given date range") 162 | # Iterate through trades and assess profit loss 163 | balance = 1000 164 | previous = trades[0] 165 | for trade in trades[1:]: 166 | if previous[1] is trade[1]: 167 | raise Exception("Error: sequencial trades with same direction") 168 | diff = trade[2] - previous[2] 169 | pl = 0 170 | if previous[1] is TradeDirection.BUY and trade[1] is TradeDirection.SELL: 171 | pl += diff if diff >= 0 else -diff 172 | # TODO consider stop and limit levels 173 | if previous[1] is TradeDirection.SELL and trade[1] is TradeDirection.BUY: 174 | pl += diff if diff < 0 else -diff 175 | # TODO consider stop and limit levels 176 | balance += pl 177 | previous = trade 178 | return {"balance": balance, "trades": trades} 179 | -------------------------------------------------------------------------------- /tradingbot/trading_bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | from datetime import datetime as dt 4 | from pathlib import Path 5 | from typing import List, Optional 6 | 7 | import pytz 8 | 9 | from .components import ( 10 | Backtester, 11 | Configuration, 12 | MarketClosedException, 13 | MarketProvider, 14 | NotSafeToTradeException, 15 | TimeAmount, 16 | TimeProvider, 17 | TradeDirection, 18 | ) 19 | from .components.broker import Broker, BrokerFactory 20 | from .interfaces import Market, Position 21 | from .strategies import StrategyFactory, StrategyImpl 22 | 23 | 24 | class TradingBot: 25 | """ 26 | Class that initialise and hold references of main components like the 27 | broker interface, the strategy or the epic_ids list 28 | """ 29 | 30 | time_provider: TimeProvider 31 | config: Configuration 32 | broker: Broker 33 | strategy: StrategyImpl 34 | market_provider: MarketProvider 35 | 36 | def __init__( 37 | self, 38 | time_provider: Optional[TimeProvider] = None, 39 | config_filepath: Optional[Path] = None, 40 | ) -> None: 41 | # Time manager 42 | self.time_provider = time_provider if time_provider else TimeProvider() 43 | # Set timezone 44 | set(pytz.all_timezones_set) 45 | 46 | # Load configuration 47 | self.config = Configuration.from_filepath(config_filepath) 48 | 49 | # Setup the global logger 50 | self.setup_logging() 51 | 52 | # Init trade services and create the broker interface 53 | # The Factory is used to create the services from the configuration file 54 | self.broker = Broker(BrokerFactory(self.config)) 55 | 56 | # Create strategy from the factory class 57 | self.strategy = StrategyFactory( 58 | self.config, self.broker 59 | ).make_from_configuration() 60 | 61 | # Create the market provider 62 | self.market_provider = MarketProvider(self.config, self.broker) 63 | 64 | def setup_logging(self) -> None: 65 | """ 66 | Setup the global logging settings 67 | """ 68 | # Clean logging handlers 69 | for handler in logging.root.handlers[:]: 70 | logging.root.removeHandler(handler) 71 | 72 | # Define the global logging settings 73 | debugLevel = ( 74 | logging.DEBUG if self.config.is_logging_debug_enabled() else logging.INFO 75 | ) 76 | if self.config.is_logging_enabled(): 77 | log_filename = self.config.get_log_filepath() 78 | Path(log_filename).parent.mkdir(parents=True, exist_ok=True) 79 | logging.basicConfig( 80 | filename=log_filename, 81 | level=debugLevel, 82 | format="[%(asctime)s] %(levelname)s: %(message)s", 83 | ) 84 | else: 85 | logging.basicConfig( 86 | level=debugLevel, format="[%(asctime)s] %(levelname)s: %(message)s" 87 | ) 88 | 89 | def start(self, single_pass=False) -> None: 90 | """ 91 | Starts the TradingBot main loop 92 | - process open positions 93 | - process markets from market source 94 | - wait for configured wait time 95 | - start over 96 | """ 97 | if single_pass: 98 | logging.info("Performing a single iteration of the market source") 99 | while True: 100 | try: 101 | # Process current open positions 102 | self.process_open_positions() 103 | # Now process markets from the configured market source 104 | self.process_market_source() 105 | # Wait for the next spin before starting over 106 | self.time_provider.wait_for( 107 | TimeAmount.SECONDS, self.config.get_spin_interval() 108 | ) 109 | if single_pass: 110 | break 111 | except MarketClosedException: 112 | logging.warning("Market is closed: stop processing") 113 | if single_pass: 114 | break 115 | self.time_provider.wait_for(TimeAmount.NEXT_MARKET_OPENING) 116 | except NotSafeToTradeException: 117 | if single_pass: 118 | break 119 | self.time_provider.wait_for( 120 | TimeAmount.SECONDS, self.config.get_spin_interval() 121 | ) 122 | except StopIteration: 123 | if single_pass: 124 | break 125 | self.time_provider.wait_for( 126 | TimeAmount.SECONDS, self.config.get_spin_interval() 127 | ) 128 | except Exception as e: 129 | logging.error("Generic exception caught: {}".format(e)) 130 | logging.error(traceback.format_exc()) 131 | if single_pass: 132 | break 133 | 134 | def process_open_positions(self) -> None: 135 | """ 136 | Fetch open positions markets and run the strategy against them closing the 137 | trades if required 138 | """ 139 | positions = self.broker.get_open_positions() 140 | # Do not run until we know the current open positions 141 | if positions is None: 142 | logging.warning("Unable to fetch open positions! Will try again...") 143 | raise RuntimeError("Unable to fetch open positions") 144 | for epic in [item.epic for item in positions]: 145 | market = self.market_provider.get_market_from_epic(epic) 146 | self.process_market(market, positions) 147 | 148 | def process_market_source(self) -> None: 149 | """ 150 | Process markets from the configured market source 151 | """ 152 | while True: 153 | market = self.market_provider.next() 154 | positions = self.broker.get_open_positions() 155 | if positions is None: 156 | logging.warning("Unable to fetch open positions! Will try again...") 157 | raise RuntimeError("Unable to fetch open positions") 158 | self.process_market(market, positions) 159 | 160 | def process_market(self, market: Market, open_positions: List[Position]) -> None: 161 | """Spin the strategy on all the markets""" 162 | if not self.config.is_paper_trading_enabled(): 163 | self.safety_checks() 164 | logging.info("Processing {}".format(market.id)) 165 | try: 166 | self.strategy.set_open_positions(open_positions) 167 | trade, limit, stop = self.strategy.run(market) 168 | self.process_trade(market, trade, limit, stop, open_positions) 169 | except Exception as e: 170 | logging.error("Strategy exception caught: {}".format(e)) 171 | logging.debug(traceback.format_exc()) 172 | return 173 | 174 | def close_open_positions(self) -> None: 175 | """ 176 | Closes all the open positions in the account 177 | """ 178 | logging.info("Closing all the open positions...") 179 | if self.broker.close_all_positions(): 180 | logging.info("All the posisions have been closed.") 181 | else: 182 | logging.error("Impossible to close all open positions, retry.") 183 | 184 | def safety_checks(self) -> None: 185 | """ 186 | Perform some safety checks before running the strategy against the next market 187 | 188 | Raise exceptions if not safe to trade 189 | """ 190 | percent_used = self.broker.get_account_used_perc() 191 | if percent_used is None: 192 | logging.warning( 193 | "Stop trading because can't fetch percentage of account used" 194 | ) 195 | raise NotSafeToTradeException() 196 | if percent_used >= self.config.get_max_account_usable(): 197 | logging.warning( 198 | "Stop trading because {}% of account is used".format(str(percent_used)) 199 | ) 200 | raise NotSafeToTradeException() 201 | if not self.time_provider.is_market_open(self.config.get_time_zone()): 202 | raise MarketClosedException() 203 | 204 | def process_trade( 205 | self, 206 | market: Market, 207 | direction: TradeDirection, 208 | limit: Optional[float], 209 | stop: Optional[float], 210 | open_positions: List[Position], 211 | ) -> None: 212 | """ 213 | Process a trade checking if it is a "close position" trade or a new trade 214 | """ 215 | # Perform trade only if required 216 | if direction is TradeDirection.NONE or limit is None or stop is None: 217 | return 218 | 219 | for item in open_positions: 220 | # If a same direction trade already exist, don't trade 221 | if item.epic == market.epic and direction is item.direction: 222 | logging.info( 223 | "There is already an open position for this epic, skip trade" 224 | ) 225 | return 226 | # If a trade in opposite direction exist, close the position 227 | elif item.epic == market.epic and direction is not item.direction: 228 | self.broker.close_position(item) 229 | return 230 | self.broker.trade(market.epic, direction, limit, stop) 231 | 232 | def backtest( 233 | self, 234 | market_id: str, 235 | start_date: str, 236 | end_date: str, 237 | epic_id: Optional[str] = None, 238 | ) -> None: 239 | """ 240 | Backtest a market using the configured strategy 241 | """ 242 | try: 243 | start = dt.strptime(start_date, "%Y-%m-%d") 244 | end = dt.strptime(end_date, "%Y-%m-%d") 245 | except ValueError as e: 246 | logging.error("Wrong date format! Must be YYYY-MM-DD") 247 | logging.debug(e) 248 | exit(1) 249 | 250 | bt = Backtester(self.broker, self.strategy) 251 | 252 | try: 253 | market = ( 254 | self.market_provider.search_market(market_id) 255 | if epic_id is None or epic_id == "" 256 | else self.market_provider.get_market_from_epic(epic_id) 257 | ) 258 | except Exception as e: 259 | logging.error(e) 260 | exit(1) 261 | 262 | bt.start(market, start, end) 263 | bt.print_results() 264 | --------------------------------------------------------------------------------