├── 01_session ├── user_prompts_1.txt ├── prompts │ ├── user_prompts_1.txt │ ├── codex_session_1_orig.jsonl │ └── user_prompts_2.txt ├── get_instruments.py ├── get_nasdaq100_history.py ├── codex_session_1_orig.jsonl ├── user_prompts_2.txt ├── efficiency_tests.py ├── streamlit_app.py └── tpqoa.py ├── 02_session ├── get_instruments.py ├── prompts │ └── user_prompts.txt ├── oanda_instruments.txt ├── mr_technical_strategy.md ├── backtest_mr.py └── tpqoa.py ├── REFERENCES.md ├── DISCLAIMER.md ├── 03_session ├── deploy.sh ├── coin_flip.py ├── optimize.py ├── mr_trading.py ├── oanda_spreads.md ├── oanda_instruments.json ├── tpqoa.py └── backtest_mr_para.py ├── .gitignore ├── algo_py_genai_ref.ipynb └── README.md /01_session/user_prompts_1.txt: -------------------------------------------------------------------------------- 1 | Using my credentials stored in oanda.cfg, generate a brief Python script that uses the tpqoa package to connect to the Oanda API and retrieve a few available instruments from the API. Never show/display the credentials themselves as stored in the oanda.cfg file. -------------------------------------------------------------------------------- /01_session/prompts/user_prompts_1.txt: -------------------------------------------------------------------------------- 1 | Using my credentials stored in oanda.cfg, generate a brief Python script that uses the tpqoa package to connect to the Oanda API and retrieve a few available instruments from the API. Never show/display the credentials themselves as stored in the oanda.cfg file. -------------------------------------------------------------------------------- /02_session/get_instruments.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Minimal script to retrieve and print all instruments for an Oanda account using tpqoa.""" 3 | 4 | from tpqoa import tpqoa 5 | 6 | 7 | def main(): 8 | # Initialize tpqoa with config file (oanda.cfg should contain your credentials) 9 | oanda = tpqoa('oanda.cfg') 10 | instruments = oanda.get_instruments() 11 | for display_name, name in instruments: 12 | print(f"{display_name}: {name}") 13 | 14 | 15 | if __name__ == '__main__': 16 | main() 17 | -------------------------------------------------------------------------------- /01_session/get_instruments.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Retrieves and prints available instruments from Oanda using the tpqoa wrapper. 4 | 5 | This script reads Oanda API credentials from the 'oanda.cfg' file in the same directory. 6 | Ensure that 'oanda.cfg' is properly configured but do not hardcode credentials here. 7 | """ 8 | 9 | import tpqoa 10 | 11 | 12 | def main(): 13 | # Instantiate the Oanda API client using credentials from oanda.cfg 14 | oanda = tpqoa.tpqoa("oanda.cfg") 15 | instruments = oanda.get_instruments() 16 | print("Available instruments (display name, name):") 17 | for display_name, name in instruments: 18 | print(f"{display_name}: {name}") 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /01_session/get_nasdaq100_history.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Fetches the last 3 months of hourly mid-price data for Nasdaq 100 from Oanda. 4 | 5 | This script reads Oanda API credentials from the 'oanda.cfg' file in the same directory. 6 | Ensure that 'oanda.cfg' is properly configured but do not hardcode credentials here. 7 | """ 8 | 9 | import tpqoa 10 | import pandas as pd 11 | 12 | 13 | def main(): 14 | # Instantiate the Oanda API client 15 | oanda = tpqoa.tpqoa("oanda.cfg") 16 | 17 | # Define time range: last 3 months until now (UTC) 18 | end = pd.Timestamp.utcnow() 19 | start = end - pd.DateOffset(months=3) 20 | 21 | # Retrieve historical data: instrument NAS100_USD, hourly, mid price 22 | data = oanda.get_history( 23 | "NAS100_USD", start, end, granularity="H1", price="M" 24 | ) 25 | 26 | # Print the retrieved DataFrame 27 | print(data) 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /REFERENCES.md: -------------------------------------------------------------------------------- 1 | ## References 2 | 3 | This file contains miscellaneous references or pieces of information. 4 | 5 | ### Efficient Markets 6 | 7 | https://chatgpt.com/share/687f9c75-bba0-8000-8554-c753832c5702 8 | 9 | ### Codex Installation 10 | 11 | https://www.perplexity.ai/search/provide-detailed-guidelines-ab-PhGTOH9pQZiG0X0ShGXNyA 12 | 13 | ### Mean-Reversion Algorithmic Trading Strategy 14 | 15 | https://www.perplexity.ai/search/describe-an-algorithmic-tradin-teAyjyRvRPSywdoFyfBg1Q 16 | 17 | ### Oanda Average Trading Costs (Spread) 18 | 19 | https://www.perplexity.ai/search/what-is-the-average-bid-ask-sp-V8svb0_aTPOrlIZqBZJRlw 20 | 21 | ### Oanda Maximum Leverage by Category 22 | 23 | https://www.perplexity.ai/search/what-are-allowed-maximum-lever-Yrkl6G1tQ2mH.OW5pEe17w 24 | 25 | ### "Winning" S10 Strategy Combination 26 | 27 | python backtest_mr_para.py --bb-window 10 --bb-std-dev 2 --rsi-period 14 --ema-period 10 --atr-period 14 --plot --start 2025-07-20 --granularity S10 -------------------------------------------------------------------------------- /DISCLAIMER.md: -------------------------------------------------------------------------------- 1 | ## Disclaimer 2 | 3 | The code in this repository is provided for **educational purposes only** as part of the "Algorithmic Trading with Python and GenAI" online bootcamp by The Python Quants GmbH. 4 | 5 | ### No Warranties 6 | A significant portion of this code was generated using Generative AI. It is provided **"as is" without any representations or warranties**, express or implied. We do not guarantee the accuracy, completeness, or performance of the code. 7 | 8 | ### Not Investment Advice 9 | Nothing in this repository constitutes investment, financial, or trading advice. All materials are for informational and educational purposes only. 10 | 11 | ### ⚠️ High-Risk Warning 12 | Algorithmic trading is highly speculative and involves a **substantial risk of loss**. Trading leveraged instruments, such as Contracts for Difference (CFDs), is particularly risky and can result in the **loss of 100% of your invested capital**. You should not risk more than you are prepared to lose. **Use this code and the concepts presented at your own risk.** -------------------------------------------------------------------------------- /01_session/codex_session_1_orig.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"59afa83f-b265-4785-98df-742edf085e7f","timestamp":"2025-07-22T16:29:53.716Z"} 2 | {"type":"message","role":"user","content":[{"type":"input_text","text":"Using my credentials stored in oanda.cfg, generate a brief Python script that uses the tpqoa package to connect to the Oanda API and retrieve a few available instruments from the API. Never show/display the credentials themselves as stored in the oanda.cfg file."}]} 3 | {"type":"local_shell_call","id":"lsh_687fa0ff624881a192bb781d058560ee0c456d8c82e6d5a5","call_id":"call_zM6g4dtACGnps724Q9HHC5cQ","status":"completed","action":{"type":"exec","command":["bash","-lc","rg tpqoa --files"],"timeout_ms":null,"working_directory":null,"env":{},"user":null}} 4 | {"type":"function_call_output","call_id":"call_zM6g4dtACGnps724Q9HHC5cQ","output":"{\"output\":\"rg: tpqoa: IO error for operation on tpqoa: No such file or directory (os error 2)\\n\",\"metadata\":{\"exit_code\":2,\"duration_seconds\":0.7}}"} 5 | {"type":"local_shell_call","id":"lsh_687fa102660481a19717ddc8e93c39b60c456d8c82e6d5a5","call_id":"call_e0ydQhXveWOoDz698fXO1Z33","status":"completed","action":{"type":"exec","command":["bash","-lc","rg get_instruments -n ."],"timeout_ms":120000,"working_directory":null,"env":{},"user":null}} 6 | {"type":"function_call_output","call_id":"call_e0ydQhXveWOoDz698fXO1Z33","output":"{\"output\":\"\",\"metadata\":{\"exit_code\":1,\"duration_seconds\":0.5}}"} 7 | {"type":"message","role":"user","content":[{"type":"input_text","text":"Don't install the package. It is installed in the current environment."}]} 8 | -------------------------------------------------------------------------------- /01_session/prompts/codex_session_1_orig.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"59afa83f-b265-4785-98df-742edf085e7f","timestamp":"2025-07-22T16:29:53.716Z"} 2 | {"type":"message","role":"user","content":[{"type":"input_text","text":"Using my credentials stored in oanda.cfg, generate a brief Python script that uses the tpqoa package to connect to the Oanda API and retrieve a few available instruments from the API. Never show/display the credentials themselves as stored in the oanda.cfg file."}]} 3 | {"type":"local_shell_call","id":"lsh_687fa0ff624881a192bb781d058560ee0c456d8c82e6d5a5","call_id":"call_zM6g4dtACGnps724Q9HHC5cQ","status":"completed","action":{"type":"exec","command":["bash","-lc","rg tpqoa --files"],"timeout_ms":null,"working_directory":null,"env":{},"user":null}} 4 | {"type":"function_call_output","call_id":"call_zM6g4dtACGnps724Q9HHC5cQ","output":"{\"output\":\"rg: tpqoa: IO error for operation on tpqoa: No such file or directory (os error 2)\\n\",\"metadata\":{\"exit_code\":2,\"duration_seconds\":0.7}}"} 5 | {"type":"local_shell_call","id":"lsh_687fa102660481a19717ddc8e93c39b60c456d8c82e6d5a5","call_id":"call_e0ydQhXveWOoDz698fXO1Z33","status":"completed","action":{"type":"exec","command":["bash","-lc","rg get_instruments -n ."],"timeout_ms":120000,"working_directory":null,"env":{},"user":null}} 6 | {"type":"function_call_output","call_id":"call_e0ydQhXveWOoDz698fXO1Z33","output":"{\"output\":\"\",\"metadata\":{\"exit_code\":1,\"duration_seconds\":0.5}}"} 7 | {"type":"message","role":"user","content":[{"type":"input_text","text":"Don't install the package. It is installed in the current environment."}]} 8 | -------------------------------------------------------------------------------- /03_session/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # deploy.sh: local deploy or remote install helper 5 | if [[ "${1-}" == "--remote" ]]; then 6 | # remote installation steps 7 | echo "=== Remote setup on $(hostname) ===" 8 | # ensure we run inside the deployment folder 9 | SCRIPT_DIR=$(dirname "$(readlink -f "$0")") 10 | cd "$SCRIPT_DIR" 11 | sudo apt-get update 12 | sudo apt-get install -y python3 python3-venv python3-pip git 13 | python3 -m venv venv 14 | source venv/bin/activate 15 | pip install --upgrade pip 16 | pip install pandas numpy python-dateutil v20 17 | pip install git+https://github.com/yhilpisch/tpqoa.git 18 | echo "Remote setup complete." 19 | exit 0 20 | fi 21 | 22 | if [[ $# -lt 1 ]]; then 23 | echo "Usage: $0 user@host" 24 | exit 1 25 | fi 26 | 27 | SERVER=$1 28 | REMOTE_DIR=~/mr_trading 29 | echo "Copying scripts and credentials to $SERVER:$REMOTE_DIR ..." 30 | ssh "$SERVER" "mkdir -p $REMOTE_DIR" 31 | scp \ 32 | mr_trading.py tpqoa.py oanda.cfg oanda_instruments.json deploy.sh \ 33 | "$SERVER":$REMOTE_DIR/ 34 | 35 | echo "Running remote setup..." 36 | ssh "$SERVER" "bash $REMOTE_DIR/deploy.sh --remote" 37 | 38 | read -p "Start live trading on $SERVER now? [y/N] " resp 39 | if [[ "$resp" =~ ^[Yy] ]]; then 40 | ssh "$SERVER" "cd $REMOTE_DIR && source venv/bin/activate && python mr_trading.py --config oanda.cfg --instrument DE30_EUR" 41 | else 42 | echo "To start later on $SERVER, run:" 43 | echo " ssh $SERVER" 44 | echo " cd $REMOTE_DIR && source venv/bin/activate && python mr_trading.py --config oanda.cfg --instrument DE30_EUR" 45 | fi 46 | -------------------------------------------------------------------------------- /01_session/user_prompts_2.txt: -------------------------------------------------------------------------------- 1 | Using my credential file oanda.cfg create a brief Python script that connects to the Oanda API via the wrapper tpqoa.py and retrieves a few instruments available. Never show/display my credentials as stored in oanda.cfg. 2 | 3 | Now create a brief script that retrieves from the Oanda API time series data for Nasdaq 100, for 3 months back from today and with 1 hour granularity (mid price). Never show my creds. 4 | 5 | File "/Users/yves/Python/envs/algo/lib/python3.12/site-packages/v20/response.py", line 35, in get 6 | raise ResponseNoField(self, field) 7 | v20.errors.ResponseNoField: 400 response for GET https://api-fxpractice.oanda.com:443/v3/instruments/NAS100_USD/candles?price=M&granularity=H1&from=2025-04-22T14%3A44%3A15.586694%2B00%3A00.000000000Z&to=2025-04-25T14%3A44%3A15.586694%2B00%3A00.000000000Z does not have field 'candles' (contains 'errorMessage') 8 | 9 | Now implement a web-based application with streamlit which allows the selection of an Oanda instrument, the start date (default:-3 months), the end date (default: today), the granularity (default: D), and as price mid price. The data shall be visualized with plotly (candlesticks). Avoid the use of pyarrow in any instance. 10 | 11 | The parameter selection is not placed in the sidebar. Everything is in the main pane. Correct this. 12 | 13 | Make the display of the data table optional (radio button). Add another figure which compares the closing price of the main instrument to a maximum of two other instruments. Put it under the original figure. 14 | 15 | Turning on/off the data table does not work. Neither does the generation of the price comparison chart work. 16 | 17 | Add the option to display the price comparison in normalized fashion, i.e. to 1. Also via radio button. -------------------------------------------------------------------------------- /01_session/prompts/user_prompts_2.txt: -------------------------------------------------------------------------------- 1 | Using my credential file oanda.cfg create a brief Python script that connects to the Oanda API via the wrapper tpqoa.py and retrieves a few instruments available. Never show/display my credentials as stored in oanda.cfg. 2 | 3 | Now create a brief script that retrieves from the Oanda API time series data for Nasdaq 100, for 3 months back from today and with 1 hour granularity (mid price). Never show my creds. 4 | 5 | File "/Users/yves/Python/envs/algo/lib/python3.12/site-packages/v20/response.py", line 35, in get 6 | raise ResponseNoField(self, field) 7 | v20.errors.ResponseNoField: 400 response for GET https://api-fxpractice.oanda.com:443/v3/instruments/NAS100_USD/candles?price=M&granularity=H1&from=2025-04-22T14%3A44%3A15.586694%2B00%3A00.000000000Z&to=2025-04-25T14%3A44%3A15.586694%2B00%3A00.000000000Z does not have field 'candles' (contains 'errorMessage') 8 | 9 | Now implement a web-based application with streamlit which allows the selection of an Oanda instrument, the start date (default:-3 months), the end date (default: today), the granularity (default: D), and as price mid price. The data shall be visualized with plotly (candlesticks). Avoid the use of pyarrow in any instance. 10 | 11 | The parameter selection is not placed in the sidebar. Everything is in the main pane. Correct this. 12 | 13 | Make the display of the data table optional (radio button). Add another figure which compares the closing price of the main instrument to a maximum of two other instruments. Put it under the original figure. 14 | 15 | Turning on/off the data table does not work. Neither does the generation of the price comparison chart work. 16 | 17 | Add the option to display the price comparison in normalized fashion, i.e. to 1. Also via radio button. -------------------------------------------------------------------------------- /03_session/coin_flip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple coin-flip strategy: every 10 seconds alternate long/short 1 unit DE30_EUR, stop after 6 trades. 4 | """ 5 | 6 | import time 7 | import argparse 8 | 9 | from tpqoa import tpqoa 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser( 14 | description='Coin-flip market-order test for DE30_EUR' 15 | ) 16 | parser.add_argument('--config', default='oanda.cfg', 17 | help='path to OANDA config file') 18 | parser.add_argument('--instrument', default='DE30_EUR', 19 | help='instrument to trade') 20 | parser.add_argument('--trades', type=int, default=6, 21 | help='total number of market orders') 22 | parser.add_argument('--interval', type=int, default=10, 23 | help='seconds between orders') 24 | args = parser.parse_args() 25 | 26 | api = tpqoa(args.config) 27 | net_pos = 0 28 | for i in range(1, args.trades + 1): 29 | # flip position: first go long 1, then flip net position each trade 30 | units = -2 * net_pos if net_pos != 0 else 1 31 | net_pos += units 32 | side = 'long' if units > 0 else 'short' 33 | print(f"[{i}/{args.trades}] Placing {side} {abs(units)} units of {args.instrument}") 34 | api.create_order(args.instrument, units) 35 | if i < args.trades: 36 | time.sleep(args.interval) 37 | 38 | # flatten final position if any 39 | if net_pos != 0: 40 | closing = -net_pos 41 | side = 'sell' if closing < 0 else 'buy' 42 | print(f"Closing out final position: {side} {abs(closing)} units") 43 | api.create_order(args.instrument, closing) 44 | 45 | 46 | if __name__ == '__main__': 47 | main() 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifics 2 | *.swp 3 | *.cfg 4 | _build/ 5 | .DS_Store 6 | creds.py 7 | oanda.cfg 8 | 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # celery beat schedule file 88 | celerybeat-schedule 89 | 90 | # SageMath parsed files 91 | *.sage.py 92 | 93 | # Environments 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | -------------------------------------------------------------------------------- /algo_py_genai_ref.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "f59547b6-71d8-4ce0-b0b3-9b33997b4023", 6 | "metadata": { 7 | "slideshow": { 8 | "slide_type": "slide" 9 | } 10 | }, 11 | "source": [ 12 | "\"The
" 13 | ] 14 | }, 15 | { 16 | "cell_type": "markdown", 17 | "id": "12eab4f3-6033-4e05-95f5-f0bb1f62eb5e", 18 | "metadata": {}, 19 | "source": [ 20 | "# Algorithmic Trading" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "id": "135e5b2b-9934-4aba-a0e9-e2c153a46886", 26 | "metadata": { 27 | "tags": [] 28 | }, 29 | "source": [ 30 | "**With Python & GenAI**\n", 31 | "\n", 32 | "© Dr. Yves J. Hilpisch | The Python Quants GmbH\n", 33 | "\n", 34 | "https://cpf.tpq.io | [team@tpq.io](mailto:team@tpq.io) | [https://linktr.ee/dyjh](https://linktr.ee/dyjh)" 35 | ] 36 | }, 37 | { 38 | "cell_type": "markdown", 39 | "id": "edacb0bd-a0e7-413d-bd5c-2e044c44c6fa", 40 | "metadata": { 41 | "slideshow": { 42 | "slide_type": "slide" 43 | } 44 | }, 45 | "source": [ 46 | "\"The
" 47 | ] 48 | } 49 | ], 50 | "metadata": { 51 | "kernelspec": { 52 | "display_name": "Python 3 (ipykernel)", 53 | "language": "python", 54 | "name": "python3" 55 | }, 56 | "language_info": { 57 | "codemirror_mode": { 58 | "name": "ipython", 59 | "version": 3 60 | }, 61 | "file_extension": ".py", 62 | "mimetype": "text/x-python", 63 | "name": "python", 64 | "nbconvert_exporter": "python", 65 | "pygments_lexer": "ipython3", 66 | "version": "3.12.11" 67 | } 68 | }, 69 | "nbformat": 4, 70 | "nbformat_minor": 5 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Algorithmic Trading 2 | =================== 3 | 4 | **With Python and GenAI** 5 | 6 | Online Bootcamp by The Python Quants GmbH 7 | 8 | Dr. Yves J. Hilpisch
9 | CEO & CPF Program Director 10 | 11 | 12 | 13 | 14 | 15 | Slides 16 | ====== 17 | 18 | You find the slide deck at: 19 | 20 | https://certificate.tpq.io/algo_py_genai.pdf 21 | 22 | GotoWebinar 23 | =========== 24 | 25 | This is the registration link for the live sessions: 26 | 27 | https://attendee.gotowebinar.com/register/2582830228235168862 28 | 29 | Quant Platform 30 | ============== 31 | 32 | Here you can register for the Quant Platform (to e.g. access recordings later): 33 | 34 | https://genai.pqp.io/register 35 | 36 | GitHub Repository 37 | ================= 38 | 39 | You find the repository at: 40 | 41 | https://github.com/yhilpisch/algo_py_genai 42 | 43 | 44 | Discord Server 45 | ============== 46 | 47 | All Q&A shall take place on our Bootcamp Discord server: 48 | 49 | https://discord.gg/eanaY3vfKp 50 | 51 | **NOTE: Only the Discord server is relevant. Q&A on GotoWebinar will vanish after each session.** 52 | 53 | 54 | Accounts 55 | ======== 56 | 57 | You need an Oanda demo account (in/for the UK, that's important): 58 | 59 | https://oanda.com/uk-en/ 60 | 61 | For Oanda, you should install our Python API wrapper package as follows: 62 | 63 | pip install git+https://github.com/yhilpisch/tpqoa.git 64 | 65 | You should have a developer/API account with OpenAI (or an alternative provider): 66 | 67 | https://platform.openai.com 68 | 69 | From both platforms you need API keys. See their documentation in this regard. 70 | 71 | With the OpenAI API key, you can use `codex` on the CLI: 72 | 73 | https://github.com/openai/codex 74 | 75 | For cloud deployment, you should have an account e.g. with DigitalOcean (this link gives you a credit of 200 USD): 76 | 77 | https://m.do.co/c/fbe512dd3dac 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /01_session/efficiency_tests.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | from statsmodels.stats.diagnostic import acorr_ljungbox 4 | from scipy.stats import norm 5 | 6 | # ---------------------- 7 | # 1. Load data and compute returns 8 | # ---------------------- 9 | # Load CSV with 'Date' and stock price columns (e.g., 'AAPL') 10 | df = pd.read_csv('../data/eod_data.csv') # adjust path if needed 11 | symbol = 'NFLX' 12 | df[f'{symbol}_returns'] = np.log(df[symbol] / df[symbol].shift(1)) 13 | returns = df[f'{symbol}_returns'].dropna() 14 | 15 | # ---------------------- 16 | # 2. Ljung-Box Test (lag=10) 17 | # ---------------------- 18 | ljung_box_result = acorr_ljungbox(returns, lags=[10], return_df=True) 19 | lb_stat = ljung_box_result['lb_stat'].values[0] 20 | lb_pval = ljung_box_result['lb_pvalue'].values[0] 21 | 22 | # ---------------------- 23 | # 3. Variance Ratio Test (Lo–MacKinlay, lag=2) 24 | # ---------------------- 25 | def variance_ratio_test(x, lag=2): 26 | x = x - np.mean(x) 27 | n = len(x) 28 | mu = np.mean(x) 29 | b = np.sum((x - mu)**2) / (n - 1) 30 | t = np.sum((x[lag:] - x[:-lag])**2) / ((n - lag) * lag) 31 | vr = t / b 32 | z = (vr - 1) / np.sqrt((2 * (2 * lag - 1) * (lag - 1)) / (3 * lag * n)) 33 | p_value = 2 * (1 - norm.cdf(abs(z))) 34 | return vr, z, p_value 35 | 36 | vr_stat, vr_z, vr_pval = variance_ratio_test(returns.values, lag=2) 37 | 38 | # ---------------------- 39 | # 4. Runs Test (sign randomness) 40 | # ---------------------- 41 | def runs_test(x): 42 | median = np.median(x) 43 | runs, n1, n2 = 0, 0, 0 44 | signs = [] 45 | 46 | for i in x: 47 | if i >= median: 48 | signs.append(1) 49 | n1 += 1 50 | else: 51 | signs.append(0) 52 | n2 += 1 53 | 54 | for i in range(1, len(signs)): 55 | if signs[i] != signs[i-1]: 56 | runs += 1 57 | 58 | runs += 1 59 | expected_runs = ((2 * n1 * n2) / (n1 + n2)) + 1 60 | std_runs = np.sqrt((2 * n1 * n2 * (2 * n1 * n2 - n1 - n2)) / 61 | (((n1 + n2)**2) * (n1 + n2 - 1))) 62 | z = (runs - expected_runs) / std_runs 63 | p_value = 2 * (1 - norm.cdf(abs(z))) 64 | return runs, expected_runs, z, p_value 65 | 66 | runs_stat, runs_exp, runs_z, runs_pval = runs_test(returns.values) 67 | 68 | # ---------------------- 69 | # 5. Print Results 70 | # ---------------------- 71 | print(f"{'Test':<30}{'Test Statistic':>20}{'p-value':>15}") 72 | print("-" * 65) 73 | print(f"{'Ljung–Box (lag=10)':<30}{lb_stat:>20.4f}{lb_pval:>15.4e}") 74 | print(f"{'Variance Ratio (lag=2)':<30}{vr_stat:>20.4f}{vr_pval:>15.4e}") 75 | print(f"{'Runs Test':<30}{runs_z:>20.4f}{runs_pval:>15.4e}") 76 | -------------------------------------------------------------------------------- /02_session/prompts/user_prompts.txt: -------------------------------------------------------------------------------- 1 | Using my creds in oanda.cfg (never show or display them) and the API Python wrapper for Oanda found in tpqoa.py, implement a minimal test script that retrieves all the instruments available for my account. 2 | 3 | Now analyze carefully the strategy in mr_technical_strategy.md. Implement a detailed and accurate backtest of that strategy based on financial data from Oanda (instrument DE30_EUR, 1 month back in time from today, granularity 10 minutes, mid price). Use tpqoa and my creds to this end. Provide detailed statistics for the backtest in a nicely formatted way (table format). 4 | 5 | Refactore the code to be a Python class. And: (algo) studio:02_session yves$ python backtest_mr.py 6 | /Users/yves/Dropbox/Program/genai/git/02_session/backtest_mr.py:164: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). 7 | end = datetime.utcnow() 8 | 9 | Also make the data interval start/end arguments (default end=today, start=3 months back), also risk_pct (now 0.01, leave as default). And make all the parameters arguments for the Pytchon script. 10 | 11 | No matter what parameters I use, the script always reports only three trades. Make sure that the strategy logic is correctly implemented. 12 | 13 | Incorporate leverage into the backtest (by default 10). Add it also as an argument. 14 | 15 | Different values for leverage don't give different results: 16 | 17 | (algo) studio:02_session yves$ python backtest_mr.py --leverage 5 18 | 19 | Backtest Results: 20 | Total Trades Win Rate (%) Avg Win Avg Loss Profit Factor Sharpe Ratio Max Drawdown (%) Net PnL 21 | 4 50.00 6498.00 -8671.25 0.75 -0.22 -11.41 -4346.50 22 | (algo) studio:02_session yves$ python backtest_mr.py --leverage 20 23 | 24 | Backtest Results: 25 | Total Trades Win Rate (%) Avg Win Avg Loss Profit Factor Sharpe Ratio Max Drawdown (%) Net PnL 26 | 4 50.00 6498.00 -8671.25 0.75 -0.22 -11.41 -4346.50 27 | 28 | Add a plotly visualization for the backtested strategy that shows entries, exits, stop losses, take profits, etc. clearly in the chart. Use colors that make it easy to read the chart. In the legend only put general symbol and explanations (e.g. what symbol represents an entry signal/trade). The plot shall only be created if --plot True. 29 | 30 | For the price chart, plot the OHLC candlesticks instead of just the prices. In the legend differentiate between Entry long and Entry short. 31 | 32 | Zooming in does not properly anymore, the y axis does not adjust to the zooming. Also add a sub-plot with the equity curve. 33 | 34 | Check again the equity curve. It should start in the beginning with the initial equity and should go to the very end of the period. 35 | 36 | The equity curve should be plotted as a step function/curve and not via dots that are linerly connected. -------------------------------------------------------------------------------- /02_session/oanda_instruments.txt: -------------------------------------------------------------------------------- 1 | First 5 available instruments (display name, name): 2 | AUD/CAD: AUD_CAD 3 | AUD/CHF: AUD_CHF 4 | AUD/HKD: AUD_HKD 5 | AUD/JPY: AUD_JPY 6 | AUD/NZD: AUD_NZD 7 | AUD/SGD: AUD_SGD 8 | AUD/USD: AUD_USD 9 | Australia 200: AU200_AUD 10 | Brent Crude Oil: BCO_USD 11 | Bund: DE10YB_EUR 12 | CAD/CHF: CAD_CHF 13 | CAD/HKD: CAD_HKD 14 | CAD/JPY: CAD_JPY 15 | CAD/SGD: CAD_SGD 16 | CHF/HKD: CHF_HKD 17 | CHF/JPY: CHF_JPY 18 | CHF/ZAR: CHF_ZAR 19 | China A50: CN50_USD 20 | China H Shares: CHINAH_HKD 21 | Copper: XCU_USD 22 | Corn: CORN_USD 23 | EUR/AUD: EUR_AUD 24 | EUR/CAD: EUR_CAD 25 | EUR/CHF: EUR_CHF 26 | EUR/CZK: EUR_CZK 27 | EUR/DKK: EUR_DKK 28 | EUR/GBP: EUR_GBP 29 | EUR/HKD: EUR_HKD 30 | EUR/HUF: EUR_HUF 31 | EUR/JPY: EUR_JPY 32 | EUR/NOK: EUR_NOK 33 | EUR/NZD: EUR_NZD 34 | EUR/PLN: EUR_PLN 35 | EUR/SEK: EUR_SEK 36 | EUR/SGD: EUR_SGD 37 | EUR/TRY: EUR_TRY 38 | EUR/USD: EUR_USD 39 | EUR/ZAR: EUR_ZAR 40 | Europe 50: EU50_EUR 41 | France 40: FR40_EUR 42 | GBP/AUD: GBP_AUD 43 | GBP/CAD: GBP_CAD 44 | GBP/CHF: GBP_CHF 45 | GBP/HKD: GBP_HKD 46 | GBP/JPY: GBP_JPY 47 | GBP/NZD: GBP_NZD 48 | GBP/PLN: GBP_PLN 49 | GBP/SGD: GBP_SGD 50 | GBP/USD: GBP_USD 51 | GBP/ZAR: GBP_ZAR 52 | Germany 30: DE30_EUR 53 | Gold: XAU_USD 54 | Gold/AUD: XAU_AUD 55 | Gold/CAD: XAU_CAD 56 | Gold/CHF: XAU_CHF 57 | Gold/EUR: XAU_EUR 58 | Gold/GBP: XAU_GBP 59 | Gold/HKD: XAU_HKD 60 | Gold/JPY: XAU_JPY 61 | Gold/NZD: XAU_NZD 62 | Gold/SGD: XAU_SGD 63 | Gold/Silver: XAU_XAG 64 | HKD/JPY: HKD_JPY 65 | Hong Kong 33: HK33_HKD 66 | Japan 225: JP225_USD 67 | Japan 225 (JPY): JP225Y_JPY 68 | NZD/CAD: NZD_CAD 69 | NZD/CHF: NZD_CHF 70 | NZD/HKD: NZD_HKD 71 | NZD/JPY: NZD_JPY 72 | NZD/SGD: NZD_SGD 73 | NZD/USD: NZD_USD 74 | Natural Gas: NATGAS_USD 75 | Netherlands 25: NL25_EUR 76 | Palladium: XPD_USD 77 | Platinum: XPT_USD 78 | SGD/CHF: SGD_CHF 79 | SGD/JPY: SGD_JPY 80 | Silver: XAG_USD 81 | Silver/AUD: XAG_AUD 82 | Silver/CAD: XAG_CAD 83 | Silver/CHF: XAG_CHF 84 | Silver/EUR: XAG_EUR 85 | Silver/GBP: XAG_GBP 86 | Silver/HKD: XAG_HKD 87 | Silver/JPY: XAG_JPY 88 | Silver/NZD: XAG_NZD 89 | Silver/SGD: XAG_SGD 90 | Singapore 30: SG30_SGD 91 | Soybeans: SOYBN_USD 92 | Spain 35: ESPIX_EUR 93 | Sugar: SUGAR_USD 94 | Switzerland 20: CH20_CHF 95 | TRY/JPY: TRY_JPY 96 | UK 100: UK100_GBP 97 | UK 10Y Gilt: UK10YB_GBP 98 | US 10Y T-Note: USB10Y_USD 99 | US 2Y T-Note: USB02Y_USD 100 | US 5Y T-Note: USB05Y_USD 101 | US Nas 100: NAS100_USD 102 | US Russ 2000: US2000_USD 103 | US SPX 500: SPX500_USD 104 | US T-Bond: USB30Y_USD 105 | US Wall St 30: US30_USD 106 | USD/CAD: USD_CAD 107 | USD/CHF: USD_CHF 108 | USD/CNH: USD_CNH 109 | USD/CZK: USD_CZK 110 | USD/DKK: USD_DKK 111 | USD/HKD: USD_HKD 112 | USD/HUF: USD_HUF 113 | USD/JPY: USD_JPY 114 | USD/MXN: USD_MXN 115 | USD/NOK: USD_NOK 116 | USD/PLN: USD_PLN 117 | USD/SEK: USD_SEK 118 | USD/SGD: USD_SGD 119 | USD/THB: USD_THB 120 | USD/TRY: USD_TRY 121 | USD/ZAR: USD_ZAR 122 | West Texas Oil: WTICO_USD 123 | Wheat: WHEAT_USD 124 | ZAR/JPY: ZAR_JPY 125 | -------------------------------------------------------------------------------- /03_session/optimize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Brute-force optimization for MeanRevBacktester strategy parameters.""" 3 | 4 | import argparse 5 | import itertools 6 | import pandas as pd 7 | import time 8 | 9 | from backtest_mr_para import MeanRevBacktester 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser( 14 | description='Optimize mean-reversion strategy parameters' 15 | ) 16 | parser.add_argument('--config', default='oanda.cfg') 17 | parser.add_argument('--instrument', default='DE30_EUR') 18 | parser.add_argument('--granularity', default='M10') 19 | parser.add_argument('--price', default='M') 20 | parser.add_argument('--equity', type=float, default=100000.0) 21 | parser.add_argument('--risk-pct', type=float, default=0.01) 22 | parser.add_argument('--leverage', type=float, default=10.0) 23 | parser.add_argument('--start', default=None) 24 | parser.add_argument('--end', default=None) 25 | parser.add_argument('--top', type=int, default=10, 26 | help='number of top combinations to display') 27 | args = parser.parse_args() 28 | 29 | base_bt = MeanRevBacktester( 30 | config=args.config, 31 | instrument=args.instrument, 32 | granularity=args.granularity, 33 | price=args.price, 34 | equity=args.equity, 35 | risk_pct=args.risk_pct, 36 | leverage=args.leverage, 37 | start=args.start, 38 | end=args.end, 39 | ) 40 | raw_df = base_bt.fetch_data() 41 | # echo exact data window so backtests can be repeated on identical history 42 | print(f"Data window: start={base_bt.start.isoformat()} end={base_bt.end.isoformat()}" 43 | f" ({len(raw_df)} bars)") 44 | print("To reproduce exactly, rerun backtest_mr_para.py with these --start/--end values and your chosen strategy flags.") 45 | 46 | grid = { 47 | 'bb_window': [10, 20, 30], 48 | 'bb_std_dev': [1.5, 2.0, 2.5], 49 | 'rsi_period': [10, 14, 20], 50 | 'ema_period': [10, 20], 51 | 'atr_period': [10, 14], 52 | } 53 | keys, values = zip(*grid.items()) 54 | combos = list(itertools.product(*values)) 55 | total = len(combos) 56 | results = [] 57 | start_time = time.time() 58 | for idx, combo in enumerate(combos, start=1): 59 | params = dict(zip(keys, combo)) 60 | print(f"Testing combo {idx}/{total}: {params}", flush=True) 61 | bt = MeanRevBacktester( 62 | config=args.config, 63 | instrument=args.instrument, 64 | granularity=args.granularity, 65 | price=args.price, 66 | equity=args.equity, 67 | risk_pct=args.risk_pct, 68 | leverage=args.leverage, 69 | start=args.start, 70 | end=args.end, 71 | bb_window=params['bb_window'], 72 | bb_std_dev=params['bb_std_dev'], 73 | rsi_period=params['rsi_period'], 74 | ema_period=params['ema_period'], 75 | atr_period=params['atr_period'], 76 | ) 77 | df = bt.compute_indicators(raw_df.copy()) 78 | trades = bt.run_backtest(df) 79 | pnl = sum(t['pnl'] for t in trades) 80 | n = len(trades) 81 | wins = [t for t in trades if t['pnl'] > 0] 82 | win_rate = len(wins) / n * 100 if n else 0.0 83 | results.append({**params, 'net_pnl': pnl, 84 | 'n_trades': n, 'win_rate': win_rate}) 85 | 86 | elapsed = time.time() - start_time 87 | df_res = pd.DataFrame(results) 88 | df_top = df_res.sort_values('net_pnl', ascending=False).head(args.top) 89 | print(f"Optimization completed in {elapsed:.1f}s") 90 | print(f'Top {args.top} parameter combinations:') 91 | print(df_top.to_string(index=False, float_format='%.2f')) 92 | 93 | 94 | if __name__ == '__main__': 95 | main() 96 | -------------------------------------------------------------------------------- /01_session/streamlit_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Streamlit app to fetch and display Oanda instrument historical data as Plotly candlesticks. 4 | 5 | Allows selection of instrument, start/end dates, and granularity (default daily). 6 | Reads credentials from 'oanda.cfg' in the same directory; credentials are never hardcoded. 7 | """ 8 | 9 | import streamlit as st 10 | import pandas as pd 11 | import plotly.graph_objects as go 12 | import tpqoa 13 | 14 | 15 | def main(): 16 | st.title("Oanda Historical Data Viewer") 17 | 18 | # Initialize Oanda API client and fetch instruments 19 | oanda = tpqoa.tpqoa("oanda.cfg") 20 | instruments = oanda.get_instruments() 21 | instrument_display = [f"{dn} ({n})" for dn, n in instruments] 22 | choice = st.sidebar.selectbox("Instrument", instrument_display) 23 | instrument = choice.split("(")[-1].strip(")") 24 | 25 | # Date inputs: default last 3 months to today (UTC date) 26 | today = pd.Timestamp.utcnow().date() 27 | default_start = (pd.Timestamp.utcnow() - pd.DateOffset(months=3)).date() 28 | start_date = st.sidebar.date_input("Start date", value=default_start) 29 | end_date = st.sidebar.date_input("End date", value=today) 30 | 31 | # Granularity selection 32 | gran_options = ["D", "H1", "H4", "M30", "M15", "M5", "M1"] 33 | gran = st.sidebar.selectbox("Granularity", gran_options, index=0) 34 | 35 | # Button to fetch data and store in session state 36 | if 'df' not in st.session_state: 37 | st.session_state.df = None 38 | if st.sidebar.button("Fetch data"): 39 | with st.spinner("Fetching data…"): 40 | df = oanda.get_history( 41 | instrument, start_date, end_date, granularity=gran, price="M" 42 | ) 43 | if df.empty: 44 | st.warning("No data found for this range.") 45 | st.session_state.df = None 46 | else: 47 | st.session_state.df = df 48 | 49 | # If data is loaded, display charts and optional table 50 | if st.session_state.df is not None: 51 | df = st.session_state.df 52 | 53 | # Main candlestick chart 54 | fig = go.Figure( 55 | data=[go.Candlestick( 56 | x=df.index, open=df['o'], high=df['h'], low=df['l'], close=df['c'] 57 | )] 58 | ) 59 | fig.update_layout( 60 | title=f"{instrument} {gran} mid-price", 61 | xaxis_title="Time", yaxis_title="Price" 62 | ) 63 | st.plotly_chart(fig, use_container_width=True) 64 | 65 | # Comparison with up to two other instruments 66 | others = [f"{dn} ({n})" for dn, n in instruments if n != instrument] 67 | compare = st.sidebar.multiselect( 68 | "Compare closes with (max 2)", others, 69 | help="Select up to two instruments" 70 | )[:2] 71 | if compare: 72 | comp_df = pd.DataFrame({instrument: df['c']}) 73 | for sel in compare: 74 | sym = sel.split("(")[-1].strip(")") 75 | df2 = oanda.get_history(sym, start_date, end_date, granularity=gran, price="M") 76 | comp_df[sym] = df2['c'] 77 | # Optionally normalize series to 1 at first point 78 | norm = st.sidebar.radio("Normalize comparison series?", ("No", "Yes"), index=0) 79 | if norm == "Yes": 80 | comp_df = comp_df / comp_df.iloc[0] 81 | fig2 = go.Figure() 82 | for col in comp_df.columns: 83 | fig2.add_trace(go.Scatter(x=comp_df.index, y=comp_df[col], mode='lines', name=col)) 84 | fig2.update_layout( 85 | title=("Normalized Closing Price Comparison" if norm == "Yes" else "Closing Price Comparison"), 86 | xaxis_title="Time", yaxis_title=("Normalized Price" if norm == "Yes" else "Price") 87 | ) 88 | st.plotly_chart(fig2, use_container_width=True) 89 | 90 | # Optional data table 91 | show_tbl = st.sidebar.radio("Show data table?", ("No", "Yes"), index=0) 92 | if show_tbl == "Yes": 93 | st.dataframe(df) 94 | 95 | 96 | if __name__ == "__main__": 97 | main() 98 | -------------------------------------------------------------------------------- /03_session/mr_trading.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Live mean-reversion trading using 3-second bars and tpqoa streaming. 4 | 5 | Fixed strategy parameters: 6 | bb_window=10, bb_std_dev=2, rsi_period=14, 7 | ema_period=10, atr_period=14 8 | """ 9 | 10 | import argparse 11 | import logging 12 | 13 | import numpy as np 14 | import pandas as pd 15 | 16 | from tpqoa import tpqoa 17 | 18 | # Set up simple trade logging 19 | logging.basicConfig( 20 | filename='trade.log', 21 | level=logging.INFO, 22 | format='%(asctime)s - %(levelname)s - %(message)s' 23 | ) 24 | 25 | 26 | def main(): 27 | p = argparse.ArgumentParser(description='Live MR trading (3s bars)') 28 | p.add_argument('--config', default='oanda.cfg', 29 | help='OANDA config file') 30 | p.add_argument('--instrument', default='DE30_EUR', 31 | help='instrument to trade') 32 | args = p.parse_args() 33 | 34 | api = tpqoa(args.config) 35 | # Strategy params 36 | bb_w, bb_sd = 10, 2 37 | rsi_p, ema_p, atr_p = 14, 10, 14 38 | 39 | # Tick buffer and bar storage 40 | ticks = [] 41 | bars = [] 42 | start = None 43 | position = 0 44 | 45 | def on_tick(inst, t, bid, ask): 46 | nonlocal ticks, bars, start, position 47 | # collect mid-price ticks 48 | dt = pd.to_datetime(t) 49 | price = 0.5 * (bid + ask) 50 | if start is None: 51 | start = dt 52 | ticks.append(price) 53 | # build bar every 3 seconds 54 | if (dt - start).total_seconds() >= 3: 55 | o = ticks[0] 56 | h = max(ticks) 57 | l = min(ticks) 58 | c = ticks[-1] 59 | bars.append({'time': start, 'o': o, 'h': h, 'l': l, 'c': c}) 60 | # show progress: number of bars collected 61 | print(f"\rBars collected: {len(bars)}", end='', flush=True) 62 | ticks = [] 63 | start = dt 64 | df = pd.DataFrame(bars).set_index('time') 65 | if len(df) >= bb_w: 66 | # compute indicators 67 | df['mb'] = df['c'].rolling(bb_w).mean() 68 | df['std'] = df['c'].rolling(bb_w).std() 69 | df['ub'] = df['mb'] + bb_sd * df['std'] 70 | df['lb'] = df['mb'] - bb_sd * df['std'] 71 | delta = df['c'].diff() 72 | up = delta.clip(lower=0) 73 | down = -delta.clip(upper=0) 74 | roll_up = up.ewm(com=rsi_p-1, adjust=False).mean() 75 | roll_down = down.ewm(com=rsi_p-1, adjust=False).mean() 76 | rs = roll_up / roll_down 77 | df['rsi'] = 100 - 100 / (1 + rs) 78 | df['ema'] = df['c'].ewm(span=ema_p, adjust=False).mean() 79 | tr1 = df['h'] - df['l'] 80 | tr2 = (df['h'] - df['c'].shift()).abs() 81 | tr3 = (df['l'] - df['c'].shift()).abs() 82 | df['atr'] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1).rolling(atr_p).mean() 83 | last = df.iloc[-1] 84 | price = last['c'] 85 | # entry logic: simple one position at a time 86 | if position == 0: 87 | if price < last['lb'] and last['rsi'] < 30: 88 | api.create_order(args.instrument, 1) 89 | position = 1 90 | print(f"{last.name} LONG @ {price:.2f}") 91 | logging.info(f"ENTRY LONG time={last.name} price={price:.2f}") 92 | elif price > last['ub'] and last['rsi'] > 70: 93 | api.create_order(args.instrument, -1) 94 | position = -1 95 | print(f"{last.name} SHORT @ {price:.2f}") 96 | logging.info(f"ENTRY SHORT time={last.name} price={price:.2f}") 97 | # exit logic: simple mean reversion to middle band 98 | elif position == 1 and price >= last['mb']: 99 | api.create_order(args.instrument, -1) 100 | position = 0 101 | print(f"{last.name} EXIT LONG @ {price:.2f}") 102 | logging.info(f"EXIT LONG time={last.name} price={price:.2f}") 103 | elif position == -1 and price <= last['mb']: 104 | api.create_order(args.instrument, 1) 105 | position = 0 106 | print(f"{last.name} EXIT SHORT @ {price:.2f}") 107 | logging.info(f"EXIT SHORT time={last.name} price={price:.2f}") 108 | 109 | # start streaming ticks 110 | print("Collecting 3-second bars...", end='\n') 111 | api.stream_data(args.instrument, callback=on_tick) 112 | 113 | 114 | if __name__ == '__main__': 115 | main() 116 | -------------------------------------------------------------------------------- /03_session/oanda_spreads.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Oanda UK Average Bid-Ask Spread (in Percent) 4 | 5 | ### Overview 6 | 7 | Oanda UK offers variable spreads across different instrument categories. The bid-ask spread (difference between the buy and sell price) is a primary component of trading costs for retail clients. The spread is typically quoted in pips for forex, points for indices, and absolute values for commodities. For comparison, all have been converted to percent of instrument price. 8 | 9 | ### Typical Average Spreads by Category 10 | 11 | | Instrument Type | Example Instrument | Average Spread (pips/points) | Approximate Spread (%) | 12 | | :-- | :-- | :-- | :-- | 13 | | FX (Majors) | EUR/USD | 0.8 pips[^1] | 0.007% | 14 | | FX (Majors) | GBP/USD | 1.4 pips[^2][^1] | 0.011% | 15 | | FX (Minors) | EUR/GBP | 0.9 pips[^1] | 0.008% | 16 | | FX (Minors) | GBP/AUD | 3.8 pips[^3] | 0.022% | 17 | | Indices | UK 100 | 1.8 points[^4] | ~0.000024% | 18 | | Indices | Germany 40 | 1.2 points[^5] | ~0.000015% | 19 | | Metals/Commodities | Gold (XAU/USD) | 0.3 USD (typical) | ~0.000016% | 20 | 21 | > **Note:** The percent values are calculated by dividing the spread (in quote currency or points) by the current price and multiplying by 100. 22 | 23 | ### Spread Ranges by Asset Class 24 | 25 | - **FX Majors:** Average 0.007–0.02% (spreads are narrowest for most liquid pairs like EUR/USD, wider for minors/exotics)[^1]. 26 | - **Commodities/Metals:** Spread (in percent) is extremely low, typically around 0.000016% for gold, but this can fluctuate based on market volatility. 27 | - **Indices:** Spreads for major indices like UK 100 and Germany 40 are quoted from 1.0–1.8 points, percent values are even lower due to high index values, usually about 0.00002%[^4][^5]. 28 | 29 | 30 | ### Typical Range (Percent) by Category 31 | 32 | - **FX:** 0.007%–0.02% 33 | - **Commodities/Metals:** ~0.00001%–0.0001% (varies by instrument) 34 | - **Indices:** ~0.00001%–0.00003% 35 | 36 | 37 | ### Key Considerations 38 | 39 | - Spreads at Oanda are variable and may widen during market open/close, high volatility, or significant news events[^6][^7]. 40 | - Actual trading costs may be higher during low liquidity periods. 41 | - Commodity and index spreads look very tight in percent terms due to the much higher nominal prices of these instruments. 42 | 43 | 44 | ### Summary Table 45 | 46 | | Asset Class | Typical Spread (%) | 47 | | :-- | :-- | 48 | | FX Majors | 0.007–0.02 | 49 | | FX Minors | 0.015–0.03 | 50 | | Indices | ~0.00001–0.00003 | 51 | | Commodities | ~0.00001–0.0001 | 52 | 53 | These values provide a general overview; exact spreads may be checked live on Oanda's trading platform[^8][^1][^4]. 54 | 55 |
56 | 57 | [^1]: https://brokerchooser.com/broker-reviews/oanda-review/oanda-forex-spread 58 | 59 | [^2]: https://brokerchooser.com/broker-reviews/oanda-review/oanda-gbpusd-spread 60 | 61 | [^3]: https://brokerchooser.com/broker-reviews/oanda-review/oanda-gbpaud-spread 62 | 63 | [^4]: https://brokerchooser.com/broker-reviews/oanda-review/uk100-spread 64 | 65 | [^5]: https://www.oanda.com/uk-en/trading/cfds/indices/ 66 | 67 | [^6]: https://www.oanda.com/bvi-en/cfds/spreads-margin/ 68 | 69 | [^7]: https://www.oanda.com/uk-en/trading/spreads-margin/ 70 | 71 | [^8]: https://www.oanda.com/uk-en/trading/historical-spreads/ 72 | 73 | [^9]: https://www.oanda.com/us-en/trading/historical-spreads/ 74 | 75 | [^10]: https://www.oanda.com/currency-converter/live-exchange-rates/ 76 | 77 | [^11]: https://help.oanda.com/uk/en/faqs/bid-ask-price.htm 78 | 79 | [^12]: https://www.oanda.com/us-en/trading/spreads-margin/ 80 | 81 | [^13]: https://help.oanda.com/bvi/en/faqs/bid-ask-price.htm 82 | 83 | [^14]: https://www.oanda.com/assets/documents/672/API_Accuracy_Claim_Report.pdf 84 | 85 | [^15]: https://www.oanda.com/bvi-en/cfds/indices/ 86 | 87 | [^16]: https://www.oanda.com/au-en/trading/historical-spreads/ 88 | 89 | [^17]: https://www.oanda.com/bvi-en/cfds/learn/introduction-to-leverage-trading/what-is-a-pip/ 90 | 91 | [^18]: https://www.oanda.com/sg-en/trading/spreads-margin/ 92 | 93 | [^19]: https://www.oanda.com/ca-en/trading/spreads-margin/ 94 | 95 | [^20]: https://www.oanda.com/au-en/trading/our-pricing/ 96 | 97 | [^21]: https://www.oanda.com/bvi-en/cfds/forex/ 98 | 99 | [^22]: https://www.oanda.com/ca-en/tools/historical-spreads/ 100 | 101 | [^23]: https://www.bestbrokers.com/reviews/oanda/ 102 | 103 | [^24]: https://www.bestbrokers.com/reviews/oanda/spreads-fees-and-commissions/ 104 | 105 | [^25]: https://www.spread-bet.co.uk/betting/oanda/ 106 | 107 | [^26]: https://www.oanda.com/bvi-en/cfds/instruments/uk100-gbp/ 108 | 109 | [^27]: https://www.compareforexbrokers.com/reviews/oanda/ 110 | 111 | [^28]: https://www.oanda.com/currency-converter/en/ 112 | 113 | [^29]: https://www.myfxbook.com/forex-broker-spreads/oanda/4582,1 114 | 115 | -------------------------------------------------------------------------------- /02_session/mr_technical_strategy.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Intraday Mean-Reversion Algorithmic Trading Strategy 4 | 5 | Below is a concrete, step-by-step algorithmic trading strategy for intraday mean reversion, utilizing several technical indicators and specifying all thresholds and parameters. This approach is designed for liquid equities but can be modified for other instruments. 6 | 7 | ## Objective 8 | 9 | Capitalize on short-term price deviations from a defined mean within the day, betting that prices revert toward an average after becoming briefly overbought or oversold. 10 | 11 | ## Indicators Used 12 | 13 | - **Bollinger Bands (20-period, 2 standard deviations)** 14 | - **Relative Strength Index (RSI, 14-period)** 15 | - **Exponential Moving Average (EMA, 20-period)** 16 | - **Average True Range (ATR, 14-period) – for volatility-based position sizing/stop-loss** 17 | 18 | 19 | ## Exact Strategy Rules 20 | 21 | ### 1. Data \& Timeframe 22 | 23 | - **Timeframe**: 5-minute candles. 24 | - **Instruments**: Only trade stocks with high intraday liquidity (e.g., S\&P 500 stocks). 25 | 26 | 27 | ### 2. Entry Conditions 28 | 29 | #### **Long Entry (Buy)** 30 | 31 | - The close of the current 5-min candle is **below the lower Bollinger Band** (20-period, 2 SD). 32 | - RSI (14) of current candle is **below 30**. 33 | - The price is **not further than 2×ATR below** the 20-period EMA (prevents entry when far outside the expected range). 34 | - No other open long position in the same instrument. 35 | 36 | 37 | #### **Short Entry (Sell)** 38 | 39 | - The close of the current 5-min candle is **above the upper Bollinger Band** (20-period, 2 SD). 40 | - RSI (14) is **above 70**. 41 | - The price is **not further than 2×ATR above** the 20-period EMA. 42 | - No other open short position in the same instrument. 43 | 44 | 45 | ### 3. Position Sizing 46 | 47 | - Risk **1% of account equity per trade**. 48 | - **Position size = (Account Equity × 1%) ÷ (Entry Price – Stop Loss distance)** where Stop Loss distance is dynamically set (see below)[^1]. 49 | 50 | 51 | ### 4. Stop-Loss and Exit 52 | 53 | - **Stop-Loss**: Set at **1.5×ATR** away from entry price, in the direction of the stop. 54 | - **Exit Take-Profit**: When price: 55 | - Closes at or crosses **back to the middle Bollinger Band (20-period moving average)** or 56 | - RSI returns to between 45 and 55, or 57 | - After a **maximum of 1 hour** after entry (timed exit to enforce intraday discipline). 58 | - Optionally, **partial exit** when price reaches the EMA; move stop to breakeven on the remainder[^2][^1][^3]. 59 | 60 | 61 | ### 5. Additional Risk Controls 62 | 63 | - **Maximum of 3 concurrent trades** open at any time. 64 | - **No entries during first 5 minutes after market open** or last 15 minutes before close (to avoid opening/closing volatility). 65 | - **No entry if one of the last three candles was a high-momentum bar** (>2× average 5-min range for the last 20 candles). 66 | 67 | 68 | ## Example 69 | 70 | | Indicator/Value | Long Entry Signal | Short Entry Signal | 71 | | :-- | :-- | :-- | 72 | | Bollinger Band (20, 2 SD) | Close < Lower Band | Close > Upper Band | 73 | | RSI (14-period) | < 30 | > 70 | 74 | | Distance from 20-EMA | > (but not >2×ATR) | < (but not <−2×ATR) | 75 | | Volatility Filter (ATR) | Used for stops | Used for stops | 76 | | Position Sizing | 1% equity | 1% equity | 77 | | Exit | MA cross or time | MA cross or time | 78 | 79 | ## Practical Notes 80 | 81 | - **Backtest and optimize** the thresholds (e.g., 2 SD, RSI 30/70, ATR multipliers) for your specific universe and timeframe. 82 | - Monitor for slippage and transaction costs; avoiding very illiquid names is essential for intraday execution. 83 | - Avoid trading during major economic news releases to minimize whipsaw risk. 84 | 85 | 86 | ## Strategy Rationale 87 | 88 | This strategy combines three perspectives to maximize mean-reversion reliability: 89 | 90 | - **Price extremes:** Bollinger Bands detect statistical outliers. 91 | - **Momentum confirmation:** RSI confirms that the move is truly overextended. 92 | - **Adaptive risk:** ATR and EMA ensure entries/exits/position sizes adapt to current volatility. 93 | 94 | These details provide a precisely specified, highly testable blueprint for an intraday mean-reversion algorithmic system[^2][^1][^3]. 95 | 96 |
97 | 98 | [^1]: https://www.luxalgo.com/blog/mean-reversion-strategies-for-algorithmic-trading/ 99 | 100 | [^2]: https://tradefundrr.com/mean-reversion-trading-techniques/ 101 | 102 | [^3]: https://macrogmsecurities.com.au/long-only-algorithmic-trading-strategies-for-stocks/ 103 | 104 | [^4]: https://www.cmcmarkets.com/en-gb/trading-guides/mean-reversion 105 | 106 | [^5]: https://de.tradingview.com/scripts/mean-reversion/ 107 | 108 | [^6]: https://www.samco.in/knowledge-center/articles/mean-reversion-trading-strategies/ 109 | 110 | [^7]: https://www.tradingview.com/script/EzDT1Dzf-Intraday-Mean-Reversion-Main/ 111 | 112 | [^8]: https://howtotrade.com/wp-content/uploads/2023/11/Mean-Reversion-Trading.pdf 113 | 114 | [^9]: https://quant.stackexchange.com/questions/78719/how-find-optimal-entry-exit-thresholds-for-a-mean-reverting-process 115 | 116 | [^10]: https://static1.squarespace.com/static/5fc9e2e184bf712dcfcc8dcf/t/5fca100cec1d586fb3934ac9/1607077902561/Kami+Export+-+Profitable_Algorithmic_Trading_Strategie+(1)+(6).pdf 117 | 118 | [^11]: https://www.quantifiedstrategies.com/mean-reversion-trading-strategy/ 119 | 120 | [^12]: https://www.reddit.com/r/algotrading/comments/1cwsco8/a_mean_reversion_strategy_with_211_sharpe/ 121 | 122 | [^13]: https://www.mql5.com/en/blogs/post/753277 123 | 124 | [^14]: https://www.interactivebrokers.com/campus/ibkr-quant-news/mean-reversion-strategies-introduction-trading-strategies-and-more-part-i/ 125 | 126 | [^15]: https://www.quantitativo.com/p/trading-the-mean-reversion-curve 127 | 128 | [^16]: https://blog.quantinsti.com/mean-reversion-strategies-introduction-building-blocks/ 129 | 130 | [^17]: https://machinelearning-basics.com/mean-reversion-trading-strategy-using-python/ 131 | 132 | [^18]: https://www.fmz.com/lang/en/strategy/430247 133 | 134 | [^19]: https://www.reddit.com/r/algotrading/comments/13ivyk6/what_are_your_day_trading_mean_reversion/ 135 | 136 | [^20]: https://in.tradingview.com/scripts/meanreversion/ 137 | 138 | -------------------------------------------------------------------------------- /03_session/oanda_instruments.json: -------------------------------------------------------------------------------- 1 | { 2 | "FX": [ 3 | { 4 | "displayName": "AUD/CAD", 5 | "instrument": "AUD_CAD" 6 | }, 7 | { 8 | "displayName": "AUD/CHF", 9 | "instrument": "AUD_CHF" 10 | }, 11 | { 12 | "displayName": "AUD/HKD", 13 | "instrument": "AUD_HKD" 14 | }, 15 | { 16 | "displayName": "AUD/JPY", 17 | "instrument": "AUD_JPY" 18 | }, 19 | { 20 | "displayName": "AUD/NZD", 21 | "instrument": "AUD_NZD" 22 | }, 23 | { 24 | "displayName": "AUD/SGD", 25 | "instrument": "AUD_SGD" 26 | }, 27 | { 28 | "displayName": "AUD/USD", 29 | "instrument": "AUD_USD" 30 | }, 31 | { 32 | "displayName": "CAD/CHF", 33 | "instrument": "CAD_CHF" 34 | }, 35 | { 36 | "displayName": "CAD/HKD", 37 | "instrument": "CAD_HKD" 38 | }, 39 | { 40 | "displayName": "CAD/JPY", 41 | "instrument": "CAD_JPY" 42 | }, 43 | { 44 | "displayName": "CAD/SGD", 45 | "instrument": "CAD_SGD" 46 | }, 47 | { 48 | "displayName": "CHF/HKD", 49 | "instrument": "CHF_HKD" 50 | }, 51 | { 52 | "displayName": "CHF/JPY", 53 | "instrument": "CHF_JPY" 54 | }, 55 | { 56 | "displayName": "CHF/ZAR", 57 | "instrument": "CHF_ZAR" 58 | }, 59 | { 60 | "displayName": "EUR/AUD", 61 | "instrument": "EUR_AUD" 62 | }, 63 | { 64 | "displayName": "EUR/CAD", 65 | "instrument": "EUR_CAD" 66 | }, 67 | { 68 | "displayName": "EUR/CHF", 69 | "instrument": "EUR_CHF" 70 | }, 71 | { 72 | "displayName": "EUR/CZK", 73 | "instrument": "EUR_CZK" 74 | }, 75 | { 76 | "displayName": "EUR/DKK", 77 | "instrument": "EUR_DKK" 78 | }, 79 | { 80 | "displayName": "EUR/GBP", 81 | "instrument": "EUR_GBP" 82 | }, 83 | { 84 | "displayName": "EUR/HKD", 85 | "instrument": "EUR_HKD" 86 | }, 87 | { 88 | "displayName": "EUR/HUF", 89 | "instrument": "EUR_HUF" 90 | }, 91 | { 92 | "displayName": "EUR/JPY", 93 | "instrument": "EUR_JPY" 94 | }, 95 | { 96 | "displayName": "EUR/NOK", 97 | "instrument": "EUR_NOK" 98 | }, 99 | { 100 | "displayName": "EUR/NZD", 101 | "instrument": "EUR_NZD" 102 | }, 103 | { 104 | "displayName": "EUR/PLN", 105 | "instrument": "EUR_PLN" 106 | }, 107 | { 108 | "displayName": "EUR/SEK", 109 | "instrument": "EUR_SEK" 110 | }, 111 | { 112 | "displayName": "EUR/SGD", 113 | "instrument": "EUR_SGD" 114 | }, 115 | { 116 | "displayName": "EUR/TRY", 117 | "instrument": "EUR_TRY" 118 | }, 119 | { 120 | "displayName": "EUR/USD", 121 | "instrument": "EUR_USD" 122 | }, 123 | { 124 | "displayName": "EUR/ZAR", 125 | "instrument": "EUR_ZAR" 126 | }, 127 | { 128 | "displayName": "GBP/AUD", 129 | "instrument": "GBP_AUD" 130 | }, 131 | { 132 | "displayName": "GBP/CAD", 133 | "instrument": "GBP_CAD" 134 | }, 135 | { 136 | "displayName": "GBP/CHF", 137 | "instrument": "GBP_CHF" 138 | }, 139 | { 140 | "displayName": "GBP/HKD", 141 | "instrument": "GBP_HKD" 142 | }, 143 | { 144 | "displayName": "GBP/JPY", 145 | "instrument": "GBP_JPY" 146 | }, 147 | { 148 | "displayName": "GBP/NZD", 149 | "instrument": "GBP_NZD" 150 | }, 151 | { 152 | "displayName": "GBP/PLN", 153 | "instrument": "GBP_PLN" 154 | }, 155 | { 156 | "displayName": "GBP/SGD", 157 | "instrument": "GBP_SGD" 158 | }, 159 | { 160 | "displayName": "GBP/USD", 161 | "instrument": "GBP_USD" 162 | }, 163 | { 164 | "displayName": "GBP/ZAR", 165 | "instrument": "GBP_ZAR" 166 | }, 167 | { 168 | "displayName": "HKD/JPY", 169 | "instrument": "HKD_JPY" 170 | }, 171 | { 172 | "displayName": "NZD/CAD", 173 | "instrument": "NZD_CAD" 174 | }, 175 | { 176 | "displayName": "NZD/CHF", 177 | "instrument": "NZD_CHF" 178 | }, 179 | { 180 | "displayName": "NZD/HKD", 181 | "instrument": "NZD_HKD" 182 | }, 183 | { 184 | "displayName": "NZD/JPY", 185 | "instrument": "NZD_JPY" 186 | }, 187 | { 188 | "displayName": "NZD/SGD", 189 | "instrument": "NZD_SGD" 190 | }, 191 | { 192 | "displayName": "NZD/USD", 193 | "instrument": "NZD_USD" 194 | }, 195 | { 196 | "displayName": "SGD/CHF", 197 | "instrument": "SGD_CHF" 198 | }, 199 | { 200 | "displayName": "SGD/JPY", 201 | "instrument": "SGD_JPY" 202 | }, 203 | { 204 | "displayName": "TRY/JPY", 205 | "instrument": "TRY_JPY" 206 | }, 207 | { 208 | "displayName": "USD/CAD", 209 | "instrument": "USD_CAD" 210 | }, 211 | { 212 | "displayName": "USD/CHF", 213 | "instrument": "USD_CHF" 214 | }, 215 | { 216 | "displayName": "USD/CNH", 217 | "instrument": "USD_CNH" 218 | }, 219 | { 220 | "displayName": "USD/CZK", 221 | "instrument": "USD_CZK" 222 | }, 223 | { 224 | "displayName": "USD/DKK", 225 | "instrument": "USD_DKK" 226 | }, 227 | { 228 | "displayName": "USD/HKD", 229 | "instrument": "USD_HKD" 230 | }, 231 | { 232 | "displayName": "USD/HUF", 233 | "instrument": "USD_HUF" 234 | }, 235 | { 236 | "displayName": "USD/JPY", 237 | "instrument": "USD_JPY" 238 | }, 239 | { 240 | "displayName": "USD/MXN", 241 | "instrument": "USD_MXN" 242 | }, 243 | { 244 | "displayName": "USD/NOK", 245 | "instrument": "USD_NOK" 246 | }, 247 | { 248 | "displayName": "USD/PLN", 249 | "instrument": "USD_PLN" 250 | }, 251 | { 252 | "displayName": "USD/SEK", 253 | "instrument": "USD_SEK" 254 | }, 255 | { 256 | "displayName": "USD/SGD", 257 | "instrument": "USD_SGD" 258 | }, 259 | { 260 | "displayName": "USD/THB", 261 | "instrument": "USD_THB" 262 | }, 263 | { 264 | "displayName": "USD/TRY", 265 | "instrument": "USD_TRY" 266 | }, 267 | { 268 | "displayName": "USD/ZAR", 269 | "instrument": "USD_ZAR" 270 | }, 271 | { 272 | "displayName": "ZAR/JPY", 273 | "instrument": "ZAR_JPY" 274 | } 275 | ], 276 | "Indices": [ 277 | { 278 | "displayName": "Australia 200", 279 | "instrument": "AU200_AUD" 280 | }, 281 | { 282 | "displayName": "China A50", 283 | "instrument": "CN50_USD" 284 | }, 285 | { 286 | "displayName": "China H Shares", 287 | "instrument": "CHINAH_HKD" 288 | }, 289 | { 290 | "displayName": "Europe 50", 291 | "instrument": "EU50_EUR" 292 | }, 293 | { 294 | "displayName": "France 40", 295 | "instrument": "FR40_EUR" 296 | }, 297 | { 298 | "displayName": "Germany 30", 299 | "instrument": "DE30_EUR" 300 | }, 301 | { 302 | "displayName": "Hong Kong 33", 303 | "instrument": "HK33_HKD" 304 | }, 305 | { 306 | "displayName": "Japan 225", 307 | "instrument": "JP225_USD" 308 | }, 309 | { 310 | "displayName": "Japan 225 (JPY)", 311 | "instrument": "JP225Y_JPY" 312 | }, 313 | { 314 | "displayName": "Netherlands 25", 315 | "instrument": "NL25_EUR" 316 | }, 317 | { 318 | "displayName": "Singapore 30", 319 | "instrument": "SG30_SGD" 320 | }, 321 | { 322 | "displayName": "Spain 35", 323 | "instrument": "ESPIX_EUR" 324 | }, 325 | { 326 | "displayName": "Switzerland 20", 327 | "instrument": "CH20_CHF" 328 | }, 329 | { 330 | "displayName": "UK 100", 331 | "instrument": "UK100_GBP" 332 | }, 333 | { 334 | "displayName": "US Nas 100", 335 | "instrument": "NAS100_USD" 336 | }, 337 | { 338 | "displayName": "US Russ 2000", 339 | "instrument": "US2000_USD" 340 | }, 341 | { 342 | "displayName": "US SPX 500", 343 | "instrument": "SPX500_USD" 344 | }, 345 | { 346 | "displayName": "US Wall St 30", 347 | "instrument": "US30_USD" 348 | } 349 | ], 350 | "Commodities": [ 351 | { 352 | "displayName": "Brent Crude Oil", 353 | "instrument": "BCO_USD" 354 | }, 355 | { 356 | "displayName": "Copper", 357 | "instrument": "XCU_USD" 358 | }, 359 | { 360 | "displayName": "Corn", 361 | "instrument": "CORN_USD" 362 | }, 363 | { 364 | "displayName": "Gold", 365 | "instrument": "XAU_USD" 366 | }, 367 | { 368 | "displayName": "Gold/AUD", 369 | "instrument": "XAU_AUD" 370 | }, 371 | { 372 | "displayName": "Gold/CAD", 373 | "instrument": "XAU_CAD" 374 | }, 375 | { 376 | "displayName": "Gold/CHF", 377 | "instrument": "XAU_CHF" 378 | }, 379 | { 380 | "displayName": "Gold/EUR", 381 | "instrument": "XAU_EUR" 382 | }, 383 | { 384 | "displayName": "Gold/GBP", 385 | "instrument": "XAU_GBP" 386 | }, 387 | { 388 | "displayName": "Gold/HKD", 389 | "instrument": "XAU_HKD" 390 | }, 391 | { 392 | "displayName": "Gold/JPY", 393 | "instrument": "XAU_JPY" 394 | }, 395 | { 396 | "displayName": "Gold/NZD", 397 | "instrument": "XAU_NZD" 398 | }, 399 | { 400 | "displayName": "Gold/SGD", 401 | "instrument": "XAU_SGD" 402 | }, 403 | { 404 | "displayName": "Gold/Silver", 405 | "instrument": "XAU_XAG" 406 | }, 407 | { 408 | "displayName": "Natural Gas", 409 | "instrument": "NATGAS_USD" 410 | }, 411 | { 412 | "displayName": "Palladium", 413 | "instrument": "XPD_USD" 414 | }, 415 | { 416 | "displayName": "Platinum", 417 | "instrument": "XPT_USD" 418 | }, 419 | { 420 | "displayName": "Silver", 421 | "instrument": "XAG_USD" 422 | }, 423 | { 424 | "displayName": "Silver/AUD", 425 | "instrument": "XAG_AUD" 426 | }, 427 | { 428 | "displayName": "Silver/CAD", 429 | "instrument": "XAG_CAD" 430 | }, 431 | { 432 | "displayName": "Silver/CHF", 433 | "instrument": "XAG_CHF" 434 | }, 435 | { 436 | "displayName": "Silver/EUR", 437 | "instrument": "XAG_EUR" 438 | }, 439 | { 440 | "displayName": "Silver/GBP", 441 | "instrument": "XAG_GBP" 442 | }, 443 | { 444 | "displayName": "Silver/HKD", 445 | "instrument": "XAG_HKD" 446 | }, 447 | { 448 | "displayName": "Silver/JPY", 449 | "instrument": "XAG_JPY" 450 | }, 451 | { 452 | "displayName": "Silver/NZD", 453 | "instrument": "XAG_NZD" 454 | }, 455 | { 456 | "displayName": "Silver/SGD", 457 | "instrument": "XAG_SGD" 458 | }, 459 | { 460 | "displayName": "Soybeans", 461 | "instrument": "SOYBN_USD" 462 | }, 463 | { 464 | "displayName": "Sugar", 465 | "instrument": "SUGAR_USD" 466 | }, 467 | { 468 | "displayName": "West Texas Oil", 469 | "instrument": "WTICO_USD" 470 | }, 471 | { 472 | "displayName": "Wheat", 473 | "instrument": "WHEAT_USD" 474 | } 475 | ], 476 | "Bonds": [ 477 | { 478 | "displayName": "Bund", 479 | "instrument": "DE10YB_EUR" 480 | }, 481 | { 482 | "displayName": "UK 10Y Gilt", 483 | "instrument": "UK10YB_GBP" 484 | }, 485 | { 486 | "displayName": "US 10Y T-Note", 487 | "instrument": "USB10Y_USD" 488 | }, 489 | { 490 | "displayName": "US 2Y T-Note", 491 | "instrument": "USB02Y_USD" 492 | }, 493 | { 494 | "displayName": "US 5Y T-Note", 495 | "instrument": "USB05Y_USD" 496 | }, 497 | { 498 | "displayName": "US T-Bond", 499 | "instrument": "USB30Y_USD" 500 | } 501 | ] 502 | } -------------------------------------------------------------------------------- /02_session/backtest_mr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Backtest intraday mean-reversion strategy on DE30_EUR using tpqoa. 4 | Refactored as a class; uses timezone-aware datetime.now(timezone.utc). 5 | """ 6 | 7 | import sys 8 | from datetime import datetime, timedelta, timezone 9 | 10 | import numpy as np 11 | import pandas as pd 12 | from dateutil.relativedelta import relativedelta 13 | 14 | from tpqoa import tpqoa 15 | from dateutil.parser import parse 16 | import argparse 17 | from dateutil.parser import parse 18 | import argparse 19 | 20 | 21 | class MeanRevBacktester: 22 | """Intraday mean-reversion backtester for DE30_EUR.""" 23 | 24 | def __init__(self, 25 | config='oanda.cfg', instrument='DE30_EUR', 26 | granularity='M10', price='M', equity=100000.0, 27 | risk_pct=0.01, leverage=10.0, start=None, end=None): 28 | self.oanda = tpqoa(config) 29 | self.instrument = instrument 30 | self.granularity = granularity 31 | self.price = price 32 | self.initial_equity = equity 33 | self.risk_pct = risk_pct 34 | self.leverage = leverage 35 | self.plot = False 36 | # parse or default end/start (default: end=now UTC, start=3mo back) 37 | if end is None: 38 | self.end = datetime.now(timezone.utc) 39 | else: 40 | dt = parse(end) 41 | self.end = dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) 42 | if start is None: 43 | self.start = self.end - relativedelta(months=3) 44 | else: 45 | dt = parse(start) 46 | self.start = dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) 47 | 48 | def fetch_data(self): 49 | df = self.oanda.get_history( 50 | self.instrument, self.start, self.end, 51 | self.granularity, self.price 52 | ) 53 | if df.empty: 54 | print('No data retrieved.') 55 | sys.exit(1) 56 | return df 57 | 58 | def compute_indicators(self, df): 59 | # Bollinger Bands 60 | df['mb'] = df['c'].rolling(20).mean() 61 | df['std'] = df['c'].rolling(20).std() 62 | df['ub'] = df['mb'] + 2 * df['std'] 63 | df['lb'] = df['mb'] - 2 * df['std'] 64 | # RSI(14) 65 | delta = df['c'].diff() 66 | up = delta.clip(lower=0) 67 | down = -delta.clip(upper=0) 68 | roll_up = up.ewm(com=14 - 1, adjust=False).mean() 69 | roll_down = down.ewm(com=14 - 1, adjust=False).mean() 70 | rs = roll_up / roll_down 71 | df['rsi'] = 100 - 100 / (1 + rs) 72 | # EMA(20) 73 | df['ema'] = df['c'].ewm(span=20, adjust=False).mean() 74 | # ATR(14) 75 | tr1 = df['h'] - df['l'] 76 | tr2 = (df['h'] - df['c'].shift()).abs() 77 | tr3 = (df['l'] - df['c'].shift()).abs() 78 | df['tr'] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) 79 | df['atr'] = df['tr'].rolling(14).mean() 80 | # Range & avg range(20) 81 | df['range'] = df['h'] - df['l'] 82 | df['avg_range20'] = df['range'].rolling(20).mean() 83 | return df.dropna() 84 | 85 | def run_backtest(self, df): 86 | equity = self.initial_equity 87 | trades = [] 88 | i, n = 0, len(df) 89 | max_concurrent = 3 90 | open_trades = 0 91 | 92 | while i < n: 93 | row = df.iloc[i] 94 | time = df.index[i] 95 | # momentum filter: skip if last 3 bars >2×avg_range20 96 | recent = df['range'].iloc[max(i - 3, 0):i] 97 | if recent.gt(2 * df['avg_range20'].iloc[max(i - 3, 0):i]).any(): 98 | i += 1 99 | continue 100 | long_entry = ( 101 | row['c'] < row['lb'] and row['rsi'] < 30 102 | and row['c'] >= row['ema'] - 2 * row['atr'] 103 | ) 104 | short_entry = ( 105 | row['c'] > row['ub'] and row['rsi'] > 70 106 | and row['c'] <= row['ema'] + 2 * row['atr'] 107 | ) 108 | # Long 109 | if long_entry and open_trades < max_concurrent: 110 | entry = row['c'] 111 | stop = entry - 1.5 * row['atr'] 112 | target = row['mb'] 113 | # position sizing with leverage 114 | risk = equity * self.risk_pct 115 | units = int((risk * self.leverage) / (entry - stop)) if entry > stop else 0 116 | cutoff = time + timedelta(hours=1) 117 | open_trades += 1 118 | for j in range(i + 1, n): 119 | r = df.iloc[j]; t = df.index[j] 120 | if r['c'] <= stop or r['c'] >= target or 45 <= r['rsi'] <= 55 or t >= cutoff: 121 | pnl = (r['c'] - entry) * units 122 | equity += pnl 123 | trades.append({ 124 | 'entry_time': time, 'exit_time': t, 125 | 'direction': 'long', 'entry': entry, 126 | 'exit': r['c'], 'stop': stop, 'target': target, 127 | 'units': units, 'pnl': pnl, 'equity': equity 128 | }) 129 | i = j 130 | open_trades -= 1 131 | break 132 | # Short 133 | elif short_entry and open_trades < max_concurrent: 134 | entry = row['c'] 135 | stop = entry + 1.5 * row['atr'] 136 | target = row['mb'] 137 | # position sizing with leverage 138 | risk = equity * self.risk_pct 139 | units = int((risk * self.leverage) / (stop - entry)) if stop > entry else 0 140 | cutoff = time + timedelta(hours=1) 141 | open_trades += 1 142 | for j in range(i + 1, n): 143 | r = df.iloc[j]; t = df.index[j] 144 | if r['c'] >= stop or r['c'] <= target or 45 <= r['rsi'] <= 55 or t >= cutoff: 145 | pnl = (entry - r['c']) * units 146 | equity += pnl 147 | trades.append({ 148 | 'entry_time': time, 'exit_time': t, 149 | 'direction': 'short', 'entry': entry, 150 | 'exit': r['c'], 'stop': stop, 'target': target, 151 | 'units': units, 'pnl': pnl, 'equity': equity 152 | }) 153 | i = j 154 | open_trades -= 1 155 | break 156 | i += 1 157 | return trades 158 | 159 | def summarize(self, df, trades): 160 | tr_df = pd.DataFrame(trades) 161 | wins = tr_df[tr_df['pnl'] > 0] 162 | losses = tr_df[tr_df['pnl'] <= 0] 163 | net = tr_df['pnl'].sum() 164 | total = len(tr_df) 165 | stats = { 166 | 'Total Trades': total, 167 | 'Win Rate (%)': len(wins) / total * 100 if total else 0, 168 | 'Avg Win': wins['pnl'].mean() if not wins.empty else 0, 169 | 'Avg Loss': losses['pnl'].mean() if not losses.empty else 0, 170 | 'Profit Factor': wins['pnl'].sum() / abs(losses['pnl'].sum()) if losses['pnl'].sum() else np.nan, 171 | 'Sharpe Ratio': (tr_df['pnl'] / self.initial_equity).mean() / (tr_df['pnl'] / self.initial_equity).std() * np.sqrt(len(tr_df)) if len(tr_df) > 1 else np.nan, 172 | 'Max Drawdown (%)': (tr_df['equity'] - tr_df['equity'].cummax()).min() / tr_df['equity'].cummax().max() * 100, 173 | 'Net PnL': net 174 | } 175 | out = pd.DataFrame([stats]) 176 | print('\nBacktest Results:') 177 | print(out.to_string(index=False, float_format='%.2f')) 178 | # plot if requested 179 | if self.plot: 180 | self.plot_trades(df, trades) 181 | 182 | def plot_trades(self, df, trades): 183 | """Plot price series with trade markers, stops, and targets.""" 184 | import plotly.graph_objects as go 185 | 186 | from plotly.subplots import make_subplots 187 | 188 | # create two-row subplot: price (with candlesticks) and equity curve 189 | fig = make_subplots(rows=2, cols=1, 190 | shared_xaxes=True, 191 | vertical_spacing=0.02, 192 | row_heights=[0.7, 0.3]) 193 | # plot OHLC candlesticks in top row 194 | fig.add_trace(go.Candlestick( 195 | x=df.index, 196 | open=df['o'], high=df['h'], low=df['l'], close=df['c'], 197 | name='OHLC' 198 | ), row=1, col=1) 199 | shown = { 200 | 'entry_long': False, 'entry_short': False, 201 | 'exit': False, 'stop': False, 'target': False 202 | } 203 | for tr in trades: 204 | et, xt = tr['entry_time'], tr['exit_time'] 205 | eprice, xprice = tr['entry'], tr['exit'] 206 | stop, target = tr['stop'], tr['target'] 207 | direction = tr['direction'] 208 | # entry marker 209 | entry_key = 'entry_long' if direction == 'long' else 'entry_short' 210 | entry_name = 'Entry (Long)' if direction == 'long' else 'Entry (Short)' 211 | fig.add_trace(go.Scatter( 212 | x=[et], y=[eprice], mode='markers', 213 | marker_symbol='triangle-up' if direction == 'long' else 'triangle-down', 214 | marker_color='green' if direction == 'long' else 'red', 215 | marker_size=12, name=entry_name, 216 | showlegend=not shown[entry_key] 217 | )) 218 | shown[entry_key] = True 219 | fig.add_trace(go.Scatter( 220 | x=[xt], y=[xprice], mode='markers', 221 | marker_symbol='x', marker_color='black', marker_size=12, 222 | name='Exit', showlegend=not shown['exit'] 223 | )) 224 | shown['exit'] = True 225 | fig.add_trace(go.Scatter( 226 | x=[et, xt], y=[stop, stop], mode='lines', 227 | line=dict(color='red', dash='dash'), 228 | name='Stop', showlegend=not shown['stop'] 229 | )) 230 | shown['stop'] = True 231 | fig.add_trace(go.Scatter( 232 | x=[et, xt], y=[target, target], mode='lines', 233 | line=dict(color='green', dash='dash'), 234 | name='Target', showlegend=not shown['target'] 235 | )) 236 | shown['target'] = True 237 | # add equity curve to bottom row, anchored at start and end 238 | idx0 = df.index[0] 239 | idxN = df.index[-1] 240 | eq_times = [idx0] + [tr['exit_time'] for tr in trades] + [idxN] 241 | last_eq = trades[-1]['equity'] 242 | eq_vals = [self.initial_equity] + [tr['equity'] for tr in trades] + [last_eq] 243 | fig.add_trace(go.Scatter( 244 | x=eq_times, y=eq_vals, mode='lines', 245 | line=dict(color='blue', shape='hv'), name='Equity' 246 | ), row=2, col=1) 247 | # layout updates 248 | fig.update_layout( 249 | title=f'Trades and Equity Curve for {self.instrument}', 250 | xaxis=dict(rangeslider=dict(visible=False)), 251 | hovermode='x unified' 252 | ) 253 | # autorange y-axes on zoom 254 | fig.update_yaxes(autorange=True, row=1, col=1) 255 | fig.update_yaxes(autorange=True, row=2, col=1) 256 | fig.show() 257 | 258 | def run(self): 259 | df = self.fetch_data() 260 | data = self.compute_indicators(df) 261 | trades = self.run_backtest(data) 262 | if not trades: 263 | print('No trades executed.') 264 | sys.exit(0) 265 | self.summarize(data, trades) 266 | if self.plot: 267 | self.plot_trades(data, trades) 268 | 269 | 270 | def parse_args(): 271 | p = argparse.ArgumentParser(description='Mean-reversion backtest') 272 | p.add_argument('--config', default='oanda.cfg') 273 | p.add_argument('--instrument', default='DE30_EUR') 274 | p.add_argument('--granularity', default='M10') 275 | p.add_argument('--price', default='M') 276 | p.add_argument('--equity', default=100000.0, type=float) 277 | p.add_argument('--risk-pct', default=0.01, type=float, 278 | help='fraction of equity to risk per trade') 279 | p.add_argument('--leverage', default=10.0, type=float, 280 | help='leverage multiplier for position sizing') 281 | p.add_argument('--start', default=None, 282 | help='start datetime (ISO8601)') 283 | p.add_argument('--end', default=None, 284 | help='end datetime (ISO8601)') 285 | p.add_argument('--plot', action='store_true', 286 | help='show trade plot') 287 | return p.parse_args() 288 | 289 | if __name__ == '__main__': 290 | args = parse_args() 291 | bt = MeanRevBacktester( 292 | config=args.config, 293 | instrument=args.instrument, 294 | granularity=args.granularity, 295 | price=args.price, 296 | equity=args.equity, 297 | risk_pct=args.risk_pct, 298 | leverage=args.leverage, 299 | start=args.start, 300 | end=args.end 301 | ) 302 | bt.plot = args.plot 303 | bt.plot = args.plot 304 | bt.run() 305 | -------------------------------------------------------------------------------- /01_session/tpqoa.py: -------------------------------------------------------------------------------- 1 | # 2 | # tpqoa is a wrapper class for the 3 | # Oanda v20 API (RESTful & streaming) 4 | # making use of the v20 Python package 5 | # 6 | # (c) Dr. Yves J. Hilpisch 7 | # The Python Quants GmbH 8 | # 9 | # 10 | # Trading forex/CFDs on margin carries a high level of risk and may 11 | # not be suitable for all investors as you could sustain losses 12 | # in excess of deposits. Leverage can work against you. Due to the certain 13 | # restrictions imposed by the local law and regulation, German resident 14 | # retail client(s) could sustain a total loss of deposited funds but are 15 | # not subject to subsequent payment obligations beyond the deposited funds. 16 | # Be aware and fully understand all risks associated with 17 | # the market and trading. Prior to trading any products, 18 | # carefully consider your financial situation and 19 | # experience level. Any opinions, news, research, analyses, prices, 20 | # or other information is provided as general market commentary, and does not 21 | # constitute investment advice. The Python Quants GmbH will not accept 22 | # liability for any loss or damage, including without limitation to, 23 | # any loss of profit, which may arise directly or indirectly from use 24 | # of or reliance on such information. 25 | # 26 | # The tpqoa package is intended as a technological illustration only. 27 | # It comes with no warranties or representations, 28 | # to the extent permitted by applicable law. 29 | # 30 | import _thread 31 | import configparser 32 | import json 33 | import signal 34 | import threading 35 | from time import sleep 36 | 37 | import pandas as pd 38 | import v20 39 | from v20.transaction import StopLossDetails, ClientExtensions 40 | from v20.transaction import TrailingStopLossDetails, TakeProfitDetails 41 | 42 | MAX_REQUEST_COUNT = float(5000) 43 | 44 | 45 | class Job(threading.Thread): 46 | def __init__(self, job_callable, args=None): 47 | threading.Thread.__init__(self) 48 | self.callable = job_callable 49 | self.args = args 50 | 51 | # The shutdown_flag is a threading.Event object that 52 | # indicates whether the thread should be terminated. 53 | self.shutdown_flag = threading.Event() 54 | self.job = None 55 | self.exception = None 56 | 57 | def run(self): 58 | print('Thread #%s started' % self.ident) 59 | try: 60 | self.job = self.callable 61 | while not self.shutdown_flag.is_set(): 62 | print("Starting job loop...") 63 | if self.args is None: 64 | self.job() 65 | else: 66 | self.job(self.args) 67 | except Exception as e: 68 | import sys 69 | import traceback 70 | print(traceback.format_exc()) 71 | self.exception = e 72 | _thread.interrupt_main() 73 | 74 | 75 | class ServiceExit(Exception): 76 | """ 77 | Custom exception which is used to trigger the clean exit 78 | of all running threads and the main program. 79 | """ 80 | 81 | def __init__(self, message=None): 82 | self.message = message 83 | 84 | def __repr__(self): 85 | return repr(self.message) 86 | 87 | 88 | def service_shutdown(signum, frame): 89 | print('exiting ...') 90 | raise ServiceExit 91 | 92 | 93 | class tpqoa(object): 94 | ''' tpqoa is a Python wrapper class for the Oanda v20 API. ''' 95 | 96 | def __init__(self, conf_file): 97 | ''' Init function is expecting a configuration file with 98 | the following content: 99 | 100 | [oanda] 101 | account_id = XYZ-ABC-... 102 | access_token = ZYXCAB... 103 | account_type = practice (default) or live 104 | 105 | Parameters 106 | ========== 107 | conf_file: string 108 | path to and filename of the configuration file, 109 | e.g. '/home/me/oanda.cfg' 110 | ''' 111 | self.config = configparser.ConfigParser() 112 | self.config.read(conf_file) 113 | self.access_token = self.config['oanda']['access_token'] 114 | self.account_id = self.config['oanda']['account_id'] 115 | self.account_type = self.config['oanda']['account_type'] 116 | 117 | if self.account_type == 'live': 118 | self.hostname = 'api-fxtrade.oanda.com' 119 | self.stream_hostname = 'stream-fxtrade.oanda.com' 120 | else: 121 | self.hostname = 'api-fxpractice.oanda.com' 122 | self.stream_hostname = 'stream-fxpractice.oanda.com' 123 | 124 | self.ctx = v20.Context( 125 | hostname=self.hostname, 126 | port=443, 127 | token=self.access_token, 128 | poll_timeout=10 129 | ) 130 | self.ctx_stream = v20.Context( 131 | hostname=self.stream_hostname, 132 | port=443, 133 | token=self.access_token, 134 | ) 135 | 136 | # suffix for nanosecond precision, retained for backward compatibility 137 | self.suffix = '.000000000Z' 138 | self.stop_stream = False 139 | 140 | def get_instruments(self): 141 | ''' Retrieves and returns all instruments for the given account. ''' 142 | resp = self.ctx.account.instruments(self.account_id) 143 | instruments = resp.get('instruments') 144 | instruments = [ins.dict() for ins in instruments] 145 | instruments = [(ins['displayName'], ins['name']) 146 | for ins in instruments] 147 | return sorted(instruments) 148 | 149 | def get_prices(self, instrument): 150 | ''' Returns the current BID/ASK prices for instrument. ''' 151 | r = self.ctx.pricing.get(self.account_id, instruments=instrument) 152 | r = json.loads(r.raw_body) 153 | bid = float(r['prices'][0]['closeoutBid']) 154 | ask = float(r['prices'][0]['closeoutAsk']) 155 | return r['time'], bid, ask 156 | 157 | def transform_datetime(self, dati): 158 | """Transforms datetime or string to an RFC3339 UTC timestamp.""" 159 | # Ensure pandas Timestamp for date, datetime or string 160 | dati = pd.Timestamp(dati) 161 | # Convert timezone-aware timestamps to UTC and make naive 162 | if dati.tzinfo is not None: 163 | dati = dati.tz_convert('UTC').tz_localize(None) 164 | # Format as 'YYYY-MM-DDTHH:MM:SSZ' 165 | return dati.strftime('%Y-%m-%dT%H:%M:%SZ') 166 | 167 | def retrieve_data(self, instrument, start, end, granularity, price): 168 | raw = self.ctx.instrument.candles( 169 | instrument=instrument, 170 | fromTime=start, toTime=end, 171 | granularity=granularity, price=price) 172 | raw = raw.get('candles') 173 | raw = [cs.dict() for cs in raw] 174 | if price == 'A': 175 | for cs in raw: 176 | cs.update(cs['ask']) 177 | del cs['ask'] 178 | elif price == 'B': 179 | for cs in raw: 180 | cs.update(cs['bid']) 181 | del cs['bid'] 182 | elif price == 'M': 183 | for cs in raw: 184 | cs.update(cs['mid']) 185 | del cs['mid'] 186 | else: 187 | raise ValueError("Price must be either 'B', 'A' or 'M'.") 188 | if len(raw) == 0: 189 | return pd.DataFrame() # return empty DataFrame if no data 190 | data = pd.DataFrame(raw) 191 | data['time'] = pd.to_datetime(data['time']) 192 | data = data.set_index('time') 193 | data.index = pd.DatetimeIndex(data.index) 194 | for col in list('ohlc'): 195 | data[col] = data[col].astype(float) 196 | return data 197 | 198 | def get_history(self, instrument, start, end, 199 | granularity, price, localize=True): 200 | ''' Retrieves historical data for instrument. 201 | 202 | Parameters 203 | ========== 204 | instrument: string 205 | valid instrument name 206 | start, end: datetime, str 207 | Python datetime or string objects for start and end 208 | granularity: string 209 | a string like 'S5', 'M1' or 'D' 210 | price: string 211 | one of 'A' (ask), 'B' (bid) or 'M' (middle) 212 | 213 | Returns 214 | ======= 215 | data: pd.DataFrame 216 | pandas DataFrame object with data 217 | ''' 218 | if granularity.startswith('S') or granularity.startswith('M') \ 219 | or granularity.startswith('H'): 220 | multiplier = float("".join(filter(str.isdigit, granularity))) 221 | if granularity.startswith('S'): 222 | # freq = '1h' 223 | freq = f"{int(MAX_REQUEST_COUNT * multiplier / float(3600))}H" 224 | else: 225 | # freq = 'D' 226 | freq = f"{int(MAX_REQUEST_COUNT * multiplier / float(1440))}D" 227 | data = pd.DataFrame() 228 | dr = pd.date_range(start, end, freq=freq) 229 | 230 | for t in range(len(dr)): 231 | batch_start = self.transform_datetime(dr[t]) 232 | if t != len(dr) - 1: 233 | batch_end = self.transform_datetime(dr[t + 1]) 234 | else: 235 | batch_end = self.transform_datetime(end) 236 | 237 | batch = self.retrieve_data(instrument, batch_start, batch_end, 238 | granularity, price) 239 | data = pd.concat([data, batch]) 240 | else: 241 | start = self.transform_datetime(start) 242 | end = self.transform_datetime(end) 243 | data = self.retrieve_data(instrument, start, end, 244 | granularity, price) 245 | if localize: 246 | data.index = data.index.tz_localize(None) 247 | 248 | return data[['o', 'h', 'l', 'c', 'volume', 'complete']] 249 | 250 | def create_order(self, instrument, units, price=None, sl_distance=None, 251 | tsl_distance=None, tp_price=None, comment=None, 252 | touch=False, suppress=False, ret=False): 253 | ''' Places order with Oanda. 254 | 255 | Parameters 256 | ========== 257 | instrument: string 258 | valid instrument name 259 | units: int 260 | number of units of instrument to be bought 261 | (positive int, eg 'units=50') 262 | or to be sold (negative int, eg 'units=-100') 263 | price: float 264 | limit order price, touch order price 265 | sl_distance: float 266 | stop loss distance price, mandatory eg in Germany 267 | tsl_distance: float 268 | trailing stop loss distance 269 | tp_price: float 270 | take profit price to be used for the trade 271 | comment: str 272 | string 273 | touch: boolean 274 | market_if_touched order (requires price to be set) 275 | suppress: boolean 276 | whether to suppress print out 277 | ret: boolean 278 | whether to return the order object 279 | ''' 280 | client_ext = ClientExtensions( 281 | comment=comment) if comment is not None else None 282 | sl_details = (StopLossDetails(distance=sl_distance, 283 | clientExtensions=client_ext) 284 | if sl_distance is not None else None) 285 | tsl_details = (TrailingStopLossDetails(distance=tsl_distance, 286 | clientExtensions=client_ext) 287 | if tsl_distance is not None else None) 288 | tp_details = (TakeProfitDetails( 289 | price=tp_price, clientExtensions=client_ext) 290 | if tp_price is not None else None) 291 | if price is None: 292 | request = self.ctx.order.market( 293 | self.account_id, 294 | instrument=instrument, 295 | units=units, 296 | stopLossOnFill=sl_details, 297 | trailingStopLossOnFill=tsl_details, 298 | takeProfitOnFill=tp_details, 299 | ) 300 | elif touch: 301 | request = self.ctx.order.market_if_touched( 302 | self.account_id, 303 | instrument=instrument, 304 | price=price, 305 | units=units, 306 | stopLossOnFill=sl_details, 307 | trailingStopLossOnFill=tsl_details, 308 | takeProfitOnFill=tp_details 309 | ) 310 | else: 311 | request = self.ctx.order.limit( 312 | self.account_id, 313 | instrument=instrument, 314 | price=price, 315 | units=units, 316 | stopLossOnFill=sl_details, 317 | trailingStopLossOnFill=tsl_details, 318 | takeProfitOnFill=tp_details 319 | ) 320 | 321 | # First checking if the order is rejected 322 | if 'orderRejectTransaction' in request.body: 323 | order = request.get('orderRejectTransaction') 324 | elif 'orderFillTransaction' in request.body: 325 | order = request.get('orderFillTransaction') 326 | elif 'orderCreateTransaction' in request.body: 327 | order = request.get('orderCreateTransaction') 328 | else: 329 | # This case does not happen. But keeping this for completeness. 330 | order = None 331 | 332 | if not suppress and order is not None: 333 | print('\n\n', order.dict(), '\n') 334 | if ret is True: 335 | return order.dict() if order is not None else None 336 | 337 | def stream_data(self, instrument, stop=None, ret=False, callback=None): 338 | ''' Starts a real-time data stream. 339 | 340 | Parameters 341 | ========== 342 | instrument: string 343 | valid instrument name 344 | ''' 345 | self.stream_instrument = instrument 346 | self.ticks = 0 347 | response = self.ctx_stream.pricing.stream( 348 | self.account_id, snapshot=True, 349 | instruments=instrument) 350 | msgs = [] 351 | for msg_type, msg in response.parts(): 352 | msgs.append(msg) 353 | # print(msg_type, msg) 354 | if msg_type == 'pricing.ClientPrice': 355 | self.ticks += 1 356 | self.time = msg.time 357 | if callback is not None: 358 | callback(msg.instrument, msg.time, 359 | float(msg.bids[0].dict()['price']), 360 | float(msg.asks[0].dict()['price'])) 361 | else: 362 | self.on_success(msg.time, 363 | float(msg.bids[0].dict()['price']), 364 | float(msg.asks[0].dict()['price'])) 365 | if stop is not None: 366 | if self.ticks >= stop: 367 | if ret: 368 | return msgs 369 | break 370 | if self.stop_stream: 371 | if ret: 372 | return msgs 373 | break 374 | 375 | def _stream_data_failsafe_thread(self, args): 376 | try: 377 | print("Starting price streaming") 378 | self.stream_data(args[0], callback=args[1]) 379 | except Exception as e: 380 | import sys 381 | import traceback 382 | print(traceback.format_exc()) 383 | sleep(3) 384 | return 385 | 386 | def stream_data_failsafe(self, instrument, callback=None): 387 | signal.signal(signal.SIGTERM, service_shutdown) 388 | signal.signal(signal.SIGINT, service_shutdown) 389 | signal.signal(signal.SIGSEGV, service_shutdown) 390 | try: 391 | price_stream_thread = Job(self._stream_data_failsafe_thread, 392 | [instrument, callback]) 393 | price_stream_thread.start() 394 | return price_stream_thread 395 | except ServiceExit as e: 396 | print('Handling exception') 397 | import sys 398 | import traceback 399 | print(traceback) 400 | price_stream_thread.shutdown_flag.set() 401 | price_stream_thread.join() 402 | 403 | def on_success(self, time, bid, ask): 404 | ''' Method called when new data is retrieved. ''' 405 | print(time, bid, ask) 406 | 407 | def get_account_summary(self, detailed=False): 408 | ''' Returns summary data for Oanda account.''' 409 | if detailed is True: 410 | response = self.ctx.account.get(self.account_id) 411 | else: 412 | response = self.ctx.account.summary(self.account_id) 413 | raw = response.get('account') 414 | return raw.dict() 415 | 416 | def get_transaction(self, tid=0): 417 | ''' Retrieves and returns transaction data. ''' 418 | response = self.ctx.transaction.get(self.account_id, tid) 419 | transaction = response.get('transaction') 420 | return transaction.dict() 421 | 422 | def get_transactions(self, tid=0): 423 | ''' Retrieves and returns transactions data. ''' 424 | response = self.ctx.transaction.since(self.account_id, id=tid) 425 | transactions = response.get('transactions') 426 | transactions = [t.dict() for t in transactions] 427 | return transactions 428 | 429 | def print_transactions(self, tid=0): 430 | ''' Prints basic transactions data. ''' 431 | transactions = self.get_transactions(tid) 432 | for trans in transactions: 433 | try: 434 | templ = '%4s | %s | %7s | %8s | %8s' 435 | print(templ % (trans['id'], 436 | trans['time'][:-8], 437 | trans['instrument'], 438 | trans['units'], 439 | trans['pl'])) 440 | except Exception: 441 | pass 442 | 443 | def get_positions(self): 444 | ''' Retrieves and returns positions data. ''' 445 | response = self.ctx.position.list_open(self.account_id).body 446 | positions = [p.dict() for p in response.get('positions')] 447 | return positions 448 | 449 | def cancel_order(self, order_id): 450 | ''' Cancels an order (e.g. SL order). 451 | 452 | Parameters 453 | ========== 454 | order_id: int 455 | valid order id 456 | ''' 457 | response = self.ctx.order.cancel(self.account_id, order_id) 458 | return json.loads(response.raw_body) 459 | -------------------------------------------------------------------------------- /02_session/tpqoa.py: -------------------------------------------------------------------------------- 1 | # 2 | # tpqoa is a wrapper class for the 3 | # Oanda v20 API (RESTful & streaming) 4 | # making use of the v20 Python package 5 | # 6 | # (c) Dr. Yves J. Hilpisch 7 | # The Python Quants GmbH 8 | # 9 | # 10 | # Trading forex/CFDs on margin carries a high level of risk and may 11 | # not be suitable for all investors as you could sustain losses 12 | # in excess of deposits. Leverage can work against you. Due to the certain 13 | # restrictions imposed by the local law and regulation, German resident 14 | # retail client(s) could sustain a total loss of deposited funds but are 15 | # not subject to subsequent payment obligations beyond the deposited funds. 16 | # Be aware and fully understand all risks associated with 17 | # the market and trading. Prior to trading any products, 18 | # carefully consider your financial situation and 19 | # experience level. Any opinions, news, research, analyses, prices, 20 | # or other information is provided as general market commentary, and does not 21 | # constitute investment advice. The Python Quants GmbH will not accept 22 | # liability for any loss or damage, including without limitation to, 23 | # any loss of profit, which may arise directly or indirectly from use 24 | # of or reliance on such information. 25 | # 26 | # The tpqoa package is intended as a technological illustration only. 27 | # It comes with no warranties or representations, 28 | # to the extent permitted by applicable law. 29 | # 30 | import _thread 31 | import configparser 32 | import json 33 | import signal 34 | import threading 35 | from time import sleep 36 | 37 | import pandas as pd 38 | import v20 39 | from v20.transaction import StopLossDetails, ClientExtensions 40 | from v20.transaction import TrailingStopLossDetails, TakeProfitDetails 41 | 42 | MAX_REQUEST_COUNT = float(5000) 43 | 44 | 45 | class Job(threading.Thread): 46 | def __init__(self, job_callable, args=None): 47 | threading.Thread.__init__(self) 48 | self.callable = job_callable 49 | self.args = args 50 | 51 | # The shutdown_flag is a threading.Event object that 52 | # indicates whether the thread should be terminated. 53 | self.shutdown_flag = threading.Event() 54 | self.job = None 55 | self.exception = None 56 | 57 | def run(self): 58 | print('Thread #%s started' % self.ident) 59 | try: 60 | self.job = self.callable 61 | while not self.shutdown_flag.is_set(): 62 | print("Starting job loop...") 63 | if self.args is None: 64 | self.job() 65 | else: 66 | self.job(self.args) 67 | except Exception as e: 68 | import sys 69 | import traceback 70 | print(traceback.format_exc()) 71 | self.exception = e 72 | _thread.interrupt_main() 73 | 74 | 75 | class ServiceExit(Exception): 76 | """ 77 | Custom exception which is used to trigger the clean exit 78 | of all running threads and the main program. 79 | """ 80 | 81 | def __init__(self, message=None): 82 | self.message = message 83 | 84 | def __repr__(self): 85 | return repr(self.message) 86 | 87 | 88 | def service_shutdown(signum, frame): 89 | print('exiting ...') 90 | raise ServiceExit 91 | 92 | 93 | class tpqoa(object): 94 | ''' tpqoa is a Python wrapper class for the Oanda v20 API. ''' 95 | 96 | def __init__(self, conf_file): 97 | ''' Init function is expecting a configuration file with 98 | the following content: 99 | 100 | [oanda] 101 | account_id = XYZ-ABC-... 102 | access_token = ZYXCAB... 103 | account_type = practice (default) or live 104 | 105 | Parameters 106 | ========== 107 | conf_file: string 108 | path to and filename of the configuration file, 109 | e.g. '/home/me/oanda.cfg' 110 | ''' 111 | self.config = configparser.ConfigParser() 112 | self.config.read(conf_file) 113 | self.access_token = self.config['oanda']['access_token'] 114 | self.account_id = self.config['oanda']['account_id'] 115 | self.account_type = self.config['oanda']['account_type'] 116 | 117 | if self.account_type == 'live': 118 | self.hostname = 'api-fxtrade.oanda.com' 119 | self.stream_hostname = 'stream-fxtrade.oanda.com' 120 | else: 121 | self.hostname = 'api-fxpractice.oanda.com' 122 | self.stream_hostname = 'stream-fxpractice.oanda.com' 123 | 124 | self.ctx = v20.Context( 125 | hostname=self.hostname, 126 | port=443, 127 | token=self.access_token, 128 | poll_timeout=10 129 | ) 130 | self.ctx_stream = v20.Context( 131 | hostname=self.stream_hostname, 132 | port=443, 133 | token=self.access_token, 134 | ) 135 | 136 | # suffix for nanosecond precision, retained for backward compatibility 137 | self.suffix = '.000000000Z' 138 | self.stop_stream = False 139 | 140 | def get_instruments(self): 141 | ''' Retrieves and returns all instruments for the given account. ''' 142 | resp = self.ctx.account.instruments(self.account_id) 143 | instruments = resp.get('instruments') 144 | instruments = [ins.dict() for ins in instruments] 145 | instruments = [(ins['displayName'], ins['name']) 146 | for ins in instruments] 147 | return sorted(instruments) 148 | 149 | def get_prices(self, instrument): 150 | ''' Returns the current BID/ASK prices for instrument. ''' 151 | r = self.ctx.pricing.get(self.account_id, instruments=instrument) 152 | r = json.loads(r.raw_body) 153 | bid = float(r['prices'][0]['closeoutBid']) 154 | ask = float(r['prices'][0]['closeoutAsk']) 155 | return r['time'], bid, ask 156 | 157 | def transform_datetime(self, dati): 158 | """Transforms datetime or string to an RFC3339 UTC timestamp.""" 159 | # Ensure pandas Timestamp for date, datetime or string 160 | dati = pd.Timestamp(dati) 161 | # Convert timezone-aware timestamps to UTC and make naive 162 | if dati.tzinfo is not None: 163 | dati = dati.tz_convert('UTC').tz_localize(None) 164 | # Format as 'YYYY-MM-DDTHH:MM:SSZ' 165 | return dati.strftime('%Y-%m-%dT%H:%M:%SZ') 166 | 167 | def retrieve_data(self, instrument, start, end, granularity, price): 168 | raw = self.ctx.instrument.candles( 169 | instrument=instrument, 170 | fromTime=start, toTime=end, 171 | granularity=granularity, price=price) 172 | raw = raw.get('candles') 173 | raw = [cs.dict() for cs in raw] 174 | if price == 'A': 175 | for cs in raw: 176 | cs.update(cs['ask']) 177 | del cs['ask'] 178 | elif price == 'B': 179 | for cs in raw: 180 | cs.update(cs['bid']) 181 | del cs['bid'] 182 | elif price == 'M': 183 | for cs in raw: 184 | cs.update(cs['mid']) 185 | del cs['mid'] 186 | else: 187 | raise ValueError("Price must be either 'B', 'A' or 'M'.") 188 | if len(raw) == 0: 189 | return pd.DataFrame() # return empty DataFrame if no data 190 | data = pd.DataFrame(raw) 191 | data['time'] = pd.to_datetime(data['time']) 192 | data = data.set_index('time') 193 | data.index = pd.DatetimeIndex(data.index) 194 | for col in list('ohlc'): 195 | data[col] = data[col].astype(float) 196 | return data 197 | 198 | def get_history(self, instrument, start, end, 199 | granularity, price, localize=True): 200 | ''' Retrieves historical data for instrument. 201 | 202 | Parameters 203 | ========== 204 | instrument: string 205 | valid instrument name 206 | start, end: datetime, str 207 | Python datetime or string objects for start and end 208 | granularity: string 209 | a string like 'S5', 'M1' or 'D' 210 | price: string 211 | one of 'A' (ask), 'B' (bid) or 'M' (middle) 212 | 213 | Returns 214 | ======= 215 | data: pd.DataFrame 216 | pandas DataFrame object with data 217 | ''' 218 | if granularity.startswith('S') or granularity.startswith('M') \ 219 | or granularity.startswith('H'): 220 | multiplier = float("".join(filter(str.isdigit, granularity))) 221 | if granularity.startswith('S'): 222 | # freq = '1h' 223 | freq = f"{int(MAX_REQUEST_COUNT * multiplier / float(3600))}H" 224 | else: 225 | # freq = 'D' 226 | freq = f"{int(MAX_REQUEST_COUNT * multiplier / float(1440))}D" 227 | data = pd.DataFrame() 228 | dr = pd.date_range(start, end, freq=freq) 229 | 230 | for t in range(len(dr)): 231 | batch_start = self.transform_datetime(dr[t]) 232 | if t != len(dr) - 1: 233 | batch_end = self.transform_datetime(dr[t + 1]) 234 | else: 235 | batch_end = self.transform_datetime(end) 236 | 237 | batch = self.retrieve_data(instrument, batch_start, batch_end, 238 | granularity, price) 239 | data = pd.concat([data, batch]) 240 | else: 241 | start = self.transform_datetime(start) 242 | end = self.transform_datetime(end) 243 | data = self.retrieve_data(instrument, start, end, 244 | granularity, price) 245 | if localize: 246 | data.index = data.index.tz_localize(None) 247 | 248 | return data[['o', 'h', 'l', 'c', 'volume', 'complete']] 249 | 250 | def create_order(self, instrument, units, price=None, sl_distance=None, 251 | tsl_distance=None, tp_price=None, comment=None, 252 | touch=False, suppress=False, ret=False): 253 | ''' Places order with Oanda. 254 | 255 | Parameters 256 | ========== 257 | instrument: string 258 | valid instrument name 259 | units: int 260 | number of units of instrument to be bought 261 | (positive int, eg 'units=50') 262 | or to be sold (negative int, eg 'units=-100') 263 | price: float 264 | limit order price, touch order price 265 | sl_distance: float 266 | stop loss distance price, mandatory eg in Germany 267 | tsl_distance: float 268 | trailing stop loss distance 269 | tp_price: float 270 | take profit price to be used for the trade 271 | comment: str 272 | string 273 | touch: boolean 274 | market_if_touched order (requires price to be set) 275 | suppress: boolean 276 | whether to suppress print out 277 | ret: boolean 278 | whether to return the order object 279 | ''' 280 | client_ext = ClientExtensions( 281 | comment=comment) if comment is not None else None 282 | sl_details = (StopLossDetails(distance=sl_distance, 283 | clientExtensions=client_ext) 284 | if sl_distance is not None else None) 285 | tsl_details = (TrailingStopLossDetails(distance=tsl_distance, 286 | clientExtensions=client_ext) 287 | if tsl_distance is not None else None) 288 | tp_details = (TakeProfitDetails( 289 | price=tp_price, clientExtensions=client_ext) 290 | if tp_price is not None else None) 291 | if price is None: 292 | request = self.ctx.order.market( 293 | self.account_id, 294 | instrument=instrument, 295 | units=units, 296 | stopLossOnFill=sl_details, 297 | trailingStopLossOnFill=tsl_details, 298 | takeProfitOnFill=tp_details, 299 | ) 300 | elif touch: 301 | request = self.ctx.order.market_if_touched( 302 | self.account_id, 303 | instrument=instrument, 304 | price=price, 305 | units=units, 306 | stopLossOnFill=sl_details, 307 | trailingStopLossOnFill=tsl_details, 308 | takeProfitOnFill=tp_details 309 | ) 310 | else: 311 | request = self.ctx.order.limit( 312 | self.account_id, 313 | instrument=instrument, 314 | price=price, 315 | units=units, 316 | stopLossOnFill=sl_details, 317 | trailingStopLossOnFill=tsl_details, 318 | takeProfitOnFill=tp_details 319 | ) 320 | 321 | # First checking if the order is rejected 322 | if 'orderRejectTransaction' in request.body: 323 | order = request.get('orderRejectTransaction') 324 | elif 'orderFillTransaction' in request.body: 325 | order = request.get('orderFillTransaction') 326 | elif 'orderCreateTransaction' in request.body: 327 | order = request.get('orderCreateTransaction') 328 | else: 329 | # This case does not happen. But keeping this for completeness. 330 | order = None 331 | 332 | if not suppress and order is not None: 333 | print('\n\n', order.dict(), '\n') 334 | if ret is True: 335 | return order.dict() if order is not None else None 336 | 337 | def stream_data(self, instrument, stop=None, ret=False, callback=None): 338 | ''' Starts a real-time data stream. 339 | 340 | Parameters 341 | ========== 342 | instrument: string 343 | valid instrument name 344 | ''' 345 | self.stream_instrument = instrument 346 | self.ticks = 0 347 | response = self.ctx_stream.pricing.stream( 348 | self.account_id, snapshot=True, 349 | instruments=instrument) 350 | msgs = [] 351 | for msg_type, msg in response.parts(): 352 | msgs.append(msg) 353 | # print(msg_type, msg) 354 | if msg_type == 'pricing.ClientPrice': 355 | self.ticks += 1 356 | self.time = msg.time 357 | if callback is not None: 358 | callback(msg.instrument, msg.time, 359 | float(msg.bids[0].dict()['price']), 360 | float(msg.asks[0].dict()['price'])) 361 | else: 362 | self.on_success(msg.time, 363 | float(msg.bids[0].dict()['price']), 364 | float(msg.asks[0].dict()['price'])) 365 | if stop is not None: 366 | if self.ticks >= stop: 367 | if ret: 368 | return msgs 369 | break 370 | if self.stop_stream: 371 | if ret: 372 | return msgs 373 | break 374 | 375 | def _stream_data_failsafe_thread(self, args): 376 | try: 377 | print("Starting price streaming") 378 | self.stream_data(args[0], callback=args[1]) 379 | except Exception as e: 380 | import sys 381 | import traceback 382 | print(traceback.format_exc()) 383 | sleep(3) 384 | return 385 | 386 | def stream_data_failsafe(self, instrument, callback=None): 387 | signal.signal(signal.SIGTERM, service_shutdown) 388 | signal.signal(signal.SIGINT, service_shutdown) 389 | signal.signal(signal.SIGSEGV, service_shutdown) 390 | try: 391 | price_stream_thread = Job(self._stream_data_failsafe_thread, 392 | [instrument, callback]) 393 | price_stream_thread.start() 394 | return price_stream_thread 395 | except ServiceExit as e: 396 | print('Handling exception') 397 | import sys 398 | import traceback 399 | print(traceback) 400 | price_stream_thread.shutdown_flag.set() 401 | price_stream_thread.join() 402 | 403 | def on_success(self, time, bid, ask): 404 | ''' Method called when new data is retrieved. ''' 405 | print(time, bid, ask) 406 | 407 | def get_account_summary(self, detailed=False): 408 | ''' Returns summary data for Oanda account.''' 409 | if detailed is True: 410 | response = self.ctx.account.get(self.account_id) 411 | else: 412 | response = self.ctx.account.summary(self.account_id) 413 | raw = response.get('account') 414 | return raw.dict() 415 | 416 | def get_transaction(self, tid=0): 417 | ''' Retrieves and returns transaction data. ''' 418 | response = self.ctx.transaction.get(self.account_id, tid) 419 | transaction = response.get('transaction') 420 | return transaction.dict() 421 | 422 | def get_transactions(self, tid=0): 423 | ''' Retrieves and returns transactions data. ''' 424 | response = self.ctx.transaction.since(self.account_id, id=tid) 425 | transactions = response.get('transactions') 426 | transactions = [t.dict() for t in transactions] 427 | return transactions 428 | 429 | def print_transactions(self, tid=0): 430 | ''' Prints basic transactions data. ''' 431 | transactions = self.get_transactions(tid) 432 | for trans in transactions: 433 | try: 434 | templ = '%4s | %s | %7s | %8s | %8s' 435 | print(templ % (trans['id'], 436 | trans['time'][:-8], 437 | trans['instrument'], 438 | trans['units'], 439 | trans['pl'])) 440 | except Exception: 441 | pass 442 | 443 | def get_positions(self): 444 | ''' Retrieves and returns positions data. ''' 445 | response = self.ctx.position.list_open(self.account_id).body 446 | positions = [p.dict() for p in response.get('positions')] 447 | return positions 448 | 449 | def cancel_order(self, order_id): 450 | ''' Cancels an order (e.g. SL order). 451 | 452 | Parameters 453 | ========== 454 | order_id: int 455 | valid order id 456 | ''' 457 | response = self.ctx.order.cancel(self.account_id, order_id) 458 | return json.loads(response.raw_body) 459 | -------------------------------------------------------------------------------- /03_session/tpqoa.py: -------------------------------------------------------------------------------- 1 | # 2 | # tpqoa is a wrapper class for the 3 | # Oanda v20 API (RESTful & streaming) 4 | # making use of the v20 Python package 5 | # 6 | # (c) Dr. Yves J. Hilpisch 7 | # The Python Quants GmbH 8 | # 9 | # 10 | # Trading forex/CFDs on margin carries a high level of risk and may 11 | # not be suitable for all investors as you could sustain losses 12 | # in excess of deposits. Leverage can work against you. Due to the certain 13 | # restrictions imposed by the local law and regulation, German resident 14 | # retail client(s) could sustain a total loss of deposited funds but are 15 | # not subject to subsequent payment obligations beyond the deposited funds. 16 | # Be aware and fully understand all risks associated with 17 | # the market and trading. Prior to trading any products, 18 | # carefully consider your financial situation and 19 | # experience level. Any opinions, news, research, analyses, prices, 20 | # or other information is provided as general market commentary, and does not 21 | # constitute investment advice. The Python Quants GmbH will not accept 22 | # liability for any loss or damage, including without limitation to, 23 | # any loss of profit, which may arise directly or indirectly from use 24 | # of or reliance on such information. 25 | # 26 | # The tpqoa package is intended as a technological illustration only. 27 | # It comes with no warranties or representations, 28 | # to the extent permitted by applicable law. 29 | # 30 | import _thread 31 | import configparser 32 | import json 33 | import signal 34 | import threading 35 | from time import sleep 36 | 37 | import pandas as pd 38 | import v20 39 | from v20.transaction import StopLossDetails, ClientExtensions 40 | from v20.transaction import TrailingStopLossDetails, TakeProfitDetails 41 | 42 | MAX_REQUEST_COUNT = float(5000) 43 | 44 | 45 | class Job(threading.Thread): 46 | def __init__(self, job_callable, args=None): 47 | threading.Thread.__init__(self) 48 | self.callable = job_callable 49 | self.args = args 50 | 51 | # The shutdown_flag is a threading.Event object that 52 | # indicates whether the thread should be terminated. 53 | self.shutdown_flag = threading.Event() 54 | self.job = None 55 | self.exception = None 56 | 57 | def run(self): 58 | print('Thread #%s started' % self.ident) 59 | try: 60 | self.job = self.callable 61 | while not self.shutdown_flag.is_set(): 62 | print("Starting job loop...") 63 | if self.args is None: 64 | self.job() 65 | else: 66 | self.job(self.args) 67 | except Exception as e: 68 | import sys 69 | import traceback 70 | print(traceback.format_exc()) 71 | self.exception = e 72 | _thread.interrupt_main() 73 | 74 | 75 | class ServiceExit(Exception): 76 | """ 77 | Custom exception which is used to trigger the clean exit 78 | of all running threads and the main program. 79 | """ 80 | 81 | def __init__(self, message=None): 82 | self.message = message 83 | 84 | def __repr__(self): 85 | return repr(self.message) 86 | 87 | 88 | def service_shutdown(signum, frame): 89 | print('exiting ...') 90 | raise ServiceExit 91 | 92 | 93 | class tpqoa(object): 94 | ''' tpqoa is a Python wrapper class for the Oanda v20 API. ''' 95 | 96 | def __init__(self, conf_file): 97 | ''' Init function is expecting a configuration file with 98 | the following content: 99 | 100 | [oanda] 101 | account_id = XYZ-ABC-... 102 | access_token = ZYXCAB... 103 | account_type = practice (default) or live 104 | 105 | Parameters 106 | ========== 107 | conf_file: string 108 | path to and filename of the configuration file, 109 | e.g. '/home/me/oanda.cfg' 110 | ''' 111 | self.config = configparser.ConfigParser() 112 | self.config.read(conf_file) 113 | self.access_token = self.config['oanda']['access_token'] 114 | self.account_id = self.config['oanda']['account_id'] 115 | self.account_type = self.config['oanda']['account_type'] 116 | 117 | if self.account_type == 'live': 118 | self.hostname = 'api-fxtrade.oanda.com' 119 | self.stream_hostname = 'stream-fxtrade.oanda.com' 120 | else: 121 | self.hostname = 'api-fxpractice.oanda.com' 122 | self.stream_hostname = 'stream-fxpractice.oanda.com' 123 | 124 | self.ctx = v20.Context( 125 | hostname=self.hostname, 126 | port=443, 127 | token=self.access_token, 128 | poll_timeout=10 129 | ) 130 | self.ctx_stream = v20.Context( 131 | hostname=self.stream_hostname, 132 | port=443, 133 | token=self.access_token, 134 | ) 135 | 136 | # suffix for nanosecond precision, retained for backward compatibility 137 | self.suffix = '.000000000Z' 138 | self.stop_stream = False 139 | 140 | def get_instruments(self): 141 | ''' Retrieves and returns all instruments for the given account. ''' 142 | resp = self.ctx.account.instruments(self.account_id) 143 | instruments = resp.get('instruments') 144 | instruments = [ins.dict() for ins in instruments] 145 | instruments = [(ins['displayName'], ins['name']) 146 | for ins in instruments] 147 | return sorted(instruments) 148 | 149 | def get_prices(self, instrument): 150 | ''' Returns the current BID/ASK prices for instrument. ''' 151 | r = self.ctx.pricing.get(self.account_id, instruments=instrument) 152 | r = json.loads(r.raw_body) 153 | bid = float(r['prices'][0]['closeoutBid']) 154 | ask = float(r['prices'][0]['closeoutAsk']) 155 | return r['time'], bid, ask 156 | 157 | def transform_datetime(self, dati): 158 | """Transforms datetime or string to an RFC3339 UTC timestamp.""" 159 | # Ensure pandas Timestamp for date, datetime or string 160 | dati = pd.Timestamp(dati) 161 | # Convert timezone-aware timestamps to UTC and make naive 162 | if dati.tzinfo is not None: 163 | dati = dati.tz_convert('UTC').tz_localize(None) 164 | # Format as 'YYYY-MM-DDTHH:MM:SSZ' 165 | return dati.strftime('%Y-%m-%dT%H:%M:%SZ') 166 | 167 | def retrieve_data(self, instrument, start, end, granularity, price): 168 | raw = self.ctx.instrument.candles( 169 | instrument=instrument, 170 | fromTime=start, toTime=end, 171 | granularity=granularity, price=price) 172 | raw = raw.get('candles') 173 | raw = [cs.dict() for cs in raw] 174 | if price == 'A': 175 | for cs in raw: 176 | cs.update(cs['ask']) 177 | del cs['ask'] 178 | elif price == 'B': 179 | for cs in raw: 180 | cs.update(cs['bid']) 181 | del cs['bid'] 182 | elif price == 'M': 183 | for cs in raw: 184 | cs.update(cs['mid']) 185 | del cs['mid'] 186 | else: 187 | raise ValueError("Price must be either 'B', 'A' or 'M'.") 188 | if len(raw) == 0: 189 | return pd.DataFrame() # return empty DataFrame if no data 190 | data = pd.DataFrame(raw) 191 | data['time'] = pd.to_datetime(data['time']) 192 | data = data.set_index('time') 193 | data.index = pd.DatetimeIndex(data.index) 194 | for col in list('ohlc'): 195 | data[col] = data[col].astype(float) 196 | return data 197 | 198 | def get_history(self, instrument, start, end, 199 | granularity, price, localize=True): 200 | ''' Retrieves historical data for instrument. 201 | 202 | Parameters 203 | ========== 204 | instrument: string 205 | valid instrument name 206 | start, end: datetime, str 207 | Python datetime or string objects for start and end 208 | granularity: string 209 | a string like 'S5', 'M1' or 'D' 210 | price: string 211 | one of 'A' (ask), 'B' (bid) or 'M' (middle) 212 | 213 | Returns 214 | ======= 215 | data: pd.DataFrame 216 | pandas DataFrame object with data 217 | ''' 218 | if granularity.startswith('S') or granularity.startswith('M') \ 219 | or granularity.startswith('H'): 220 | multiplier = float("".join(filter(str.isdigit, granularity))) 221 | if granularity.startswith('S'): 222 | # freq = '1h' 223 | freq = f"{int(MAX_REQUEST_COUNT * multiplier / float(3600))}H" 224 | else: 225 | # freq = 'D' 226 | freq = f"{int(MAX_REQUEST_COUNT * multiplier / float(1440))}D" 227 | data = pd.DataFrame() 228 | dr = pd.date_range(start, end, freq=freq) 229 | 230 | for t in range(len(dr)): 231 | batch_start = self.transform_datetime(dr[t]) 232 | if t != len(dr) - 1: 233 | batch_end = self.transform_datetime(dr[t + 1]) 234 | else: 235 | batch_end = self.transform_datetime(end) 236 | 237 | batch = self.retrieve_data(instrument, batch_start, batch_end, 238 | granularity, price) 239 | data = pd.concat([data, batch]) 240 | else: 241 | start = self.transform_datetime(start) 242 | end = self.transform_datetime(end) 243 | data = self.retrieve_data(instrument, start, end, 244 | granularity, price) 245 | if localize: 246 | data.index = data.index.tz_localize(None) 247 | 248 | return data[['o', 'h', 'l', 'c', 'volume', 'complete']] 249 | 250 | def create_order(self, instrument, units, price=None, sl_distance=None, 251 | tsl_distance=None, tp_price=None, comment=None, 252 | touch=False, suppress=False, ret=False): 253 | ''' Places order with Oanda. 254 | 255 | Parameters 256 | ========== 257 | instrument: string 258 | valid instrument name 259 | units: int 260 | number of units of instrument to be bought 261 | (positive int, eg 'units=50') 262 | or to be sold (negative int, eg 'units=-100') 263 | price: float 264 | limit order price, touch order price 265 | sl_distance: float 266 | stop loss distance price, mandatory eg in Germany 267 | tsl_distance: float 268 | trailing stop loss distance 269 | tp_price: float 270 | take profit price to be used for the trade 271 | comment: str 272 | string 273 | touch: boolean 274 | market_if_touched order (requires price to be set) 275 | suppress: boolean 276 | whether to suppress print out 277 | ret: boolean 278 | whether to return the order object 279 | ''' 280 | client_ext = ClientExtensions( 281 | comment=comment) if comment is not None else None 282 | sl_details = (StopLossDetails(distance=sl_distance, 283 | clientExtensions=client_ext) 284 | if sl_distance is not None else None) 285 | tsl_details = (TrailingStopLossDetails(distance=tsl_distance, 286 | clientExtensions=client_ext) 287 | if tsl_distance is not None else None) 288 | tp_details = (TakeProfitDetails( 289 | price=tp_price, clientExtensions=client_ext) 290 | if tp_price is not None else None) 291 | if price is None: 292 | request = self.ctx.order.market( 293 | self.account_id, 294 | instrument=instrument, 295 | units=units, 296 | stopLossOnFill=sl_details, 297 | trailingStopLossOnFill=tsl_details, 298 | takeProfitOnFill=tp_details, 299 | ) 300 | elif touch: 301 | request = self.ctx.order.market_if_touched( 302 | self.account_id, 303 | instrument=instrument, 304 | price=price, 305 | units=units, 306 | stopLossOnFill=sl_details, 307 | trailingStopLossOnFill=tsl_details, 308 | takeProfitOnFill=tp_details 309 | ) 310 | else: 311 | request = self.ctx.order.limit( 312 | self.account_id, 313 | instrument=instrument, 314 | price=price, 315 | units=units, 316 | stopLossOnFill=sl_details, 317 | trailingStopLossOnFill=tsl_details, 318 | takeProfitOnFill=tp_details 319 | ) 320 | 321 | # First checking if the order is rejected 322 | if 'orderRejectTransaction' in request.body: 323 | order = request.get('orderRejectTransaction') 324 | elif 'orderFillTransaction' in request.body: 325 | order = request.get('orderFillTransaction') 326 | elif 'orderCreateTransaction' in request.body: 327 | order = request.get('orderCreateTransaction') 328 | else: 329 | # This case does not happen. But keeping this for completeness. 330 | order = None 331 | 332 | if not suppress and order is not None: 333 | print('\n\n', order.dict(), '\n') 334 | if ret is True: 335 | return order.dict() if order is not None else None 336 | 337 | def stream_data(self, instrument, stop=None, ret=False, callback=None): 338 | ''' Starts a real-time data stream. 339 | 340 | Parameters 341 | ========== 342 | instrument: string 343 | valid instrument name 344 | ''' 345 | self.stream_instrument = instrument 346 | self.ticks = 0 347 | response = self.ctx_stream.pricing.stream( 348 | self.account_id, snapshot=True, 349 | instruments=instrument) 350 | msgs = [] 351 | for msg_type, msg in response.parts(): 352 | msgs.append(msg) 353 | # print(msg_type, msg) 354 | if msg_type == 'pricing.ClientPrice': 355 | self.ticks += 1 356 | self.time = msg.time 357 | if callback is not None: 358 | callback(msg.instrument, msg.time, 359 | float(msg.bids[0].dict()['price']), 360 | float(msg.asks[0].dict()['price'])) 361 | else: 362 | self.on_success(msg.time, 363 | float(msg.bids[0].dict()['price']), 364 | float(msg.asks[0].dict()['price'])) 365 | if stop is not None: 366 | if self.ticks >= stop: 367 | if ret: 368 | return msgs 369 | break 370 | if self.stop_stream: 371 | if ret: 372 | return msgs 373 | break 374 | 375 | def _stream_data_failsafe_thread(self, args): 376 | try: 377 | print("Starting price streaming") 378 | self.stream_data(args[0], callback=args[1]) 379 | except Exception as e: 380 | import sys 381 | import traceback 382 | print(traceback.format_exc()) 383 | sleep(3) 384 | return 385 | 386 | def stream_data_failsafe(self, instrument, callback=None): 387 | signal.signal(signal.SIGTERM, service_shutdown) 388 | signal.signal(signal.SIGINT, service_shutdown) 389 | signal.signal(signal.SIGSEGV, service_shutdown) 390 | try: 391 | price_stream_thread = Job(self._stream_data_failsafe_thread, 392 | [instrument, callback]) 393 | price_stream_thread.start() 394 | return price_stream_thread 395 | except ServiceExit as e: 396 | print('Handling exception') 397 | import sys 398 | import traceback 399 | print(traceback) 400 | price_stream_thread.shutdown_flag.set() 401 | price_stream_thread.join() 402 | 403 | def on_success(self, time, bid, ask): 404 | ''' Method called when new data is retrieved. ''' 405 | print(time, bid, ask) 406 | 407 | def get_account_summary(self, detailed=False): 408 | ''' Returns summary data for Oanda account.''' 409 | if detailed is True: 410 | response = self.ctx.account.get(self.account_id) 411 | else: 412 | response = self.ctx.account.summary(self.account_id) 413 | raw = response.get('account') 414 | return raw.dict() 415 | 416 | def get_transaction(self, tid=0): 417 | ''' Retrieves and returns transaction data. ''' 418 | response = self.ctx.transaction.get(self.account_id, tid) 419 | transaction = response.get('transaction') 420 | return transaction.dict() 421 | 422 | def get_transactions(self, tid=0): 423 | ''' Retrieves and returns transactions data. ''' 424 | response = self.ctx.transaction.since(self.account_id, id=tid) 425 | transactions = response.get('transactions') 426 | transactions = [t.dict() for t in transactions] 427 | return transactions 428 | 429 | def print_transactions(self, tid=0): 430 | ''' Prints basic transactions data. ''' 431 | transactions = self.get_transactions(tid) 432 | for trans in transactions: 433 | try: 434 | templ = '%4s | %s | %7s | %8s | %8s' 435 | print(templ % (trans['id'], 436 | trans['time'][:-8], 437 | trans['instrument'], 438 | trans['units'], 439 | trans['pl'])) 440 | except Exception: 441 | pass 442 | 443 | def get_positions(self): 444 | ''' Retrieves and returns positions data. ''' 445 | response = self.ctx.position.list_open(self.account_id).body 446 | positions = [p.dict() for p in response.get('positions')] 447 | return positions 448 | 449 | def cancel_order(self, order_id): 450 | ''' Cancels an order (e.g. SL order). 451 | 452 | Parameters 453 | ========== 454 | order_id: int 455 | valid order id 456 | ''' 457 | response = self.ctx.order.cancel(self.account_id, order_id) 458 | return json.loads(response.raw_body) 459 | -------------------------------------------------------------------------------- /03_session/backtest_mr_para.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Backtest intraday mean-reversion strategy on DE30_EUR using tpqoa. 4 | Refactored as a class; uses timezone-aware datetime.now(timezone.utc). 5 | """ 6 | 7 | import sys 8 | from datetime import datetime, timedelta, timezone, time 9 | 10 | import numpy as np 11 | import pandas as pd 12 | from dateutil.relativedelta import relativedelta 13 | 14 | from tpqoa import tpqoa 15 | from dateutil.parser import parse 16 | import argparse 17 | import os 18 | import json 19 | from dateutil.parser import parse 20 | import argparse 21 | 22 | 23 | class MeanRevBacktester: 24 | """Intraday mean-reversion backtester for DE30_EUR.""" 25 | 26 | def __init__(self, 27 | config='oanda.cfg', 28 | instrument='DE30_EUR', 29 | granularity='M10', 30 | price='M', 31 | equity=100000.0, 32 | risk_pct=0.01, 33 | leverage=10.0, 34 | start=None, 35 | end=None, 36 | bb_window=20, 37 | bb_std_dev=2, 38 | rsi_period=14, 39 | ema_period=20, 40 | atr_period=14, 41 | avg_range_window=20, 42 | rsi_lower=30, 43 | rsi_upper=70, 44 | entry_atr_multiplier=2, 45 | stop_atr_multiplier=1.5, 46 | momentum_window=3, 47 | momentum_multiplier=2, 48 | cutoff_hours=1, 49 | max_concurrent_trades=3, 50 | use_spreads=True): 51 | self.oanda = tpqoa(config) 52 | self.instrument = instrument 53 | self.granularity = granularity 54 | self.price = price 55 | self.initial_equity = equity 56 | self.risk_pct = risk_pct 57 | self.leverage = leverage 58 | # strategy parameters 59 | self.bb_window = bb_window 60 | self.bb_std_dev = bb_std_dev 61 | self.rsi_period = rsi_period 62 | self.ema_period = ema_period 63 | self.atr_period = atr_period 64 | self.avg_range_window = avg_range_window 65 | self.rsi_lower = rsi_lower 66 | self.rsi_upper = rsi_upper 67 | self.entry_atr_multiplier = entry_atr_multiplier 68 | self.stop_atr_multiplier = stop_atr_multiplier 69 | self.momentum_window = momentum_window 70 | self.momentum_multiplier = momentum_multiplier 71 | self.cutoff_hours = cutoff_hours 72 | self.max_concurrent_trades = max_concurrent_trades 73 | self.plot = False 74 | self.use_spreads = use_spreads 75 | # load instrument->category map for spread lookup 76 | inst_file = os.path.join(os.path.dirname(__file__), 'oanda_instruments.json') 77 | try: 78 | with open(inst_file) as f: 79 | inst_data = json.load(f) 80 | except Exception: 81 | inst_data = {} 82 | self._instr_category = { 83 | inst['instrument']: cat 84 | for cat, lst in inst_data.items() for inst in lst 85 | } 86 | # simplified typical spreads (%) per category (midpoint of range) 87 | spread_pct = { 88 | 'FX': (0.007 + 0.02) / 2, 89 | 'Indices': (0.00001 + 0.00003) / 2, 90 | 'Commodities': (0.00001 + 0.0001) / 2, 91 | } 92 | # convert to fraction of price 93 | self._spread_frac = {cat: pct / 100 for cat, pct in spread_pct.items()} 94 | # parse or default end/start (default: end=now UTC, start=3mo back) 95 | if end is None: 96 | # default end at UTC midnight of today's date 97 | today = datetime.now(timezone.utc).date() 98 | self.end = datetime.combine(today, time(0, 0), tzinfo=timezone.utc) 99 | else: 100 | dt = parse(end) 101 | self.end = dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) 102 | if start is None: 103 | # default start three months before end date at UTC midnight 104 | start_date = self.end.date() - relativedelta(months=3) 105 | self.start = datetime.combine(start_date, time(0, 0), tzinfo=timezone.utc) 106 | else: 107 | dt = parse(start) 108 | self.start = dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) 109 | 110 | def fetch_data(self): 111 | df = self.oanda.get_history( 112 | self.instrument, self.start, self.end, 113 | self.granularity, self.price 114 | ) 115 | if df.empty: 116 | print('No data retrieved.') 117 | sys.exit(1) 118 | return df 119 | 120 | def compute_indicators(self, df): 121 | # Bollinger Bands 122 | df['mb'] = df['c'].rolling(self.bb_window).mean() 123 | df['std'] = df['c'].rolling(self.bb_window).std() 124 | df['ub'] = df['mb'] + self.bb_std_dev * df['std'] 125 | df['lb'] = df['mb'] - self.bb_std_dev * df['std'] 126 | # RSI 127 | delta = df['c'].diff() 128 | up = delta.clip(lower=0) 129 | down = -delta.clip(upper=0) 130 | roll_up = up.ewm(com=self.rsi_period - 1, adjust=False).mean() 131 | roll_down = down.ewm(com=self.rsi_period - 1, adjust=False).mean() 132 | rs = roll_up / roll_down 133 | df['rsi'] = 100 - 100 / (1 + rs) 134 | # EMA 135 | df['ema'] = df['c'].ewm(span=self.ema_period, adjust=False).mean() 136 | # ATR 137 | tr1 = df['h'] - df['l'] 138 | tr2 = (df['h'] - df['c'].shift()).abs() 139 | tr3 = (df['l'] - df['c'].shift()).abs() 140 | df['tr'] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) 141 | df['atr'] = df['tr'].rolling(self.atr_period).mean() 142 | # Range & average range 143 | df['range'] = df['h'] - df['l'] 144 | df['avg_range'] = df['range'].rolling(self.avg_range_window).mean() 145 | return df.dropna() 146 | 147 | def run_backtest(self, df): 148 | equity = self.initial_equity 149 | trades = [] 150 | i, n = 0, len(df) 151 | open_trades = 0 152 | 153 | while i < n: 154 | row = df.iloc[i] 155 | time = df.index[i] 156 | # momentum filter: skip if any of last bars range > momentum_multiplier * avg_range 157 | start = max(i - self.momentum_window, 0) 158 | recent = df['range'].iloc[start:i] 159 | avg_rng = df['avg_range'].iloc[start:i] 160 | if recent.gt(self.momentum_multiplier * avg_rng).any(): 161 | i += 1 162 | continue 163 | # entry conditions 164 | long_entry = ( 165 | row['c'] < row['lb'] and row['rsi'] < self.rsi_lower 166 | and row['c'] >= row['ema'] - self.entry_atr_multiplier * row['atr'] 167 | ) 168 | short_entry = ( 169 | row['c'] > row['ub'] and row['rsi'] > self.rsi_upper 170 | and row['c'] <= row['ema'] + self.entry_atr_multiplier * row['atr'] 171 | ) 172 | # Long trade 173 | if long_entry and open_trades < self.max_concurrent_trades: 174 | entry = row['c'] 175 | stop = entry - self.stop_atr_multiplier * row['atr'] 176 | target = row['mb'] 177 | risk = equity * self.risk_pct 178 | units = int((risk * self.leverage) / (entry - stop)) if entry > stop else 0 179 | cutoff = time + timedelta(hours=self.cutoff_hours) 180 | open_trades += 1 181 | for j in range(i + 1, n): 182 | r = df.iloc[j]; t = df.index[j] 183 | if (r['c'] <= stop or r['c'] >= target 184 | or 45 <= r['rsi'] <= 55 or t >= cutoff): 185 | pnl = (r['c'] - entry) * units 186 | if self.use_spreads: 187 | cat = self._instr_category.get(self.instrument) 188 | spread = self._spread_frac.get(cat, 0.0) 189 | cost = spread * (entry + r['c']) * units 190 | pnl -= cost 191 | equity += pnl 192 | trades.append({ 193 | 'entry_time': time, 'exit_time': t, 194 | 'direction': 'long', 'entry': entry, 195 | 'exit': r['c'], 'stop': stop, 'target': target, 196 | 'units': units, 'pnl': pnl, 'equity': equity 197 | }) 198 | i = j 199 | open_trades -= 1 200 | break 201 | # Short trade 202 | elif short_entry and open_trades < self.max_concurrent_trades: 203 | entry = row['c'] 204 | stop = entry + self.stop_atr_multiplier * row['atr'] 205 | target = row['mb'] 206 | risk = equity * self.risk_pct 207 | units = int((risk * self.leverage) / (stop - entry)) if stop > entry else 0 208 | cutoff = time + timedelta(hours=self.cutoff_hours) 209 | open_trades += 1 210 | for j in range(i + 1, n): 211 | r = df.iloc[j]; t = df.index[j] 212 | if (r['c'] >= stop or r['c'] <= target 213 | or 45 <= r['rsi'] <= 55 or t >= cutoff): 214 | pnl = (entry - r['c']) * units 215 | if self.use_spreads: 216 | cat = self._instr_category.get(self.instrument) 217 | spread = self._spread_frac.get(cat, 0.0) 218 | cost = spread * (entry + r['c']) * units 219 | pnl -= cost 220 | equity += pnl 221 | trades.append({ 222 | 'entry_time': time, 'exit_time': t, 223 | 'direction': 'short', 'entry': entry, 224 | 'exit': r['c'], 'stop': stop, 'target': target, 225 | 'units': units, 'pnl': pnl, 'equity': equity 226 | }) 227 | i = j 228 | open_trades -= 1 229 | break 230 | i += 1 231 | return trades 232 | 233 | def summarize(self, df, trades): 234 | tr_df = pd.DataFrame(trades) 235 | wins = tr_df[tr_df['pnl'] > 0] 236 | losses = tr_df[tr_df['pnl'] <= 0] 237 | net = tr_df['pnl'].sum() 238 | total = len(tr_df) 239 | stats = { 240 | 'Total Trades': total, 241 | 'Win Rate (%)': len(wins) / total * 100 if total else 0, 242 | 'Avg Win': wins['pnl'].mean() if not wins.empty else 0, 243 | 'Avg Loss': losses['pnl'].mean() if not losses.empty else 0, 244 | 'Profit Factor': wins['pnl'].sum() / abs(losses['pnl'].sum()) if losses['pnl'].sum() else np.nan, 245 | 'Sharpe Ratio': (tr_df['pnl'] / self.initial_equity).mean() / (tr_df['pnl'] / self.initial_equity).std() * np.sqrt(len(tr_df)) if len(tr_df) > 1 else np.nan, 246 | 'Max Drawdown (%)': (tr_df['equity'] - tr_df['equity'].cummax()).min() / tr_df['equity'].cummax().max() * 100, 247 | 'Net PnL': net 248 | } 249 | out = pd.DataFrame([stats]) 250 | print('\nBacktest Results:') 251 | print(out.to_string(index=False, float_format='%.2f')) 252 | # plot if requested 253 | if self.plot: 254 | self.plot_trades(df, trades) 255 | 256 | def plot_trades(self, df, trades): 257 | """Plot price series with trade markers, stops, and targets.""" 258 | import plotly.graph_objects as go 259 | 260 | from plotly.subplots import make_subplots 261 | 262 | # create two-row subplot: price (with candlesticks) and equity curve 263 | fig = make_subplots(rows=2, cols=1, 264 | shared_xaxes=True, 265 | vertical_spacing=0.02, 266 | row_heights=[0.7, 0.3]) 267 | # plot OHLC candlesticks in top row 268 | fig.add_trace(go.Candlestick( 269 | x=df.index, 270 | open=df['o'], high=df['h'], low=df['l'], close=df['c'], 271 | name='OHLC' 272 | ), row=1, col=1) 273 | shown = { 274 | 'entry_long': False, 'entry_short': False, 275 | 'exit': False, 'stop': False, 'target': False 276 | } 277 | for tr in trades: 278 | et, xt = tr['entry_time'], tr['exit_time'] 279 | eprice, xprice = tr['entry'], tr['exit'] 280 | stop, target = tr['stop'], tr['target'] 281 | direction = tr['direction'] 282 | # entry marker 283 | entry_key = 'entry_long' if direction == 'long' else 'entry_short' 284 | entry_name = 'Entry (Long)' if direction == 'long' else 'Entry (Short)' 285 | fig.add_trace(go.Scatter( 286 | x=[et], y=[eprice], mode='markers', 287 | marker_symbol='triangle-up' if direction == 'long' else 'triangle-down', 288 | marker_color='green' if direction == 'long' else 'red', 289 | marker_size=12, name=entry_name, 290 | showlegend=not shown[entry_key] 291 | )) 292 | shown[entry_key] = True 293 | fig.add_trace(go.Scatter( 294 | x=[xt], y=[xprice], mode='markers', 295 | marker_symbol='x', marker_color='black', marker_size=12, 296 | name='Exit', showlegend=not shown['exit'] 297 | )) 298 | shown['exit'] = True 299 | fig.add_trace(go.Scatter( 300 | x=[et, xt], y=[stop, stop], mode='lines', 301 | line=dict(color='red', dash='dash'), 302 | name='Stop', showlegend=not shown['stop'] 303 | )) 304 | shown['stop'] = True 305 | fig.add_trace(go.Scatter( 306 | x=[et, xt], y=[target, target], mode='lines', 307 | line=dict(color='green', dash='dash'), 308 | name='Target', showlegend=not shown['target'] 309 | )) 310 | shown['target'] = True 311 | # add equity curve to bottom row, anchored at start and end 312 | idx0 = df.index[0] 313 | idxN = df.index[-1] 314 | eq_times = [idx0] + [tr['exit_time'] for tr in trades] + [idxN] 315 | last_eq = trades[-1]['equity'] 316 | eq_vals = [self.initial_equity] + [tr['equity'] for tr in trades] + [last_eq] 317 | fig.add_trace(go.Scatter( 318 | x=eq_times, y=eq_vals, mode='lines', 319 | line=dict(color='blue', shape='hv'), name='Equity' 320 | ), row=2, col=1) 321 | # layout updates 322 | fig.update_layout( 323 | title=f'Trades and Equity Curve for {self.instrument}', 324 | xaxis=dict(rangeslider=dict(visible=False)), 325 | hovermode='x unified' 326 | ) 327 | # autorange y-axes on zoom 328 | fig.update_yaxes(autorange=True, row=1, col=1) 329 | fig.update_yaxes(autorange=True, row=2, col=1) 330 | fig.show() 331 | 332 | def run(self): 333 | df = self.fetch_data() 334 | data = self.compute_indicators(df) 335 | trades = self.run_backtest(data) 336 | if not trades: 337 | print('No trades executed.') 338 | sys.exit(0) 339 | self.summarize(data, trades) 340 | if self.plot: 341 | self.plot_trades(data, trades) 342 | 343 | 344 | def parse_args(): 345 | p = argparse.ArgumentParser(description='Mean-reversion backtest') 346 | p.add_argument('--config', default='oanda.cfg') 347 | p.add_argument('--instrument', default='DE30_EUR') 348 | p.add_argument('--granularity', default='M10') 349 | p.add_argument('--price', default='M') 350 | p.add_argument('--equity', default=100000.0, type=float) 351 | p.add_argument('--risk-pct', default=0.01, type=float, 352 | help='fraction of equity to risk per trade') 353 | p.add_argument('--leverage', default=10.0, type=float, 354 | help='leverage multiplier for position sizing') 355 | p.add_argument('--start', default=None, 356 | help='start datetime (ISO8601)') 357 | p.add_argument('--end', default=None, 358 | help='end datetime (ISO8601)') 359 | p.add_argument('--plot', action='store_true', 360 | help='show trade plot') 361 | p.add_argument('--no-spreads', action='store_true', 362 | help='disable transaction-cost spreads') 363 | # strategy parameter arguments 364 | p.add_argument('--bb-window', type=int, default=20, 365 | help='window for Bollinger Bands') 366 | p.add_argument('--bb-std-dev', type=float, default=2, 367 | help='std deviation multiplier for Bollinger Bands') 368 | p.add_argument('--rsi-period', type=int, default=14, 369 | help='period for RSI calculation') 370 | p.add_argument('--ema-period', type=int, default=20, 371 | help='period for EMA calculation') 372 | p.add_argument('--atr-period', type=int, default=14, 373 | help='period for ATR calculation') 374 | p.add_argument('--avg-range-window', type=int, default=20, 375 | help='window for average range calculation') 376 | p.add_argument('--rsi-lower', type=float, default=30, 377 | help='lower threshold for RSI-entry') 378 | p.add_argument('--rsi-upper', type=float, default=70, 379 | help='upper threshold for RSI-entry') 380 | p.add_argument('--entry-atr-multiplier',type=float, default=2, 381 | help='ATR multiplier for entry condition') 382 | p.add_argument('--stop-atr-multiplier', type=float, default=1.5, 383 | help='ATR multiplier for stop distance') 384 | p.add_argument('--momentum-window', type=int, default=3, 385 | help='number of bars for momentum filter') 386 | p.add_argument('--momentum-multiplier', type=float, default=2, 387 | help='multiplier for momentum filter') 388 | p.add_argument('--cutoff-hours', type=float, default=1, 389 | help='hours until cutoff for trade exit') 390 | p.add_argument('--max-concurrent-trades', type=int, default=3, 391 | help='maximum concurrent open trades') 392 | return p.parse_args() 393 | 394 | if __name__ == '__main__': 395 | args = parse_args() 396 | bt = MeanRevBacktester( 397 | config=args.config, 398 | instrument=args.instrument, 399 | granularity=args.granularity, 400 | price=args.price, 401 | equity=args.equity, 402 | risk_pct=args.risk_pct, 403 | leverage=args.leverage, 404 | start=args.start, 405 | end=args.end, 406 | bb_window=args.bb_window, 407 | bb_std_dev=args.bb_std_dev, 408 | rsi_period=args.rsi_period, 409 | ema_period=args.ema_period, 410 | atr_period=args.atr_period, 411 | avg_range_window=args.avg_range_window, 412 | rsi_lower=args.rsi_lower, 413 | rsi_upper=args.rsi_upper, 414 | entry_atr_multiplier=args.entry_atr_multiplier, 415 | stop_atr_multiplier=args.stop_atr_multiplier, 416 | momentum_window=args.momentum_window, 417 | momentum_multiplier=args.momentum_multiplier, 418 | cutoff_hours=args.cutoff_hours, 419 | max_concurrent_trades=args.max_concurrent_trades, 420 | use_spreads=not args.no_spreads 421 | ) 422 | bt.plot = args.plot 423 | bt.run() 424 | --------------------------------------------------------------------------------