├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── oanda_bot ├── __init__.py └── oanda_bot.py ├── register.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── backtest.png ├── report.png └── test_oanda_bot.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__/ 3 | .venv/ 4 | *.egg-info/ 5 | dist/ 6 | .coverage 7 | .pytest_cache/ 8 | build/ 9 | .vscode/ 10 | .mypy_cache/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Set the build language to Python 2 | language: python 3 | 4 | # Set the python version 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | 10 | # Install the codecov pip dependency 11 | install: 12 | - pip install -r requirements.txt 13 | - pip install codecov 14 | 15 | # Run the unit test 16 | script: 17 | - python -m pytest --cov=oanda_bot tests/ 18 | 19 | # Push the results back to codecov 20 | after_success: 21 | - codecov -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 10mohi6 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oanda-bot 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/oanda-bot)](https://pypi.org/project/oanda-bot/) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![codecov](https://codecov.io/gh/10mohi6/oanda-bot-python/branch/master/graph/badge.svg)](https://codecov.io/gh/10mohi6/oanda-bot-python) 6 | [![Build Status](https://travis-ci.com/10mohi6/oanda-bot-python.svg?branch=master)](https://travis-ci.com/10mohi6/oanda-bot-python) 7 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/oanda-bot)](https://pypi.org/project/oanda-bot/) 8 | [![Downloads](https://pepy.tech/badge/oanda-bot)](https://pepy.tech/project/oanda-bot) 9 | 10 | oanda-bot is a python library for automated trading bot with oanda rest api on Python 3.6 and above. 11 | 12 | 13 | ## Installation 14 | 15 | $ pip install oanda-bot 16 | 17 | ## Usage 18 | 19 | ### basic run 20 | ```python 21 | from oanda_bot import Bot 22 | 23 | class MyBot(Bot): 24 | def strategy(self): 25 | fast_ma = self.sma(period=5) 26 | slow_ma = self.sma(period=25) 27 | # golden cross 28 | self.sell_exit = self.buy_entry = (fast_ma > slow_ma) & ( 29 | fast_ma.shift() <= slow_ma.shift() 30 | ) 31 | # dead cross 32 | self.buy_exit = self.sell_entry = (fast_ma < slow_ma) & ( 33 | fast_ma.shift() >= slow_ma.shift() 34 | ) 35 | 36 | MyBot( 37 | account_id='', 38 | access_token='', 39 | ).run() 40 | ``` 41 | 42 | ### basic backtest 43 | ```python 44 | from oanda_bot import Bot 45 | 46 | class MyBot(Bot): 47 | def strategy(self): 48 | fast_ma = self.sma(period=5) 49 | slow_ma = self.sma(period=25) 50 | # golden cross 51 | self.sell_exit = self.buy_entry = (fast_ma > slow_ma) & ( 52 | fast_ma.shift() <= slow_ma.shift() 53 | ) 54 | # dead cross 55 | self.buy_exit = self.sell_entry = (fast_ma < slow_ma) & ( 56 | fast_ma.shift() >= slow_ma.shift() 57 | ) 58 | 59 | MyBot( 60 | account_id='', 61 | access_token='', 62 | ).backtest() 63 | ``` 64 | 65 | ### basic report 66 | ```python 67 | from oanda_bot import Bot 68 | 69 | Bot( 70 | account_id='', 71 | access_token='', 72 | ).report() 73 | ``` 74 | 75 | ### advanced run 76 | ```python 77 | from oanda_bot import Bot 78 | 79 | class MyBot(Bot): 80 | def strategy(self): 81 | rsi = self.rsi(period=10) 82 | ema = self.ema(period=20) 83 | lower = ema - (ema * 0.001) 84 | upper = ema + (ema * 0.001) 85 | self.buy_entry = (rsi < 30) & (self.df.C < lower) 86 | self.sell_entry = (rsi > 70) & (self.df.C > upper) 87 | self.sell_exit = ema > self.df.C 88 | self.buy_exit = ema < self.df.C 89 | self.units = 1000 # currency unit (default=10000) 90 | self.take_profit = 50 # take profit pips (default=0 take profit none) 91 | self.stop_loss = 20 # stop loss pips (default=0 stop loss none) 92 | 93 | MyBot( 94 | account_id='', 95 | access_token='', 96 | # trading environment (default=practice) 97 | environment='practice', 98 | # trading currency (default=EUR_USD) 99 | instrument='USD_JPY', 100 | # 1 minute candlesticks (default=D) 101 | granularity='M1', 102 | # trading time (default=Bot.SUMMER_TIME) 103 | trading_time=Bot.WINTER_TIME, 104 | # Slack notification when an error occurs 105 | slack_webhook_url='', 106 | # Line notification when an error occurs 107 | line_notify_token='', 108 | # Discord notification when an error occurs 109 | discord_webhook_url='', 110 | ).run() 111 | ``` 112 | 113 | ### advanced backtest 114 | ```python 115 | from oanda_bot import Bot 116 | 117 | class MyBot(Bot): 118 | def strategy(self): 119 | rsi = self.rsi(period=10) 120 | ema = self.ema(period=20) 121 | lower = ema - (ema * 0.001) 122 | upper = ema + (ema * 0.001) 123 | self.buy_entry = (rsi < 30) & (self.df.C < lower) 124 | self.sell_entry = (rsi > 70) & (self.df.C > upper) 125 | self.sell_exit = ema > self.df.C 126 | self.buy_exit = ema < self.df.C 127 | self.units = 1000 # currency unit (default=10000) 128 | self.take_profit = 50 # take profit pips (default=0 take profit none) 129 | self.stop_loss = 20 # stop loss pips (default=0 stop loss none) 130 | 131 | MyBot( 132 | account_id='', 133 | access_token='', 134 | instrument='USD_JPY', 135 | granularity='S15', # 15 second candlestick 136 | ).backtest(from_date="2020-7-7", to_date="2020-7-13", filename="backtest.png") 137 | ``` 138 | ```python 139 | total profit 3910.000 140 | total trades 374.000 141 | win rate 59.091 142 | profit factor 1.115 143 | maximum drawdown 4220.000 144 | recovery factor 0.927 145 | riskreward ratio 0.717 146 | sharpe ratio 0.039 147 | average return 9.787 148 | stop loss 0.000 149 | take profit 0.000 150 | ``` 151 | ![backtest.png](https://raw.githubusercontent.com/10mohi6/oanda-bot-python/master/tests/backtest.png) 152 | 153 | ### advanced report 154 | ```python 155 | from oanda_bot import Bot 156 | 157 | Bot( 158 | account_id='', 159 | access_token='', 160 | instrument='USD_JPY', 161 | granularity='S15', # 15 second candlestick 162 | ).report(filename="report.png", days=-7) # from 7 days ago to now 163 | ``` 164 | ```python 165 | total profit -4960.000 166 | total trades 447.000 167 | win rate 59.284 168 | profit factor -0.887 169 | maximum drawdown 10541.637 170 | recovery factor -0.471 171 | riskreward ratio -0.609 172 | sharpe ratio -0.043 173 | average return -10.319 174 | ``` 175 | ![report.png](https://raw.githubusercontent.com/10mohi6/oanda-bot-python/master/tests/report.png) 176 | 177 | ### live run 178 | ```python 179 | from oanda_bot import Bot 180 | 181 | class MyBot(Bot): 182 | def atr(self, *, period: int = 14, price: str = "C"): 183 | a = (self.df.H - self.df.L).abs() 184 | b = (self.df.H - self.df[price].shift()).abs() 185 | c = (self.df.L - self.df[price].shift()).abs() 186 | 187 | df = pd.concat([a, b, c], axis=1).max(axis=1) 188 | return df.ewm(span=period).mean() 189 | 190 | def strategy(self): 191 | rsi = self.rsi(period=10) 192 | ema = self.ema(period=20) 193 | atr = self.atr(period=20) 194 | lower = ema - atr 195 | upper = ema + atr 196 | self.buy_entry = (rsi < 30) & (self.df.C < lower) 197 | self.sell_entry = (rsi > 70) & (self.df.C > upper) 198 | self.sell_exit = ema > self.df.C 199 | self.buy_exit = ema < self.df.C 200 | self.units = 1000 201 | 202 | MyBot( 203 | account_id='', 204 | access_token='', 205 | environment='live', 206 | instrument='EUR_GBP', 207 | granularity='H12', # 12 hour candlesticks 208 | trading_time=Bot.WINTER_TIME, 209 | slack_webhook_url='', 210 | ).run() 211 | ``` 212 | 213 | ## Supported indicators 214 | - Simple Moving Average 'sma' 215 | - Exponential Moving Average 'ema' 216 | - Moving Average Convergence Divergence 'macd' 217 | - Relative Strenght Index 'rsi' 218 | - Bollinger Bands 'bbands' 219 | - Market Momentum 'mom' 220 | - Stochastic Oscillator 'stoch' 221 | - Awesome Oscillator 'ao' 222 | 223 | 224 | ## Getting started 225 | 226 | For help getting started with OANDA REST API, view our online [documentation](https://developer.oanda.com/rest-live-v20/introduction/). 227 | 228 | 229 | ## Contributing 230 | 231 | 1. Fork it 232 | 2. Create your feature branch (`git checkout -b my-new-feature`) 233 | 3. Commit your changes (`git commit -am 'Add some feature'`) 234 | 4. Push to the branch (`git push origin my-new-feature`) 235 | 5. Create new Pull Request -------------------------------------------------------------------------------- /oanda_bot/__init__.py: -------------------------------------------------------------------------------- 1 | from oanda_bot.oanda_bot import Bot 2 | -------------------------------------------------------------------------------- /oanda_bot/oanda_bot.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from apscheduler.schedulers.blocking import BlockingScheduler 3 | from typing import Tuple, Any 4 | import numpy as np 5 | import pandas as pd 6 | import os 7 | import matplotlib.pyplot as plt 8 | import datetime 9 | from urllib import parse 10 | import json 11 | import logging 12 | from slack_webhook import Slack 13 | from linenotipy import Line 14 | from discordwebhook import Discord 15 | import time 16 | import dateutil.parser 17 | import matplotlib.dates as mdates 18 | 19 | 20 | class Bot(object): 21 | WINTER_TIME = 21 22 | SUMMER_TIME = 20 23 | 24 | def __init__( 25 | self, 26 | *, 27 | account_id: str, 28 | access_token: str, 29 | environment: str = "practice", 30 | instrument: str = "EUR_USD", 31 | granularity: str = "D", 32 | trading_time: int = SUMMER_TIME, 33 | slack_webhook_url: str = "", 34 | discord_webhook_url: str = "", 35 | line_notify_token: str = "", 36 | ) -> None: 37 | self.BUY = 1 38 | self.SELL = -1 39 | self.EXIT = False 40 | self.ENTRY = True 41 | self.trading_time = trading_time 42 | self.account_id = account_id 43 | self.headers = { 44 | "Content-Type": "application/json", 45 | "Authorization": "Bearer {}".format(access_token), 46 | } 47 | if environment == "practice": 48 | self.base_url = "https://api-fxpractice.oanda.com" 49 | else: 50 | self.base_url = "https://api-fxtrade.oanda.com" 51 | self.sched = BlockingScheduler() 52 | self.instrument = instrument 53 | self.granularity = granularity 54 | if len(granularity) > 1: 55 | if granularity[0] == "S": 56 | self.sched.add_job(self._job, "cron", second="*/" + granularity[1:]) 57 | elif granularity[0] == "M": 58 | self.sched.add_job(self._job, "cron", minute="*/" + granularity[1:]) 59 | elif granularity[0] == "H": 60 | self.sched.add_job(self._job, "cron", hour="*/" + granularity[1:]) 61 | else: 62 | if granularity == "D": 63 | self.sched.add_job(self._job, "cron", day="*") 64 | elif granularity == "W": 65 | self.sched.add_job(self._job, "cron", week="*") 66 | elif granularity == "M": 67 | self.sched.add_job(self._job, "cron", month="*") 68 | if slack_webhook_url == "": 69 | self.slack = None 70 | else: 71 | self.slack = Slack(url=slack_webhook_url) 72 | if line_notify_token == "": 73 | self.line = None 74 | else: 75 | self.line = Line(token=line_notify_token) 76 | if discord_webhook_url == "": 77 | self.discord = None 78 | else: 79 | self.discord = Discord(url=discord_webhook_url) 80 | formatter = logging.Formatter( 81 | "%(asctime)s - %(funcName)s - %(levelname)s - %(message)s" 82 | ) 83 | handler = logging.StreamHandler() 84 | handler.setLevel(logging.INFO) 85 | handler.setFormatter(formatter) 86 | self.log = logging.getLogger(__name__) 87 | self.log.setLevel(logging.INFO) 88 | self.log.addHandler(handler) 89 | if "JPY" in self.instrument: 90 | self.point = 0.01 91 | else: 92 | self.point = 0.0001 93 | self.units = 10000 # currency unit 94 | self.take_profit = 0 95 | self.stop_loss = 0 96 | self.buy_entry = ( 97 | self.buy_exit 98 | ) = self.sell_entry = self.sell_exit = pd.DataFrame() 99 | 100 | def _candles( 101 | self, *, from_date: str = "", to_date: str = "", count: str = "5000" 102 | ) -> pd.DataFrame: 103 | url = "{}/v3/instruments/{}/candles".format(self.base_url, self.instrument) 104 | params = {"granularity": self.granularity, "count": count} 105 | if from_date != "": 106 | _dt = dateutil.parser.parse(from_date) 107 | params["from"] = str( 108 | datetime.datetime( 109 | _dt.year, _dt.month, _dt.day, tzinfo=datetime.timezone.utc 110 | ).date() 111 | ) 112 | if to_date != "": 113 | _dt = dateutil.parser.parse(to_date) 114 | params["to"] = str( 115 | datetime.datetime( 116 | _dt.year, _dt.month, _dt.day, tzinfo=datetime.timezone.utc 117 | ).date() 118 | ) 119 | data = [] 120 | if "from" in params and "to" in params: 121 | _from = params["from"] 122 | _to = params["to"] 123 | del params["to"] 124 | while _to > _from: 125 | time.sleep(0.5) 126 | params["from"] = _from 127 | res = requests.get(url, headers=self.headers, params=params) 128 | if res.status_code != 200: 129 | self._error( 130 | "status_code {} - {}".format(res.status_code, res.json()) 131 | ) 132 | for r in res.json()["candles"]: 133 | data.append( 134 | [ 135 | pd.to_datetime(r["time"]), 136 | float(r["mid"]["o"]), 137 | float(r["mid"]["h"]), 138 | float(r["mid"]["l"]), 139 | float(r["mid"]["c"]), 140 | float(r["volume"]), 141 | ] 142 | ) 143 | _dt = pd.to_datetime(res.json()["candles"][-1]["time"]) 144 | _from = str(datetime.date(_dt.year, _dt.month, _dt.day)) 145 | else: 146 | res = requests.get(url, headers=self.headers, params=params) 147 | if res.status_code != 200: 148 | self._error("status_code {} - {}".format(res.status_code, res.json())) 149 | for r in res.json()["candles"]: 150 | data.append( 151 | [ 152 | pd.to_datetime(r["time"]), 153 | float(r["mid"]["o"]), 154 | float(r["mid"]["h"]), 155 | float(r["mid"]["l"]), 156 | float(r["mid"]["c"]), 157 | float(r["volume"]), 158 | ] 159 | ) 160 | self.df = ( 161 | pd.DataFrame(data, columns=["T", "O", "H", "L", "C", "V"]) 162 | .set_index("T") 163 | .drop_duplicates() 164 | ) 165 | return self.df 166 | 167 | def __accounts(self) -> requests.models.Response: 168 | url = "{}/v3/accounts/{}".format(self.base_url, self.account_id) 169 | res = requests.get(url, headers=self.headers) 170 | if res.status_code != 200: 171 | self._error("status_code {} - {}".format(res.status_code, res.json())) 172 | return res 173 | 174 | def _account(self) -> Tuple[bool, bool]: 175 | buy_position = False 176 | sell_position = False 177 | for pos in self.__accounts().json()["account"]["positions"]: 178 | if pos["instrument"] == self.instrument: 179 | if pos["long"]["units"] != "0": 180 | buy_position = True 181 | if pos["short"]["units"] != "0": 182 | sell_position = True 183 | return buy_position, sell_position 184 | 185 | def __order(self, data: Any) -> requests.models.Response: 186 | url = "{}/v3/accounts/{}/orders".format(self.base_url, self.account_id) 187 | res = requests.post(url, headers=self.headers, data=json.dumps(data)) 188 | if res.status_code != 201: 189 | self._error("status_code {} - {}".format(res.status_code, res.json())) 190 | return res 191 | 192 | def _order(self, sign: int, entry: bool = False) -> None: 193 | order = {} 194 | order["instrument"] = self.instrument 195 | order["units"] = str(self.units * sign) 196 | order["type"] = "MARKET" 197 | order["positionFill"] = "DEFAULT" 198 | res = self.__order({"order": order}) 199 | order_id = res.json()["orderFillTransaction"]["id"] 200 | price = float(res.json()["orderFillTransaction"]["price"]) 201 | if self.stop_loss != 0 and entry: 202 | stop_loss = {} 203 | stop_loss["timeInForce"] = "GTC" 204 | stop_loss["price"] = str( 205 | round(price + (self.stop_loss * self.point * -sign), 3) 206 | ) 207 | stop_loss["type"] = "STOP_LOSS" 208 | stop_loss["tradeID"] = order_id 209 | self.__order({"order": stop_loss}) 210 | if self.take_profit != 0 and entry: 211 | take_profit = {} 212 | take_profit["timeInForce"] = "GTC" 213 | take_profit["price"] = str( 214 | round(price + (self.take_profit * self.point * sign), 3) 215 | ) 216 | take_profit["type"] = "TAKE_PROFIT" 217 | take_profit["tradeID"] = order_id 218 | self.__order({"order": take_profit}) 219 | 220 | def _is_close(self) -> bool: 221 | utcnow = datetime.datetime.utcnow() 222 | hour = utcnow.hour 223 | weekday = utcnow.weekday() 224 | if ( 225 | (4 == weekday and self.trading_time < hour) 226 | or 5 == weekday 227 | or (6 == weekday and self.trading_time >= hour) 228 | ): 229 | return True 230 | return False 231 | 232 | def _job(self) -> None: 233 | if self._is_close(): 234 | return None 235 | self._candles(count="500") 236 | self.strategy() 237 | buy_position, sell_position = self._account() 238 | buy_entry = self.buy_entry[-1] 239 | sell_entry = self.sell_entry[-1] 240 | buy_exit = self.buy_exit[-1] 241 | sell_exit = self.sell_exit[-1] 242 | # buy entry 243 | if buy_entry and not buy_position: 244 | if sell_position: 245 | self._order(self.BUY) 246 | self._order(self.BUY, self.ENTRY) 247 | return None 248 | # sell entry 249 | if sell_entry and not sell_position: 250 | if buy_position: 251 | self._order(self.SELL) 252 | self._order(self.SELL, self.ENTRY) 253 | return None 254 | # buy exit 255 | if buy_exit and buy_position: 256 | self._order(self.SELL) 257 | # sell exit 258 | if sell_exit and sell_position: 259 | self._order(self.BUY) 260 | 261 | def _error(self, message: Any = {}) -> None: 262 | self.log.error(message) 263 | if self.slack is not None: 264 | self.slack.post(text=message) 265 | if self.line is not None: 266 | self.line.post(message=message) 267 | if self.discord is not None: 268 | self.discord.post(content=message) 269 | 270 | def __transactions(self, params: Any = {}) -> requests.models.Response: 271 | url = "{}/v3/accounts/{}/transactions".format(self.base_url, self.account_id) 272 | res = requests.get(url, headers=self.headers, params=params) 273 | if res.status_code != 200: 274 | self._error("status_code {} - {}".format(res.status_code, res.json())) 275 | return res 276 | 277 | def __transactions_sinceid(self, params: Any = {}) -> requests.models.Response: 278 | url = "{}/v3/accounts/{}/transactions/sinceid".format( 279 | self.base_url, self.account_id 280 | ) 281 | res = requests.get(url, headers=self.headers, params=params) 282 | if res.status_code != 200: 283 | self._error("status_code {} - {}".format(res.status_code, res.json())) 284 | return res 285 | 286 | def report(self, *, days: int = -1, filename: str = "",) -> None: 287 | tran = self.__transactions( 288 | { 289 | "from": ( 290 | datetime.datetime.utcnow().date() + datetime.timedelta(days=days) 291 | ), 292 | "type": "ORDER_FILL", 293 | } 294 | ).json() 295 | if len(tran["pages"]) == 0: 296 | print("Transactions do not exist") 297 | return None 298 | id = parse.parse_qs(parse.urlparse(tran["pages"][0]).query)["from"] 299 | data = [] 300 | for t in self.__transactions_sinceid({"id": id, "type": "ORDER_FILL"}).json()[ 301 | "transactions" 302 | ]: 303 | if float(t["pl"]) == 0.0: 304 | continue 305 | data.append( 306 | [ 307 | pd.to_datetime(t["time"]), 308 | t["id"], 309 | float(t["pl"]), 310 | round(float(t["pl"]), 2), 311 | float(t["price"]), 312 | float(t["accountBalance"]), 313 | ] 314 | ) 315 | df = pd.DataFrame( 316 | data, columns=["time", "id", "pl", "rr", "price", "accountBalance"] 317 | ).set_index("time") 318 | 319 | s = pd.Series(dtype="object") 320 | s.loc["total profit"] = round(df["pl"].sum(), 3) 321 | s.loc["total trades"] = len(df["pl"]) 322 | s.loc["win rate"] = round(len(df[df["pl"] > 0]) / len(df["pl"]) * 100, 3) 323 | s.loc["profit factor"] = round( 324 | df[df["pl"] > 0]["pl"].sum() / df[df["pl"] <= 0]["pl"].sum(), 3 325 | ) 326 | s.loc["maximum drawdown"] = round( 327 | (df["accountBalance"].cummax() - df["accountBalance"]).max(), 3 328 | ) 329 | s.loc["recovery factor"] = round( 330 | df["pl"].sum() 331 | / (df["accountBalance"].cummax() - df["accountBalance"]).max(), 332 | 3, 333 | ) 334 | s.loc["riskreward ratio"] = round( 335 | (df[df["pl"] > 0]["pl"].sum() / len(df[df["pl"] > 0])) 336 | / (df[df["pl"] <= 0]["pl"].sum() / len(df[df["pl"] <= 0])), 337 | 3, 338 | ) 339 | s.loc["sharpe ratio"] = round(df["rr"].mean() / df["rr"].std(), 3) 340 | s.loc["average return"] = round(df["rr"].mean(), 3) 341 | print(s) 342 | 343 | fig = plt.figure() 344 | fig.subplots_adjust( 345 | wspace=0.2, hspace=0.7, left=0.095, right=0.95, bottom=0.095, top=0.95 346 | ) 347 | ax1 = fig.add_subplot(3, 1, 1) 348 | ax1.plot(df["price"], label="price") 349 | ax1.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d\n%H:%M")) 350 | ax1.legend() 351 | ax2 = fig.add_subplot(3, 1, 2) 352 | ax2.plot(df["accountBalance"], label="accountBalance") 353 | ax2.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d\n%H:%M")) 354 | ax2.legend() 355 | ax3 = fig.add_subplot(3, 1, 3) 356 | ax3.hist(df["rr"], 50, rwidth=0.9) 357 | ax3.axvline( 358 | df["rr"].mean(), color="orange", label="average return", 359 | ) 360 | ax3.legend() 361 | if filename == "": 362 | plt.show() 363 | else: 364 | plt.savefig(filename) 365 | 366 | def strategy(self) -> None: 367 | pass 368 | 369 | def backtest( 370 | self, *, from_date: str = "", to_date: str = "", filename: str = "" 371 | ) -> None: 372 | csv = "{}-{}-{}-{}.csv".format( 373 | self.instrument, self.granularity, from_date, to_date 374 | ) 375 | if os.path.exists(csv): 376 | self.df = pd.read_csv( 377 | csv, index_col=0, parse_dates=True, infer_datetime_format=True 378 | ) 379 | else: 380 | self._candles(from_date=from_date, to_date=to_date) 381 | if from_date != "" and to_date != "": 382 | self.df.to_csv(csv) 383 | self.strategy() 384 | o = self.df.O.values 385 | L = self.df.L.values 386 | h = self.df.H.values 387 | N = len(self.df) 388 | long_trade = np.zeros(N) 389 | short_trade = np.zeros(N) 390 | 391 | # buy entry 392 | buy_entry_s = np.hstack((False, self.buy_entry[:-1])) # shift 393 | long_trade[buy_entry_s] = o[buy_entry_s] 394 | # buy exit 395 | buy_exit_s = np.hstack((False, self.buy_exit[:-2], True)) # shift 396 | long_trade[buy_exit_s] = -o[buy_exit_s] 397 | # sell entry 398 | sell_entry_s = np.hstack((False, self.sell_entry[:-1])) # shift 399 | short_trade[sell_entry_s] = o[sell_entry_s] 400 | # sell exit 401 | sell_exit_s = np.hstack((False, self.sell_exit[:-2], True)) # shift 402 | short_trade[sell_exit_s] = -o[sell_exit_s] 403 | 404 | long_pl = pd.Series(np.zeros(N)) # profit/loss of buy position 405 | short_pl = pd.Series(np.zeros(N)) # profit/loss of sell position 406 | buy_price = sell_price = 0 407 | long_rr = [] # long return rate 408 | short_rr = [] # short return rate 409 | stop_loss = take_profit = 0 410 | 411 | for i in range(1, N): 412 | # buy entry 413 | if long_trade[i] > 0: 414 | if buy_price == 0: 415 | buy_price = long_trade[i] 416 | short_trade[i] = -buy_price # sell exit 417 | else: 418 | long_trade[i] = 0 419 | 420 | # sell entry 421 | if short_trade[i] > 0: 422 | if sell_price == 0: 423 | sell_price = short_trade[i] 424 | long_trade[i] = -sell_price # buy exit 425 | else: 426 | short_trade[i] = 0 427 | 428 | # buy exit 429 | if long_trade[i] < 0: 430 | if buy_price != 0: 431 | long_pl[i] = ( 432 | -(buy_price + long_trade[i]) * self.units 433 | ) # profit/loss fixed 434 | long_rr.append( 435 | round(long_pl[i] / buy_price * 100, 2) 436 | ) # long return rate 437 | buy_price = 0 438 | else: 439 | long_trade[i] = 0 440 | 441 | # sell exit 442 | if short_trade[i] < 0: 443 | if sell_price != 0: 444 | short_pl[i] = ( 445 | sell_price + short_trade[i] 446 | ) * self.units # profit/loss fixed 447 | short_rr.append( 448 | round(short_pl[i] / sell_price * 100, 2) 449 | ) # short return rate 450 | sell_price = 0 451 | else: 452 | short_trade[i] = 0 453 | 454 | # close buy position with stop loss 455 | if buy_price != 0 and self.stop_loss > 0: 456 | stop_price = buy_price - self.stop_loss * self.point 457 | if L[i] <= stop_price: 458 | long_trade[i] = -stop_price 459 | long_pl[i] = ( 460 | -(buy_price + long_trade[i]) * self.units 461 | ) # profit/loss fixed 462 | long_rr.append( 463 | round(long_pl[i] / buy_price * 100, 2) 464 | ) # long return rate 465 | buy_price = 0 466 | stop_loss += 1 467 | 468 | # close buy positon with take profit 469 | if buy_price != 0 and self.take_profit > 0: 470 | limit_price = buy_price + self.take_profit * self.point 471 | if h[i] >= limit_price: 472 | long_trade[i] = -limit_price 473 | long_pl[i] = ( 474 | -(buy_price + long_trade[i]) * self.units 475 | ) # profit/loss fixed 476 | long_rr.append( 477 | round(long_pl[i] / buy_price * 100, 2) 478 | ) # long return rate 479 | buy_price = 0 480 | take_profit += 1 481 | 482 | # close sell position with stop loss 483 | if sell_price != 0 and self.stop_loss > 0: 484 | stop_price = sell_price + self.stop_loss * self.point 485 | if h[i] >= stop_price: 486 | short_trade[i] = -stop_price 487 | short_pl[i] = ( 488 | sell_price + short_trade[i] 489 | ) * self.units # profit/loss fixed 490 | short_rr.append( 491 | round(short_pl[i] / sell_price * 100, 2) 492 | ) # short return rate 493 | sell_price = 0 494 | stop_loss += 1 495 | 496 | # close sell position with take profit 497 | if sell_price != 0 and self.take_profit > 0: 498 | limit_price = sell_price - self.take_profit * self.point 499 | if L[i] <= limit_price: 500 | short_trade[i] = -limit_price 501 | short_pl[i] = ( 502 | sell_price + short_trade[i] 503 | ) * self.units # profit/loss fixed 504 | short_rr.append( 505 | round(short_pl[i] / sell_price * 100, 2) 506 | ) # short return rate 507 | sell_price = 0 508 | take_profit += 1 509 | 510 | win_trades = np.count_nonzero(long_pl.clip(lower=0)) + np.count_nonzero( 511 | short_pl.clip(lower=0) 512 | ) 513 | lose_trades = np.count_nonzero(long_pl.clip(upper=0)) + np.count_nonzero( 514 | short_pl.clip(upper=0) 515 | ) 516 | trades = (np.count_nonzero(long_trade) // 2) + ( 517 | np.count_nonzero(short_trade) // 2 518 | ) 519 | gross_profit = long_pl.clip(lower=0).sum() + short_pl.clip(lower=0).sum() 520 | gross_loss = long_pl.clip(upper=0).sum() + short_pl.clip(upper=0).sum() 521 | profit_pl = gross_profit + gross_loss 522 | self.equity = (long_pl + short_pl).cumsum() 523 | mdd = (self.equity.cummax() - self.equity).max() 524 | self.return_rate = pd.Series(short_rr + long_rr) 525 | 526 | s = pd.Series(dtype="object") 527 | s.loc["total profit"] = round(profit_pl, 3) 528 | s.loc["total trades"] = trades 529 | s.loc["win rate"] = round(win_trades / trades * 100, 3) 530 | s.loc["profit factor"] = round(-gross_profit / gross_loss, 3) 531 | s.loc["maximum drawdown"] = round(mdd, 3) 532 | s.loc["recovery factor"] = round(profit_pl / mdd, 3) 533 | s.loc["riskreward ratio"] = round( 534 | -(gross_profit / win_trades) / (gross_loss / lose_trades), 3 535 | ) 536 | s.loc["sharpe ratio"] = round( 537 | self.return_rate.mean() / self.return_rate.std(), 3 538 | ) 539 | s.loc["average return"] = round(self.return_rate.mean(), 3) 540 | s.loc["stop loss"] = stop_loss 541 | s.loc["take profit"] = take_profit 542 | print(s) 543 | 544 | fig = plt.figure() 545 | fig.subplots_adjust( 546 | wspace=0.2, hspace=0.5, left=0.095, right=0.95, bottom=0.095, top=0.95 547 | ) 548 | ax1 = fig.add_subplot(3, 1, 1) 549 | ax1.plot(self.df.C, label="close") 550 | ax1.legend() 551 | ax2 = fig.add_subplot(3, 1, 2) 552 | ax2.plot(self.equity, label="equity") 553 | ax2.legend() 554 | ax3 = fig.add_subplot(3, 1, 3) 555 | ax3.hist(self.return_rate, 50, rwidth=0.9) 556 | ax3.axvline( 557 | sum(self.return_rate) / len(self.return_rate), 558 | color="orange", 559 | label="average return", 560 | ) 561 | ax3.legend() 562 | if filename == "": 563 | plt.show() 564 | else: 565 | plt.savefig(filename) 566 | 567 | def run(self) -> None: 568 | self.sched.start() 569 | 570 | def sma(self, *, period: int, price: str = "C") -> pd.DataFrame: 571 | return self.df[price].rolling(period).mean() 572 | 573 | def ema(self, *, period: int, price: str = "C") -> pd.DataFrame: 574 | return self.df[price].ewm(span=period).mean() 575 | 576 | def bbands( 577 | self, *, period: int = 20, band: int = 2, price: str = "C" 578 | ) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: 579 | std = self.df[price].rolling(period).std() 580 | mean = self.df[price].rolling(period).mean() 581 | return mean + (std * band), mean, mean - (std * band) 582 | 583 | def macd( 584 | self, 585 | *, 586 | fast_period: int = 12, 587 | slow_period: int = 26, 588 | signal_period: int = 9, 589 | price: str = "C", 590 | ) -> Tuple[pd.DataFrame, pd.DataFrame]: 591 | macd = ( 592 | self.df[price].ewm(span=fast_period).mean() 593 | - self.df[price].ewm(span=slow_period).mean() 594 | ) 595 | signal = macd.ewm(span=signal_period).mean() 596 | return macd, signal 597 | 598 | def stoch( 599 | self, *, k_period: int = 5, d_period: int = 3 600 | ) -> Tuple[pd.DataFrame, pd.DataFrame]: 601 | k = ( 602 | (self.df.C - self.df.L.rolling(k_period).min()) 603 | / (self.df.H.rolling(k_period).max() - self.df.L.rolling(k_period).min()) 604 | * 100 605 | ) 606 | d = k.rolling(d_period).mean() 607 | return k, d 608 | 609 | def mom(self, *, period: int = 10, price: str = "C") -> pd.DataFrame: 610 | return self.df[price].diff(period) 611 | 612 | def rsi(self, *, period: int = 14, price: str = "C") -> pd.DataFrame: 613 | return 100 - 100 / ( 614 | 1 615 | - self.df[price].diff().clip(lower=0).rolling(period).mean() 616 | / self.df[price].diff().clip(upper=0).rolling(period).mean() 617 | ) 618 | 619 | def ao(self, *, fast_period: int = 5, slow_period: int = 34) -> pd.DataFrame: 620 | return ((self.df.H + self.df.L) / 2).rolling(fast_period).mean() - ( 621 | (self.df.H + self.df.L) / 2 622 | ).rolling(slow_period).mean() 623 | -------------------------------------------------------------------------------- /register.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.system("rm dist/*") 4 | os.system("python setup.py sdist bdist_wheel") 5 | os.system("twine upload dist/*") 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | APScheduler==3.6.3 2 | attrs==20.3.0 3 | certifi==2020.12.5 4 | chardet==3.0.4 5 | coverage==5.3 6 | cycler==0.10.0 7 | discordwebhook==1.0.0 8 | idna==2.10 9 | iniconfig==1.1.1 10 | kiwisolver==1.3.1 11 | linenotipy==1.0.2 12 | matplotlib==3.3.3 13 | numpy==1.19.4 14 | packaging==20.7 15 | pandas==1.1.4 16 | Pillow==8.0.1 17 | pluggy==0.13.1 18 | py==1.9.0 19 | pyparsing==2.4.7 20 | pytest==6.1.2 21 | pytest-cov==2.10.1 22 | python-dateutil==2.8.1 23 | pytz==2020.4 24 | requests==2.25.0 25 | six==1.15.0 26 | slack-webhook==1.0.4 27 | toml==0.10.2 28 | tzlocal==2.1 29 | urllib3==1.26.2 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E203,W503,W504 4 | 5 | [mypy] 6 | ignore_missing_imports = True 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="oanda-bot", 5 | version="0.1.2", 6 | description="oanda-bot is a python library \ 7 | for automated trading bot with oanda rest api on Python 3.6 and above.", 8 | long_description=open("README.md").read(), 9 | long_description_content_type="text/markdown", 10 | license="MIT", 11 | author="10mohi6", 12 | author_email="10.mohi.6.y@gmail.com", 13 | url="https://github.com/10mohi6/oanda-bot-python", 14 | keywords="oanda automating trading bot python backtest report fx", 15 | packages=find_packages(), 16 | install_requires=[ 17 | "requests", 18 | "numpy", 19 | "pandas", 20 | "matplotlib", 21 | "APScheduler", 22 | "slack_webhook", 23 | "linenotipy", 24 | "discordwebhook", 25 | ], 26 | python_requires=">=3.6.0", 27 | classifiers=[ 28 | "Development Status :: 4 - Beta", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: 3.6", 32 | "Programming Language :: Python :: 3.7", 33 | "Programming Language :: Python :: 3.8", 34 | "Intended Audience :: Developers", 35 | "Intended Audience :: Financial and Insurance Industry", 36 | "Operating System :: OS Independent", 37 | "Topic :: Office/Business :: Financial :: Investment", 38 | "License :: OSI Approved :: MIT License", 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /tests/backtest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10mohi6/oanda-bot-python/cedafb259b899fd0b096dabb50b7cb6420f096cc/tests/backtest.png -------------------------------------------------------------------------------- /tests/report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10mohi6/oanda-bot-python/cedafb259b899fd0b096dabb50b7cb6420f096cc/tests/report.png -------------------------------------------------------------------------------- /tests/test_oanda_bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from oanda_bot import Bot 4 | import time 5 | 6 | 7 | @pytest.fixture(scope="module", autouse=True) 8 | def scope_module(): 9 | class MyBot(Bot): 10 | def strategy(self): 11 | rsi = self.rsi(period=10) 12 | ema = self.ema(period=20) 13 | self.buy_entry = (rsi < 30) & (self.df.C < ema) 14 | self.sell_entry = (rsi > 70) & (self.df.C > ema) 15 | self.sell_exit = ema > self.df.C 16 | self.buy_exit = ema < self.df.C 17 | self.units = 10000 18 | self.take_profit = 0 19 | self.stop_loss = 0 20 | 21 | yield MyBot( 22 | account_id=os.environ["OANDA_BOT_ID"], 23 | access_token=os.environ["OANDA_BOT_TOKEN"], 24 | environment="practice", 25 | instrument="USD_JPY", 26 | granularity="M1", 27 | trading_time=Bot.SUMMER_TIME, 28 | slack_webhook_url=os.environ["SLACK_WEBHOOK_URL"], 29 | line_notify_token=os.environ["LINE_NOTIFY_TOKEN"], 30 | discord_webhook_url=os.environ["DISCORD_WEBHOOK_URL"], 31 | ) 32 | 33 | 34 | @pytest.fixture(scope="function", autouse=True) 35 | def bot(scope_module): 36 | time.sleep(0.5) 37 | yield scope_module 38 | 39 | 40 | # @pytest.mark.skip 41 | def test_error(bot): 42 | bot._error("oanda bot error test") 43 | 44 | 45 | # @pytest.mark.skip 46 | def test_backtest(bot): 47 | bot.backtest() 48 | 49 | 50 | # @pytest.mark.skip 51 | def test_report(bot): 52 | bot.report() 53 | --------------------------------------------------------------------------------