├── 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 | "
"
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 | "
"
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 |
--------------------------------------------------------------------------------