├── .github └── FUNDING.yml ├── btoandav20 ├── version.py ├── feeds │ ├── __init__.py │ └── oandav20feed.py ├── brokers │ ├── __init__.py │ └── oandav20broker.py ├── commissions │ ├── __init__.py │ └── oandav20comm.py ├── sizers │ ├── __init__.py │ ├── oandav20backtestsizer.py │ └── oandav20sizer.py ├── stores │ ├── __init__.py │ ├── oandaposition.py │ └── oandav20store.py └── __init__.py ├── requirements.txt ├── examples ├── config.json ├── oanda_order_data.py ├── oanda_order_replace.py ├── oanda_order.py └── oandav20test │ └── oandav20test.py ├── setup.py ├── .gitignore ├── README.md └── LICENSE /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: happydasch 2 | -------------------------------------------------------------------------------- /btoandav20/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.1' 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backtrader 2 | pyyaml 3 | v20 -------------------------------------------------------------------------------- /examples/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "oanda": { 3 | "token": "", 4 | "account": "", 5 | "practice": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /btoandav20/feeds/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from .oandav20feed import OandaV20Data 5 | -------------------------------------------------------------------------------- /btoandav20/brokers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from .oandav20broker import OandaV20Broker 5 | -------------------------------------------------------------------------------- /btoandav20/commissions/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from .oandav20comm import OandaV20BacktestCommInfo 5 | -------------------------------------------------------------------------------- /btoandav20/sizers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from .oandav20sizer import * 5 | from .oandav20backtestsizer import * 6 | -------------------------------------------------------------------------------- /btoandav20/stores/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from .oandav20store import OandaV20Store 5 | from .oandaposition import OandaPosition 6 | -------------------------------------------------------------------------------- /btoandav20/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from . import feeds as feeds 5 | from . import stores as stores 6 | from . import brokers as brokers 7 | from . import sizers as sizers 8 | from . import commissions as commissions 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from distutils.util import convert_path 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | main_ns = {} 8 | ver_path = convert_path('btoandav20/version.py') 9 | with open(ver_path) as ver_file: 10 | exec(ver_file.read(), main_ns) 11 | 12 | setuptools.setup( 13 | name="btoandav20", 14 | version=main_ns['__version__'], 15 | description="Integrate Oanda-V20 API into backtrader", 16 | long_description=long_description, 17 | license='GNU General Public License Version 3', 18 | url="https://github.com/happydasch/btoandav20", 19 | packages=setuptools.find_packages(), 20 | install_requires=[ 21 | 'backtrader>=1.9', 22 | 'pyyaml', 23 | 'v20' 24 | ], 25 | classifiers=[ 26 | "Development Status :: 3 - Alpha", 27 | "Programming Language :: Python :: 3" 28 | ], 29 | python_requires='>=3.6' 30 | ) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .idea 103 | .project 104 | .pydevproject 105 | -------------------------------------------------------------------------------- /examples/oanda_order_data.py: -------------------------------------------------------------------------------- 1 | import backtrader as bt 2 | import btoandav20 as bto 3 | import json 4 | 5 | ''' Order info ''' 6 | 7 | 8 | class St(bt.Strategy): 9 | 10 | def __init__(self): 11 | self.order = None 12 | self.mybuysignals = { 13 | "buysignal1": False, 14 | "buysignal2": False, 15 | "buysignal3": False, 16 | "buysignal4": True, 17 | "buysignal5": False, 18 | 19 | } 20 | 21 | def notify_store(self, msg, *args, **kwargs): 22 | if "clientExtensions" in msg: 23 | o_info = json.loads(msg["clientExtensions"]["comment"]) 24 | buytrigger = o_info["buytrigger"] 25 | 26 | def next(self): 27 | if self.order: 28 | return 29 | 30 | for k, v in self.mybuysignals.items(): 31 | if v: 32 | self.order = self.buy( 33 | size=1, 34 | buytrigger=k 35 | ) 36 | 37 | with open("config.json", "r") as file: 38 | config = json.load(file) 39 | 40 | storekwargs = dict( 41 | token=config["oanda"]["token"], 42 | account=config["oanda"]["account"], 43 | practice=config["oanda"]["practice"], 44 | notif_transactions=True, 45 | stream_timeout=10, 46 | ) 47 | store = bto.stores.OandaV20Store(**storekwargs) 48 | datakwargs = dict( 49 | timeframe=bt.TimeFrame.Minutes, 50 | compression=1, 51 | tz='Europe/Berlin', 52 | backfill=False, 53 | backfill_start=False, 54 | ) 55 | data = store.getdata(dataname="EUR_USD", **datakwargs) 56 | data.resample( 57 | timeframe=bt.TimeFrame.Minutes, 58 | compression=1) # rightedge=True, boundoff=1) 59 | cerebro = bt.Cerebro() 60 | cerebro.adddata(data) 61 | cerebro.setbroker(store.getbroker()) 62 | cerebro.addstrategy(St) 63 | cerebro.run() 64 | -------------------------------------------------------------------------------- /btoandav20/commissions/oandav20comm.py: -------------------------------------------------------------------------------- 1 | from backtrader.comminfo import CommInfoBase 2 | 3 | 4 | class OandaV20BacktestCommInfo(CommInfoBase): 5 | 6 | params = dict( 7 | spread=0.0, 8 | acc_counter_currency=True, 9 | pip_location=-4, 10 | margin=0.5, 11 | leverage=20.0, 12 | stocklike=False, 13 | commtype=CommInfoBase.COMM_FIXED, 14 | ) 15 | 16 | def __init__(self, data=None): 17 | self.data = data 18 | if self.p.stocklike: 19 | raise Exception('Stocklike is not supported') 20 | super(OandaV20BacktestCommInfo, self).__init__() 21 | 22 | def getsize(self, price, cash): 23 | '''Returns the needed size to meet a cash operation at a given price''' 24 | size = super(OandaV20BacktestCommInfo, self).getsize(price, cash) 25 | size *= self.p.margin 26 | if not self.p.acc_counter_currency: 27 | size /= price 28 | return int(size) 29 | 30 | def _getcommission(self, size, price, pseudoexec): 31 | ''' 32 | This scheme will apply half the commission when buying and half when selling. 33 | If account currency is same as the base currency, change pip value calc. 34 | https://community.backtrader.com/topic/525/forex-commission-scheme 35 | ''' 36 | if (self.data is not None 37 | and hasattr(self.data.l, 'bid_close') 38 | and hasattr(self.data.l, 'ask_close') 39 | and hasattr(self.data.l, 'mid_close')): 40 | if size > 0: 41 | spread = self.data.l.mid_close[0] - self.data.l.bid_close[0] 42 | else: 43 | spread = self.data.l.ask_close[0] - self.data.l.mid_close[0] 44 | else: 45 | spread = self.p.spread 46 | multiplier = float(10 ** self.p.pip_location) 47 | if self.p.acc_counter_currency: 48 | comm = abs(spread * (size * multiplier)) 49 | else: 50 | comm = abs(spread * ((size / price) * multiplier)) 51 | return comm / 2 52 | -------------------------------------------------------------------------------- /examples/oanda_order_replace.py: -------------------------------------------------------------------------------- 1 | import backtrader as bt 2 | import btoandav20 as bto 3 | import json 4 | 5 | ''' Test for orders from oanda ''' 6 | 7 | 8 | class St(bt.Strategy): 9 | 10 | params = { 11 | "order_type": bt.Order.StopTrail, 12 | } 13 | 14 | def __init__(self): 15 | self.order = None 16 | self.stop = None 17 | self.limit = None 18 | 19 | def notify_store(self, msg, *args, **kwargs): 20 | txt = ["*" * 5, "STORE NOTIF:", msg] 21 | print(", ".join(txt)) 22 | 23 | def notify_order(self, order): 24 | if order.status == order.Completed: 25 | if order.ref == self.order.ref: 26 | self.order = None 27 | txt = ["*" * 5, "ORDER NOTIF:", str(order)] 28 | print(", ".join(txt)) 29 | 30 | def notify_trade(self, trade): 31 | txt = ["*" * 5, "TRADE NOTIF:", str(trade)] 32 | print(", ".join(txt)) 33 | 34 | def next(self): 35 | 36 | if self.order: 37 | return 38 | 39 | if self.p.order_type == bt.Order.StopLimit: 40 | if not self.stop and not self.limit: 41 | price = self.data_close[0] + 0.0002 42 | self.order, self.stop, self.limit = self.buy_bracket( 43 | size=1, 44 | exectype=bt.Order.Stop, 45 | price=price, 46 | oargs={}, 47 | stopexec=None, 48 | limitprice=price+0.001, 49 | limitexec=bt.Order.StopLimit, 50 | limitargs={}) 51 | elif self.limit: 52 | price = self.data_close[0] + 0.002 53 | self.limit = self.sell( 54 | exectype=bt.Order.StopLimit, 55 | plimit=price, 56 | replace=self.limit.ref) 57 | elif self.p.order_type == bt.Order.StopTrail: 58 | if not self.stop and not self.limit: 59 | price = self.data_close[0] + 0.0002 60 | self.order, self.stop, self.limit = self.buy_bracket( 61 | size=1, 62 | exectype=bt.Order.Stop, 63 | price=price, 64 | oargs={}, 65 | stopexec=bt.Order.StopTrail, 66 | stopargs={ 67 | "trailamount": 0.0005 68 | }, 69 | limitexec=None,) 70 | elif self.stop: 71 | self.stop = self.sell( 72 | exectype=bt.Order.StopTrail, 73 | trailamount=0.001, 74 | replace=self.stop.ref) 75 | 76 | 77 | with open("config.json", "r") as file: 78 | config = json.load(file) 79 | 80 | storekwargs = dict( 81 | token=config["oanda"]["token"], 82 | account=config["oanda"]["account"], 83 | practice=config["oanda"]["practice"], 84 | notif_transactions=True, 85 | stream_timeout=10, 86 | ) 87 | store = bto.stores.OandaV20Store(**storekwargs) 88 | datakwargs = dict( 89 | timeframe=bt.TimeFrame.Minutes, 90 | compression=1, 91 | tz='Europe/Berlin', 92 | backfill=False, 93 | backfill_start=False, 94 | ) 95 | data = store.getdata(dataname="EUR_USD", **datakwargs) 96 | data.resample( 97 | timeframe=bt.TimeFrame.Minutes, 98 | compression=1) # rightedge=True, boundoff=1) 99 | cerebro = bt.Cerebro() 100 | cerebro.adddata(data) 101 | cerebro.setbroker(store.getbroker()) 102 | cerebro.addstrategy(St) 103 | cerebro.run() 104 | -------------------------------------------------------------------------------- /btoandav20/sizers/oandav20backtestsizer.py: -------------------------------------------------------------------------------- 1 | import backtrader as bt 2 | 3 | from btoandav20.commissions import OandaV20BacktestCommInfo 4 | 5 | 6 | class OandaV20BacktestSizer(bt.Sizer): 7 | 8 | params = dict( 9 | percents=0, # percents of cash 10 | amount=0, # amount of cash 11 | avail_reduce_perc=0, 12 | ) 13 | 14 | def _getsizing(self, comminfo, cash, data, isbuy): 15 | position = self.broker.getposition(data) 16 | if position: 17 | return position.size 18 | price = data.close[0] 19 | avail = comminfo.getsize(price, cash) 20 | if self.p.avail_reduce_perc > 0: 21 | avail -= avail/100 * self.p.avail_reduce_perc 22 | if self.p.percents != 0: 23 | size = avail * (self.p.percents / 100) 24 | elif self.p.amount != 0: 25 | size = (avail / cash) * self.p.amount 26 | else: 27 | size = 0 28 | return int(size) 29 | 30 | 31 | class OandaV20BacktestPercentSizer(OandaV20BacktestSizer): 32 | 33 | params = dict( 34 | percents=5, 35 | ) 36 | 37 | 38 | class OandaV20BacktestCashSizer(OandaV20BacktestSizer): 39 | 40 | params = dict( 41 | amount=50, 42 | ) 43 | 44 | 45 | class OandaV20BacktestRiskSizer(bt.Sizer): 46 | 47 | params = dict( 48 | percents=0, # risk percents 49 | amount=0, # risk amount 50 | pips=5, # stop loss in pips 51 | avail_reduce_perc=0, 52 | ) 53 | 54 | def getsizing(self, data, isbuy, pips=None, price=None, 55 | exchange_rate=None): 56 | comminfo = self.broker.getcommissioninfo(data) 57 | return self._getsizing( 58 | comminfo, self.broker.getvalue(), 59 | data, isbuy, pips, price, exchange_rate) 60 | 61 | def _getsizing(self, comminfo, cash, data, isbuy, pips=None, 62 | price=None, exchange_rate=None): 63 | position = self.broker.getposition(data) 64 | if position: 65 | return position.size 66 | if not pips: 67 | pips = self.p.pips 68 | price = data.close[0] 69 | avail = comminfo.getsize(price, cash) 70 | if self.p.avail_reduce_perc > 0: 71 | avail -= avail / 100 * self.p.avail_reduce_perc 72 | if self.p.percents != 0: 73 | cash_to_use = cash * (self.p.percents / 100) 74 | elif self.p.amount != 0: 75 | cash_to_use = self.p.amount 76 | else: 77 | raise Exception('Either percents or amount is needed') 78 | if not isinstance(comminfo, OandaV20BacktestCommInfo): 79 | raise Exception('OandaV20CommInfo required') 80 | 81 | mult = float(1 / 10 ** comminfo.p.pip_location) 82 | price_per_pip = cash_to_use / pips 83 | if not comminfo.p.acc_counter_currency and price: 84 | # Acc currency is same as base currency 85 | pip = price_per_pip * price 86 | size = pip * mult 87 | elif exchange_rate: 88 | # Acc currency is neither same as base or counter currency 89 | pip = price_per_pip * exchange_rate 90 | size = pip * mult 91 | else: 92 | # Acc currency and counter currency are the same 93 | size = price_per_pip * mult 94 | size = min(size, avail) 95 | return int(size) 96 | 97 | 98 | class OandaV20BacktestRiskPercentSizer(OandaV20BacktestRiskSizer): 99 | 100 | params = dict( 101 | percents=5, 102 | ) 103 | 104 | 105 | class OandaV20BacktestRiskCashSizer(OandaV20BacktestRiskSizer): 106 | 107 | params = dict( 108 | amount=50, 109 | ) 110 | -------------------------------------------------------------------------------- /btoandav20/sizers/oandav20sizer.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | import backtrader as bt 5 | from btoandav20.stores import oandav20store 6 | 7 | 8 | class OandaV20Sizer(bt.Sizer): 9 | 10 | params = dict( 11 | percents=0, # percents of cash 12 | amount=0, # fixed amount 13 | avail_reduce_perc=0, 14 | ) 15 | 16 | def __init__(self, **kwargs): 17 | super(OandaV20Sizer, self).__init__(**kwargs) 18 | self.o = oandav20store.OandaV20Store(**kwargs) 19 | 20 | def _getsizing(self, comminfo, cash, data, isbuy): 21 | position = self.broker.getposition(data) 22 | if position: 23 | return position.size 24 | 25 | name = data.contractdetails['name'] 26 | sym_src = self.o.get_currency() 27 | sym_to = name[-(len(sym_src)):] 28 | 29 | cash_to_use = 0 30 | if self.p.percents != 0: 31 | cash_to_use = cash * (self.p.percents / 100) 32 | elif self.p.amount != 0: 33 | cash_to_use = self.p.amount 34 | if self.p.avail_reduce_perc > 0: 35 | cash_to_use -= cash_to_use / 100 * self.p.avail_reduce_perc 36 | 37 | price = self.o.get_pricing(name) 38 | if not price: 39 | return 0 40 | if sym_src != sym_to: 41 | # convert cash to target currency 42 | convprice = self.o.get_pricing(sym_src + '_' + sym_to) 43 | if convprice: 44 | cash_to_use = ( 45 | cash_to_use 46 | / (1 / float(convprice['closeoutAsk']))) 47 | 48 | if self.p.percents != 0: 49 | size = avail * (self.p.percents / 100) 50 | elif self.p.amount != 0: 51 | size = cash_to_use * (self.p.amount / cash) 52 | else: 53 | size = 0 54 | return int(size) 55 | 56 | 57 | class OandaV20PercentSizer(OandaV20Sizer): 58 | 59 | params = dict( 60 | percents=5, 61 | ) 62 | 63 | 64 | class OandaV20CashSizer(OandaV20Sizer): 65 | 66 | params = dict( 67 | amount=50, 68 | ) 69 | 70 | 71 | class OandaV20RiskSizer(bt.Sizer): 72 | 73 | params = dict( 74 | percents=0, # risk percents 75 | amount=0, # risk amount 76 | pips=10, # stop loss in pips 77 | avail_reduce_perc=0, 78 | ) 79 | 80 | def __init__(self, **kwargs): 81 | super(OandaV20RiskSizer, self).__init__(**kwargs) 82 | self.o = oandav20store.OandaV20Store(**kwargs) 83 | 84 | def getsizing(self, data, isbuy, pips=None): 85 | comminfo = self.broker.getcommissioninfo(data) 86 | return self._getsizing( 87 | comminfo, 88 | self.broker.getcash(), 89 | data, 90 | isbuy, 91 | pips) 92 | 93 | def _getsizing(self, comminfo, cash, data, isbuy, pips=None): 94 | if not pips: 95 | pips = self.p.pips 96 | position = self.broker.getposition(data) 97 | if position: 98 | return position.size 99 | 100 | name = data.contractdetails['name'] 101 | sym_src = self.o.get_currency() 102 | sym_to = name[-(len(sym_src)):] 103 | 104 | cash_to_use = 0 105 | if self.p.percents != 0: 106 | cash_to_use = cash * (self.p.percents / 100) 107 | elif self.p.amount != 0: 108 | cash_to_use = self.p.amount 109 | if self.p.avail_reduce_perc > 0: 110 | cash_to_use -= cash_to_use / 100 * self.p.avail_reduce_perc 111 | 112 | price = self.o.get_pricing(name) 113 | if not price: 114 | return 0 115 | if sym_src != sym_to: 116 | # convert cash to target currency 117 | convprice = self.o.get_pricing(sym_src + '_' + sym_to) 118 | if convprice: 119 | cash_to_use = ( 120 | cash_to_use 121 | / (1 / float(convprice['closeoutAsk']))) 122 | 123 | price_per_pip = cash_to_use / pips 124 | mult = float(1 / 10 ** data.contractdetails['pipLocation']) 125 | size = price_per_pip * mult 126 | return int(size) 127 | 128 | 129 | class OandaV20RiskPercentSizer(OandaV20RiskSizer): 130 | 131 | params = dict( 132 | percents=5, 133 | ) 134 | 135 | 136 | class OandaV20RiskCashSizer(OandaV20RiskSizer): 137 | 138 | params = dict( 139 | amount=50, 140 | ) 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # btoandav20 2 | 3 | Support for Oanda-V20 API in backtrader 4 | 5 | **This integration is still under development and may have some issues, use it for live trading at your own risk!** 6 | 7 | ## What is it 8 | 9 | **btoandav20** is a package to integrate OANDA into [backtrader](https://www.backtrader.com/). 10 | It uses the [v20](http://developer.oanda.com/rest-live-v20/introduction/) API of OANDA. It can be used with demo or live account. 11 | We highly recommend to have a specific account to use backtrader with OANDA. You should not trade manually on the same account if you wish to use backtrader. 12 | 13 | **It includes all necessary utilities to backtest or do live trading:** 14 | 15 | * Store 16 | * Broker 17 | * Data Feeds 18 | * Sizers 19 | * Commissions 20 | 21 | **Available features:** 22 | 23 | * Accessing oandav20 API 24 | * Streaming prices 25 | * Streaming events 26 | * Get *unlimited* history prices for backtesting 27 | * Replay functionality for backtesting 28 | * Replace pending orders 29 | * Possibility to load existing positions from the OANDA account 30 | * Reconnects on broken connections and after timeouts, also backfills data after a timeout or disconnect occurred 31 | 32 | * **Support different type of orders:** 33 | * Order.Market 34 | * Order.Limit 35 | * Order.Stop 36 | * Order.StopTrail (by using brackets) 37 | * Bracket orders are supported by using the takeprofit and stoploss order members and creating internally simulated orders. 38 | 39 | * **4 different OandaV20 Sizers:** 40 | * OandaV20PercentSizer - returns position size which matches the percent amount of total cash 41 | * OandaV20CashSizer - return position size which matches the cash amount 42 | * OandaV20RiskPercentSizer - returns position size which matches the total risk in percent of total amount (max stop loss) 43 | * OandaV2 44 | 0RiskCashSizer - returns position size which matches the total risk in percent of total amount (max stop loss) 45 | 46 | * **4 different backtest Sizers:** 47 | * OandaV20BacktestPercentSizer - returns position size which matches the percent amount of total cash 48 | * OandaV20BacktestCashSizer - return position size which matches the cash amount 49 | * OandaV20BacktestRiskPercentSizer - returns position size which matches the total risk in percent of total amount (max pips) 50 | * OandaV20BacktestRiskCashSizer - returns position size which matches the total risk in percent of total amount (max pips) 51 | 52 | ## Order Types 53 | 54 | btoandav20 supports Market, Limit and Stop orders. Other order types, like StopTrail need to be created using brackets. 55 | 56 | ### StopTrail order 57 | 58 | orderexec: bt.Order.Stop 59 | 60 | stopexec: bt.Order.StopTrail 61 | 62 | ### Changing StopTrail order 63 | 64 | To change a StopTrail order the stopside or takeside needs to be canceled and a new order with the order type StopTrail needs to be created. 65 | 66 | Also an oref of the original order needs to be provided, when creating this order. 67 | The order needs to go into the opposing direction. 68 | 69 | **StopTrail example:** 70 | 71 | Provide the stoptrail in stopargs with trailamount or trailpercent 72 | 73 | * o, ostop, olimit = buy_bracket(exectype=bt.Order.Stop, stopexec=bt.Order.StopTrail, stopargs={"trailamount": xxx or "trailpercent": yyy} limitexec=None) 74 | 75 | Create new trailing stop for parent order 76 | 77 | * self.sell(exectype=bt.Order.StopTrail, trailamount=xxx or trailpercent=yyy, replace=ostop.ref) 78 | 79 | ## Dependencies 80 | 81 | * python 3.6 82 | * ``Backtrader`` (tested with version 1.9.61.122) 83 | * ``pyyaml`` (tested with version 3.13) 84 | * ``v20`` (tested with version 3.0.25) () 85 | 86 | ## Installation 87 | 88 | The following steps have been tested on Mac OS High Sierra and Ubuntu 16 and 18. 89 | 90 | 1. Install backtrader ``pip install backtrader[plotting]`` () 91 | 2. Install btoandav20 ``pip install git+https://github.com/happydasch/btoandav20`` 92 | or with ``pipenv install git+https://github.com/happydasch/btoandav20#egg=btoandav20`` 93 | 94 | 3. Import ``btoandav20`` into your script: ``import btoandav20`` (this is considering your script is at the root of your folder) 95 | 96 | **You can then access the different parts such as:** 97 | 98 | *Live:* 99 | 100 | * Store: ``btoandav20.stores.OandaV20Store`` 101 | * Data Feed: ``btoandav20.feeds.OandaV20Data`` 102 | * Broker: ``btoandav20.brokers.OandaV20Broker`` 103 | * Sizers: 104 | * ``btoandav20.sizers.OandaV20PercentSizer`` 105 | * ``btoandav20.sizers.OandaV20CashSizer`` 106 | * ``btoandav20.sizers.OandaV20RiskPercentSizer`` 107 | * ``btoandav20.sizers.OandaV20RiskCashSizer`` 108 | 109 | *Backtesting:* 110 | 111 | * Sizers: 112 | * ``btoandav20.sizers.ForexPercentSizer`` 113 | * ``btoandav20.sizers.ForexCashSizer`` 114 | * ``btoandav20.sizers.ForexRiskPercentSizer`` 115 | * ``btoandav20.sizers.ForexRiskCashSizer`` 116 | * Commissioninfo: ``btoandav20.commissions.OandaV20CommInfoBacktest`` 117 | 118 | If you encounter an issue during installation, please check this url first: and create a new issue if this doesn't solve it. 119 | 120 | ## Getting Started 121 | 122 | See the [example](examples) folder for more detailed explanation on how to use it. 123 | 124 | ## Contribute 125 | 126 | We are looking for contributors: if you are interested to join us please contact us. 127 | 128 | ## Sponsoring 129 | 130 | If you want to support the development of btoandav20, consider to support this project. 131 | 132 | * BTC: 39BJtPgUv6UMjQvjguphN7kkjQF65rgMMF 133 | * ETH: 0x06d6f3134CD679d05AAfeA6e426f55805f9B395D 134 | * 135 | 136 | ## License 137 | 138 | All code is based on backtrader oandastore which is released under GNU General Public License Version 3 by Daniel Rodriguez 139 | -------------------------------------------------------------------------------- /examples/oanda_order.py: -------------------------------------------------------------------------------- 1 | import backtrader as bt 2 | import btoandav20 as bto 3 | import json 4 | 5 | ''' Test for orders from oanda ''' 6 | 7 | 8 | class St(bt.Strategy): 9 | 10 | params = dict( 11 | order_type=bt.Order.Stop, 12 | use_brackets=True, 13 | sell=False 14 | ) 15 | 16 | def __init__(self): 17 | self.order = None 18 | 19 | def notify_store(self, msg, *args, **kwargs): 20 | txt = ["*" * 5, "STORE NOTIF:", msg] 21 | print(", ".join(txt)) 22 | 23 | def notify_order(self, order): 24 | txt = ["*" * 5, "ORDER NOTIF:", str(order)] 25 | print(", ".join(txt)) 26 | 27 | def notify_trade(self, trade): 28 | txt = ["*" * 5, "TRADE NOTIF:", str(trade)] 29 | print(", ".join(txt)) 30 | 31 | def next(self): 32 | 33 | if self.order: 34 | return 35 | 36 | # create a market order 37 | if self.p.order_type == bt.Order.Market: 38 | price = self.data.close[0] 39 | if self.p.use_brackets: 40 | if self.p.sell: 41 | self.order, _, _ = self.sell_bracket( 42 | size=1, exectype=bt.Order.Market, 43 | oargs={}, 44 | stopprice=price + 0.0002, 45 | stopexec=bt.Order.Stop, 46 | stopargs={}, 47 | limitprice=price - 0.0002, 48 | limitexec=bt.Order.Limit, 49 | limitargs={}) 50 | else: 51 | self.order, _, _ = self.buy_bracket( 52 | size=1, exectype=bt.Order.Market, 53 | oargs={}, 54 | stopprice=price - 0.0002, 55 | stopexec=bt.Order.Stop, 56 | stopargs={}, 57 | limitprice=price + 0.0002, 58 | limitexec=bt.Order.Limit, 59 | limitargs={}) 60 | else: 61 | if self.p.sell: 62 | self.order = self.sell(exectype=bt.Order.Market, size=1) 63 | else: 64 | self.order = self.buy(exectype=bt.Order.Market, size=1) 65 | elif self.p.order_type == bt.Order.Limit: 66 | price = self.data.close[0] 67 | if self.p.use_brackets: 68 | if self.p.sell: 69 | self.order, _, _ = self.sell_bracket( 70 | size=1, 71 | exectype=bt.Order.Limit, 72 | price=price, 73 | oargs={}, 74 | stopprice=price + 0.0005, 75 | stopexec=bt.Order.Stop, 76 | stopargs={}, 77 | limitprice=price - 0.0005, 78 | limitexec=bt.Order.Limit, 79 | limitargs={}) 80 | else: 81 | self.order, _, _ = self.buy_bracket( 82 | size=1, 83 | exectype=bt.Order.Limit, 84 | price=price, 85 | oargs={}, 86 | stopprice=price - 0.0005, 87 | stopexec=bt.Order.Stop, 88 | stopargs={}, 89 | limitprice=price + 0.0005, 90 | limitexec=bt.Order.Limit, 91 | limitargs={}) 92 | else: 93 | if self.p.sell: 94 | self.order = self.sell(exectype=bt.Order.Limit, price=price, size=1) 95 | else: 96 | self.order = self.buy(exectype=bt.Order.Limit, price=price, size=1) 97 | elif self.p.order_type == bt.Order.Stop: 98 | price = self.data.close[0] 99 | if self.p.use_brackets: 100 | if self.p.sell: 101 | self.order, _, _ = self.sell_bracket( 102 | size=1, 103 | exectype=bt.Order.Stop, 104 | price=price, 105 | oargs={}, 106 | stopprice=price + 0.0002, 107 | stopexec=bt.Order.Stop, 108 | stopargs={}, 109 | limitprice=price - 0.0002, 110 | limitexec=bt.Order.Limit, 111 | limitargs={}) 112 | else: 113 | self.order, _, _ = self.buy_bracket( 114 | size=1, 115 | exectype=bt.Order.Stop, 116 | price=price, 117 | oargs={}, 118 | stopprice=price - 0.0005, 119 | stopexec=bt.Order.Stop, 120 | stopargs={}, 121 | limitprice=price + 0.0005, 122 | limitexec=bt.Order.Limit, 123 | limitargs={}) 124 | else: 125 | if self.p.sell: 126 | self.order = self.sell(exectype=bt.Order.Stop, price=price, size=1) 127 | else: 128 | self.order = self.buy(exectype=bt.Order.Stop, price=price, size=1) 129 | 130 | 131 | with open("config.json", "r") as file: 132 | config = json.load(file) 133 | 134 | storekwargs = dict( 135 | token=config["oanda"]["token"], 136 | account=config["oanda"]["account"], 137 | practice=config["oanda"]["practice"], 138 | notif_transactions=True, 139 | stream_timeout=10, 140 | ) 141 | store = bto.stores.OandaV20Store(**storekwargs) 142 | datakwargs = dict( 143 | timeframe=bt.TimeFrame.Seconds, 144 | compression=30, 145 | tz='Europe/Berlin', 146 | backfill=False, 147 | backfill_start=False, 148 | ) 149 | data = store.getdata(dataname="EUR_USD", **datakwargs) 150 | data.resample( 151 | timeframe=bt.TimeFrame.Seconds, 152 | compression=30) # rightedge=True, boundoff=1) 153 | cerebro = bt.Cerebro() 154 | cerebro.adddata(data) 155 | cerebro.setbroker(store.getbroker()) 156 | cerebro.addstrategy(St) 157 | cerebro.run() 158 | -------------------------------------------------------------------------------- /btoandav20/stores/oandaposition.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8; py-indent-offset:4 -*- 3 | ############################################################################### 4 | # 5 | # Copyright (C) 2015, 2016, 2017 Daniel Rodriguez 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | # 20 | ############################################################################### 21 | from __future__ import (absolute_import, division, print_function, 22 | unicode_literals) 23 | 24 | 25 | from copy import copy 26 | from datetime import datetime 27 | 28 | 29 | class OandaPosition(object): 30 | ''' 31 | Keeps and updates the size and price of a position. The object has no 32 | relationship to any asset. It only keeps size and price. 33 | 34 | Member Attributes: 35 | - size (int): current size of the position 36 | - price (float): current price of the position 37 | 38 | The Position instances can be tested using len(position) to see if size 39 | is not null 40 | ''' 41 | 42 | def __str__(self): 43 | items = list() 44 | items.append('--- Position Begin') 45 | items.append('- Size: {}'.format(self.size)) 46 | items.append('- Price: {}'.format(self.price)) 47 | items.append('- Price orig: {}'.format(self.price_orig)) 48 | items.append('- Closed: {}'.format(self.upclosed)) 49 | items.append('- Opened: {}'.format(self.upopened)) 50 | items.append('- Adjbase: {}'.format(self.adjbase)) 51 | items.append('- Update: {}'.format(self.updt)) 52 | items.append('--- Position End') 53 | return '\n'.join(items) 54 | 55 | def __init__(self, size=0, price=0.0, dt=None): 56 | self.size = size 57 | if size: 58 | self.price = self.price_orig = price 59 | else: 60 | self.price = 0.0 61 | 62 | self.adjbase = None 63 | 64 | self.upopened = size 65 | self.upclosed = 0 66 | self.set(size, price) 67 | 68 | self.updt = dt or datetime.utcnow() 69 | 70 | def fix(self, size, price): 71 | oldsize = self.size 72 | self.size = size 73 | self.price = price 74 | return self.size == oldsize 75 | 76 | def set(self, size, price): 77 | if self.size > 0: 78 | if size > self.size: 79 | self.upopened = size - self.size # new 10 - old 5 -> 5 80 | self.upclosed = 0 81 | else: 82 | # same side min(0, 3) -> 0 / reversal min(0, -3) -> -3 83 | self.upopened = min(0, size) 84 | # same side min(10, 10 - 5) -> 5 85 | # reversal min(10, 10 - -5) -> min(10, 15) -> 10 86 | self.upclosed = min(self.size, self.size - size) 87 | 88 | elif self.size < 0: 89 | if size < self.size: 90 | self.upopened = size - self.size # ex: -5 - -3 -> -2 91 | self.upclosed = 0 92 | else: 93 | # same side max(0, -5) -> 0 / reversal max(0, 5) -> 5 94 | self.upopened = max(0, size) 95 | # same side max(-10, -10 - -5) -> max(-10, -5) -> -5 96 | # reversal max(-10, -10 - 5) -> max(-10, -15) -> -10 97 | self.upclosed = max(self.size, self.size - size) 98 | 99 | else: # self.size == 0 100 | self.upopened = self.size 101 | self.upclosed = 0 102 | 103 | self.size = size 104 | self.price_orig = self.price 105 | if size: 106 | self.price = price 107 | else: 108 | self.price = 0.0 109 | 110 | return self.size, self.price, self.upopened, self.upclosed 111 | 112 | def __len__(self): 113 | return abs(self.size) 114 | 115 | def __bool__(self): 116 | return bool(self.size != 0) 117 | 118 | __nonzero__ = __bool__ 119 | 120 | def clone(self): 121 | return OandaPosition(size=self.size, price=self.price) 122 | 123 | def pseudoupdate(self, size, price): 124 | return OandaPosition(self.size, self.price).update(size, price) 125 | 126 | def update(self, size, price, dt=None): 127 | ''' 128 | Updates the current position and returns the updated size, price and 129 | units used to open/close a position 130 | 131 | Args: 132 | size (int): amount to update the position size 133 | size < 0: A sell operation has taken place 134 | size > 0: A buy operation has taken place 135 | 136 | price (float): 137 | Must always be positive to ensure consistency 138 | 139 | Returns: 140 | A tuple (non-named) contaning 141 | size - new position size 142 | Simply the sum of the existing size plus the "size" argument 143 | price - new position price 144 | If a position is increased the new average price will be 145 | returned 146 | If a position is reduced the price of the remaining size 147 | does not change 148 | If a position is closed the price is nullified 149 | If a position is reversed the price is the price given as 150 | argument 151 | opened - amount of contracts from argument "size" that were used 152 | to open/increase a position. 153 | A position can be opened from 0 or can be a reversal. 154 | If a reversal is performed then opened is less than "size", 155 | because part of "size" will have been used to close the 156 | existing position 157 | closed - amount of units from arguments "size" that were used to 158 | close/reduce a position 159 | 160 | Both opened and closed carry the same sign as the "size" argument 161 | because they refer to a part of the "size" argument 162 | ''' 163 | self.updt = dt or datetime.utcnow() # record datetime update (datetime.datetime) 164 | 165 | self.price_orig = self.price 166 | oldsize = self.size 167 | self.size += size 168 | 169 | if not self.size: 170 | # Update closed existing position 171 | opened, closed = 0, size 172 | self.price = 0.0 173 | elif not oldsize: 174 | # Update opened a position from 0 175 | opened, closed = size, 0 176 | self.price = price 177 | elif oldsize > 0: # existing "long" position updated 178 | 179 | if size > 0: # increased position 180 | opened, closed = size, 0 181 | self.price = (self.price * oldsize + size * price) / self.size 182 | 183 | elif self.size > 0: # reduced position 184 | opened, closed = 0, size 185 | # self.price = self.price 186 | 187 | else: # self.size < 0 # reversed position form plus to minus 188 | opened, closed = self.size, -oldsize 189 | self.price = price 190 | 191 | else: # oldsize < 0 - existing short position updated 192 | 193 | if size < 0: # increased position 194 | opened, closed = size, 0 195 | self.price = (self.price * oldsize + size * price) / self.size 196 | 197 | elif self.size < 0: # reduced position 198 | opened, closed = 0, size 199 | # self.price = self.price 200 | 201 | else: # self.size > 0 - reversed position from minus to plus 202 | opened, closed = self.size, -oldsize 203 | self.price = price 204 | 205 | self.upopened = opened 206 | self.upclosed = closed 207 | 208 | return self.size, self.price, opened, closed 209 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /btoandav20/brokers/oandav20broker.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | import collections 5 | 6 | from backtrader import BrokerBase, Order, BuyOrder, SellOrder 7 | from backtrader.utils.py3 import with_metaclass 8 | from backtrader.position import Position 9 | from backtrader.comminfo import CommInfoBase 10 | 11 | from btoandav20.stores import oandav20store 12 | 13 | 14 | class OandaV20CommInfo(CommInfoBase): 15 | def getvaluesize(self, size, price): 16 | # In real life the margin approaches the price 17 | return abs(size) * price 18 | 19 | def getoperationcost(self, size, price): 20 | '''Returns the needed amount of cash an operation would cost''' 21 | # Same reasoning as above 22 | return abs(size) * price 23 | 24 | 25 | class MetaOandaV20Broker(BrokerBase.__class__): 26 | def __init__(self, name, bases, dct): 27 | '''Class has already been created ... register''' 28 | # Initialize the class 29 | super(MetaOandaV20Broker, self).__init__(name, bases, dct) 30 | oandav20store.OandaV20Store.BrokerCls = self 31 | 32 | 33 | class OandaV20Broker(with_metaclass(MetaOandaV20Broker, BrokerBase)): 34 | '''Broker implementation for Oanda v20. 35 | 36 | This class maps the orders/positions from Oanda to the 37 | internal API of ``backtrader``. 38 | 39 | Params: 40 | 41 | - ``use_positions`` (default:``True``): When connecting to the broker 42 | provider use the existing positions to kickstart the broker. 43 | 44 | Set to ``False`` during instantiation to disregard any existing 45 | position 46 | ''' 47 | params = dict( 48 | use_positions=True, 49 | ) 50 | 51 | def __init__(self, **kwargs): 52 | super(OandaV20Broker, self).__init__() 53 | self.o = oandav20store.OandaV20Store(**kwargs) 54 | 55 | self.orders = collections.OrderedDict() # orders by order id 56 | self.notifs = collections.deque() # holds orders which are notified 57 | 58 | self.opending = collections.defaultdict(list) # pending transmission 59 | self.brackets = dict() # confirmed brackets 60 | 61 | self.startingcash = self.cash = 0.0 62 | self.startingvalue = self.value = 0.0 63 | self.positions = collections.defaultdict(Position) 64 | 65 | def start(self): 66 | super(OandaV20Broker, self).start() 67 | self.o.start(broker=self) 68 | self.startingcash = self.cash = self.o.get_cash() 69 | self.startingvalue = self.value = self.o.get_value() 70 | comminfo = OandaV20CommInfo( 71 | leverage=self.o.get_leverage(), 72 | stocklike=False, 73 | commtype=CommInfoBase.COMM_FIXED) 74 | # set as default comminfo 75 | self.addcommissioninfo(comminfo, name=None) 76 | 77 | if self.p.use_positions: 78 | positions = self.o.get_positions() 79 | if positions is None: 80 | return 81 | for p in positions: 82 | size = float(p['long']['units']) + float(p['short']['units']) 83 | price = ( 84 | float(p['long']['averagePrice']) if size > 0 85 | else float(p['short']['averagePrice'])) 86 | self.positions[p['instrument']] = Position(size, price) 87 | 88 | def data_started(self, data): 89 | pos = self.getposition(data) 90 | 91 | if pos.size == 0: 92 | return 93 | 94 | if pos.size < 0: 95 | order = SellOrder(data=data, 96 | size=pos.size, price=pos.price, 97 | exectype=Order.Market, 98 | simulated=True) 99 | else: 100 | order = BuyOrder(data=data, 101 | size=pos.size, price=pos.price, 102 | exectype=Order.Market, 103 | simulated=True) 104 | 105 | order.addcomminfo(self.getcommissioninfo(data)) 106 | order.execute(0, pos.size, pos.price, 107 | 0, 0.0, 0.0, 108 | pos.size, 0.0, 0.0, 109 | 0.0, 0.0, 110 | pos.size, pos.price) 111 | 112 | order.completed() 113 | self.notify(order) 114 | 115 | def stop(self): 116 | super(OandaV20Broker, self).stop() 117 | self.o.stop() 118 | 119 | def getcash(self): 120 | # This call cannot block if no answer is available from oanda 121 | self.cash = cash = self.o.get_cash() 122 | return cash 123 | 124 | def getvalue(self, datas=None): 125 | self.value = self.o.get_value() 126 | return self.value 127 | 128 | def getposition(self, data, clone=True): 129 | # return self.o.getposition(data._dataname, clone=clone) 130 | pos = self.positions[data._dataname] 131 | if clone: 132 | pos = pos.clone() 133 | 134 | return pos 135 | 136 | def getserverposition(self,data,update_latest = False): 137 | poss = self.o.get_server_position(update_latest = update_latest) 138 | pos = poss[data._dataname] 139 | pos = pos.clone() 140 | return pos 141 | 142 | def orderstatus(self, order): 143 | o = self.orders[order.ref] 144 | return o.status 145 | 146 | def _submit(self, oref): 147 | order = self.orders[oref] 148 | order.submit() 149 | self.notify(order) 150 | 151 | def _reject(self, oref): 152 | order = self.orders[oref] 153 | order.reject() 154 | self.notify(order) 155 | 156 | def _accept(self, oref): 157 | order = self.orders[oref] 158 | order.accept() 159 | self.notify(order) 160 | 161 | def _cancel(self, oref): 162 | order = self.orders[oref] 163 | order.cancel() 164 | self.notify(order) 165 | 166 | def _expire(self, oref): 167 | order = self.orders[oref] 168 | order.expire() 169 | self.notify(order) 170 | 171 | def _bracketize(self, order): 172 | pref = getattr(order.parent, 'ref', order.ref) # parent ref or self 173 | br = self.brackets.pop(pref, None) # to avoid recursion 174 | if br is None: 175 | return 176 | 177 | if len(br) == 3: # all 3 orders in place, parent was filled 178 | br = br[1:] # discard index 0, parent 179 | for o in br: 180 | o and o.activate() # simulate activate for children 181 | self.brackets[pref] = br # not done - reinsert children 182 | 183 | elif len(br) == 2: # filling a children 184 | oidx = br.index(order) # find index to filled (0 or 1) 185 | self._cancel(br[1 - oidx].ref) # cancel remaining (1 - 0 -> 1) 186 | 187 | def _fill_external(self, data, size, price): 188 | if size == 0: 189 | return 190 | 191 | pos = self.getposition(data, clone=False) 192 | pos.update(size, price) 193 | 194 | if size < 0: 195 | order = SellOrder(data=data, 196 | size=size, price=price, 197 | exectype=Order.Market, 198 | simulated=True) 199 | else: 200 | order = BuyOrder(data=data, 201 | size=size, price=price, 202 | exectype=Order.Market, 203 | simulated=True) 204 | 205 | order.addcomminfo(self.getcommissioninfo(data)) 206 | order.execute(0, size, price, 207 | 0, 0.0, 0.0, 208 | size, 0.0, 0.0, 209 | 0.0, 0.0, 210 | size, price) 211 | 212 | order.completed() 213 | self.notify(order) 214 | 215 | def _fill(self, oref, size, price, reason, **kwargs): 216 | order = self.orders[oref] 217 | if not order.alive(): # can be a bracket 218 | pref = getattr(order.parent, 'ref', order.ref) 219 | if pref not in self.brackets: 220 | msg = ('Order fill received for {}, with price {} and size {} ' 221 | 'but order is no longer alive and is not a bracket. ' 222 | 'Unknown situation {}') 223 | msg = msg.format(order.ref, price, size, reason) 224 | self.o.put_notification(msg) 225 | return 226 | 227 | # [main, stopside, takeside], neg idx to array are -3, -2, -1 228 | if reason == 'STOP_LOSS_ORDER': 229 | order = self.brackets[pref][-2] 230 | elif reason == 'TRAILING_STOP_LOSS_ORDER': 231 | order = self.brackets[pref][-2] 232 | elif reason == 'TAKE_PROFIT_ORDER': 233 | order = self.brackets[pref][-1] 234 | else: 235 | msg = ('Order fill received for {}, with price {} and size {} ' 236 | 'but order is no longer alive and is a bracket. ' 237 | 'Unknown situation {}') 238 | msg = msg.format(order.ref, price, size, reason) 239 | self.o.put_notification(msg) 240 | return 241 | 242 | data = order.data 243 | pos = self.getposition(data, clone=False) 244 | psize, pprice, opened, closed = pos.update(size, price) 245 | 246 | closedvalue = closedcomm = 0.0 247 | openedvalue = openedcomm = 0.0 248 | margin = pnl = 0.0 249 | 250 | order.execute(data.datetime[0], size, price, 251 | closed, closedvalue, closedcomm, 252 | opened, openedvalue, openedcomm, 253 | margin, pnl, 254 | psize, pprice) 255 | 256 | if order.executed.remsize: 257 | order.partial() 258 | self.notify(order) 259 | else: 260 | order.completed() 261 | self.notify(order) 262 | self._bracketize(order) 263 | 264 | def _transmit(self, order): 265 | oref = order.ref 266 | pref = getattr(order.parent, 'ref', oref) # parent ref or self 267 | 268 | if order.transmit: 269 | if oref != pref: # children order 270 | # get pending orders, parent is needed, child may be None 271 | pending = self.opending.pop(pref) 272 | # ensure there are two items in list before unpacking 273 | while len(pending) < 2: 274 | pending.append(None) 275 | parent, child = pending 276 | # set takeside and stopside 277 | if order.exectype in [order.StopTrail, order.Stop]: 278 | stopside = order 279 | takeside = child 280 | else: 281 | takeside = order 282 | stopside = child 283 | for o in parent, stopside, takeside: 284 | if o is not None: 285 | self.orders[o.ref] = o # write them down 286 | self.brackets[pref] = [parent, stopside, takeside] 287 | self.o.order_create(parent, stopside, takeside) 288 | return takeside or stopside 289 | 290 | else: # Parent order, which is being transmitted 291 | self.orders[order.ref] = order 292 | return self.o.order_create(order) 293 | 294 | # Not transmitting 295 | self.opending[pref].append(order) 296 | return order 297 | 298 | def buy(self, owner, data, 299 | size, price=None, plimit=None, 300 | exectype=None, valid=None, tradeid=0, oco=None, 301 | trailamount=None, trailpercent=None, 302 | parent=None, transmit=True, 303 | **kwargs): 304 | 305 | order = BuyOrder(owner=owner, data=data, 306 | size=size, price=price, pricelimit=plimit, 307 | exectype=exectype, valid=valid, tradeid=tradeid, 308 | trailamount=trailamount, trailpercent=trailpercent, 309 | parent=parent, transmit=transmit) 310 | 311 | order.addinfo(**kwargs) 312 | order.addcomminfo(self.getcommissioninfo(data)) 313 | return self._transmit(order) 314 | 315 | def sell(self, owner, data, 316 | size, price=None, plimit=None, 317 | exectype=None, valid=None, tradeid=0, oco=None, 318 | trailamount=None, trailpercent=None, 319 | parent=None, transmit=True, 320 | **kwargs): 321 | 322 | order = SellOrder(owner=owner, data=data, 323 | size=size, price=price, pricelimit=plimit, 324 | exectype=exectype, valid=valid, tradeid=tradeid, 325 | trailamount=trailamount, trailpercent=trailpercent, 326 | parent=parent, transmit=transmit) 327 | 328 | order.addinfo(**kwargs) 329 | order.addcomminfo(self.getcommissioninfo(data)) 330 | return self._transmit(order) 331 | 332 | def cancel(self, order): 333 | o = self.orders[order.ref] 334 | if o.status == Order.Cancelled: # already cancelled 335 | return 336 | 337 | return self.o.order_cancel(o) 338 | 339 | def notify(self, order): 340 | self.notifs.append(order.clone()) 341 | 342 | def get_notification(self): 343 | if not self.notifs: 344 | return None 345 | 346 | return self.notifs.popleft() 347 | 348 | def next(self): 349 | self.notifs.append(None) # mark notification boundary 350 | -------------------------------------------------------------------------------- /examples/oandav20test/oandav20test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import (absolute_import, division, print_function, 4 | unicode_literals) 5 | 6 | import argparse 7 | import datetime 8 | 9 | import backtrader as bt 10 | from backtrader.utils import flushfile # win32 quick stdout flushing 11 | 12 | import btoandav20 13 | 14 | StoreCls = btoandav20.stores.OandaV20Store 15 | DataCls = btoandav20.feeds.OandaV20Data 16 | # BrokerCls = btoandav20.brokers.OandaV20Broker 17 | 18 | # available timeframes for oanda 19 | TIMEFRAMES = [bt.TimeFrame.Names[bt.TimeFrame.Seconds], 20 | bt.TimeFrame.Names[bt.TimeFrame.Minutes], 21 | bt.TimeFrame.Names[bt.TimeFrame.Days], 22 | bt.TimeFrame.Names[bt.TimeFrame.Weeks], 23 | bt.TimeFrame.Names[bt.TimeFrame.Months]] 24 | 25 | class TestStrategy(bt.Strategy): 26 | params = dict( 27 | smaperiod=5, 28 | trade=False, 29 | stake=10, 30 | exectype=bt.Order.Market, 31 | stopafter=0, 32 | valid=None, 33 | cancel=0, 34 | donotcounter=False, 35 | sell=False, 36 | usebracket=False, 37 | ) 38 | 39 | def __init__(self): 40 | # To control operation entries 41 | self.orderid = list() 42 | self.order = None 43 | 44 | self.counttostop = 0 45 | self.datastatus = 0 46 | 47 | # Create SMA on 2nd data 48 | self.sma = bt.indicators.MovAv.SMA(self.data, period=self.p.smaperiod) 49 | 50 | print('--------------------------------------------------') 51 | print('Strategy Created') 52 | print('--------------------------------------------------') 53 | 54 | def notify_data(self, data, status, *args, **kwargs): 55 | print('*' * 5, 'DATA NOTIF:', data._getstatusname(status), *args) 56 | if status == data.LIVE: 57 | self.counttostop = self.p.stopafter 58 | self.datastatus = 1 59 | 60 | def notify_store(self, msg, *args, **kwargs): 61 | print('*' * 5, 'STORE NOTIF:', msg) 62 | 63 | def notify_order(self, order): 64 | if order.status in [order.Completed, order.Cancelled, order.Rejected]: 65 | self.order = None 66 | 67 | print('-' * 50, 'ORDER BEGIN', datetime.datetime.now()) 68 | print(order) 69 | print('-' * 50, 'ORDER END') 70 | 71 | def notify_trade(self, trade): 72 | print('-' * 50, 'TRADE BEGIN', datetime.datetime.now()) 73 | print(trade) 74 | print('-' * 50, 'TRADE END') 75 | 76 | def prenext(self): 77 | self.next(frompre=True) 78 | 79 | def next(self, frompre=False): 80 | txt = list() 81 | txt.append('Data0') 82 | txt.append('%04d' % len(self.data0)) 83 | dtfmt = '%Y-%m-%dT%H:%M:%S.%f' 84 | txt.append('{:f}'.format(self.data.datetime[0])) 85 | txt.append('%s' % self.data.datetime.datetime(0).strftime(dtfmt)) 86 | txt.append('{:f}'.format(self.data.open[0])) 87 | txt.append('{:f}'.format(self.data.high[0])) 88 | txt.append('{:f}'.format(self.data.low[0])) 89 | txt.append('{:f}'.format(self.data.close[0])) 90 | txt.append('{:6d}'.format(int(self.data.volume[0]))) 91 | txt.append('{:d}'.format(int(self.data.openinterest[0]))) 92 | txt.append('{:f}'.format(self.sma[0])) 93 | print(', '.join(txt)) 94 | 95 | if len(self.datas) > 1 and len(self.data1): 96 | txt = list() 97 | txt.append('Data1') 98 | txt.append('%04d' % len(self.data1)) 99 | dtfmt = '%Y-%m-%dT%H:%M:%S.%f' 100 | txt.append('{}'.format(self.data1.datetime[0])) 101 | txt.append('%s' % self.data1.datetime.datetime(0).strftime(dtfmt)) 102 | txt.append('{}'.format(self.data1.open[0])) 103 | txt.append('{}'.format(self.data1.high[0])) 104 | txt.append('{}'.format(self.data1.low[0])) 105 | txt.append('{}'.format(self.data1.close[0])) 106 | txt.append('{}'.format(self.data1.volume[0])) 107 | txt.append('{}'.format(self.data1.openinterest[0])) 108 | txt.append('{}'.format(float('NaN'))) 109 | print(', '.join(txt)) 110 | 111 | if self.counttostop: # stop after x live lines 112 | self.counttostop -= 1 113 | if not self.counttostop: 114 | self.env.runstop() 115 | return 116 | 117 | if not self.p.trade: 118 | return 119 | 120 | if self.datastatus and not self.position and len(self.orderid) < 1: 121 | if not self.p.usebracket: 122 | if not self.p.sell: 123 | # price = round(self.data0.close[0] * 0.90, 2) 124 | price = self.data0.close[0] - 0.005 125 | self.order = self.buy(size=self.p.stake, 126 | exectype=self.p.exectype, 127 | price=price, 128 | valid=self.p.valid) 129 | else: 130 | # price = round(self.data0.close[0] * 1.10, 4) 131 | price = self.data0.close[0] - 0.05 132 | self.order = self.sell(size=self.p.stake, 133 | exectype=self.p.exectype, 134 | price=price, 135 | valid=self.p.valid) 136 | 137 | else: 138 | print('USING BRACKET') 139 | price = self.data0.close[0] - 0.05 140 | self.order, _, _ = self.buy_bracket(size=self.p.stake, 141 | exectype=bt.Order.Market, 142 | price=price, 143 | stopprice=price - 0.10, 144 | limitprice=price + 0.10, 145 | valid=self.p.valid) 146 | 147 | self.orderid.append(self.order) 148 | elif self.position and not self.p.donotcounter: 149 | if self.order is None: 150 | if not self.p.sell: 151 | self.order = self.sell(size=self.p.stake // 2, 152 | exectype=bt.Order.Market, 153 | price=self.data0.close[0]) 154 | else: 155 | self.order = self.buy(size=self.p.stake // 2, 156 | exectype=bt.Order.Market, 157 | price=self.data0.close[0]) 158 | 159 | self.orderid.append(self.order) 160 | 161 | elif self.order is not None and self.p.cancel: 162 | if self.datastatus > self.p.cancel: 163 | self.cancel(self.order) 164 | 165 | if self.datastatus: 166 | self.datastatus += 1 167 | 168 | def start(self): 169 | if self.data0.contractdetails is not None: 170 | print('-- Contract Details:') 171 | print(self.data0.contractdetails) 172 | 173 | header = ['Datetime', 'Open', 'High', 'Low', 'Close', 'Volume', 174 | 'OpenInterest', 'SMA'] 175 | print(', '.join(header)) 176 | 177 | self.done = False 178 | 179 | 180 | def runstrategy(): 181 | args = parse_args() 182 | 183 | # Create a cerebro 184 | cerebro = bt.Cerebro() 185 | 186 | storekwargs = dict( 187 | token=args.token, 188 | account=args.account, 189 | practice=not args.live 190 | ) 191 | 192 | if not args.no_store: 193 | store = StoreCls(**storekwargs) 194 | 195 | if args.broker: 196 | if args.no_store: 197 | broker = BrokerCls(**storekwargs) 198 | else: 199 | broker = store.getbroker() 200 | 201 | cerebro.setbroker(broker) 202 | 203 | timeframe = bt.TimeFrame.TFrame(args.timeframe) 204 | # Manage data1 parameters 205 | tf1 = args.timeframe1 206 | tf1 = bt.TimeFrame.TFrame(tf1) if tf1 is not None else timeframe 207 | cp1 = args.compression1 208 | cp1 = cp1 if cp1 is not None else args.compression 209 | 210 | if args.resample or args.replay: 211 | datatf = datatf1 = bt.TimeFrame.Ticks 212 | datacomp = datacomp1 = 1 213 | else: 214 | datatf = timeframe 215 | datacomp = args.compression 216 | datatf1 = tf1 217 | datacomp1 = cp1 218 | 219 | fromdate = None 220 | if args.fromdate: 221 | dtformat = '%Y-%m-%d' + ('T%H:%M:%S' * ('T' in args.fromdate)) 222 | fromdate = datetime.datetime.strptime(args.fromdate, dtformat) 223 | 224 | DataFactory = DataCls if args.no_store else store.getdata 225 | 226 | datakwargs = dict( 227 | timeframe=datatf, compression=datacomp, 228 | qcheck=args.qcheck, 229 | historical=args.historical, 230 | fromdate=fromdate, 231 | bidask=args.bidask, 232 | useask=args.useask, 233 | backfill_start=not args.no_backfill_start, 234 | backfill=not args.no_backfill, 235 | tz=args.timezone 236 | ) 237 | 238 | if args.no_store and not args.broker: # neither store nor broker 239 | datakwargs.update(storekwargs) # pass the store args over the data 240 | 241 | data0 = DataFactory(dataname=args.data0, **datakwargs) 242 | 243 | data1 = None 244 | if args.data1 is not None: 245 | if args.data1 != args.data0: 246 | datakwargs['timeframe'] = datatf1 247 | datakwargs['compression'] = datacomp1 248 | data1 = DataFactory(dataname=args.data1, **datakwargs) 249 | else: 250 | data1 = data0 251 | 252 | rekwargs = dict( 253 | timeframe=timeframe, compression=args.compression, 254 | bar2edge=not args.no_bar2edge, 255 | adjbartime=not args.no_adjbartime, 256 | rightedge=not args.no_rightedge, 257 | takelate=not args.no_takelate, 258 | ) 259 | 260 | if args.replay: 261 | cerebro.replaydata(data0, **rekwargs) 262 | 263 | if data1 is not None: 264 | rekwargs['timeframe'] = tf1 265 | rekwargs['compression'] = cp1 266 | cerebro.replaydata(data1, **rekwargs) 267 | 268 | elif args.resample: 269 | cerebro.resampledata(data0, **rekwargs) 270 | 271 | if data1 is not None: 272 | rekwargs['timeframe'] = tf1 273 | rekwargs['compression'] = cp1 274 | cerebro.resampledata(data1, **rekwargs) 275 | 276 | else: 277 | cerebro.adddata(data0) 278 | if data1 is not None: 279 | cerebro.adddata(data1) 280 | 281 | if args.valid is None: 282 | valid = None 283 | else: 284 | valid = datetime.timedelta(seconds=args.valid) 285 | # Add the strategy 286 | cerebro.addstrategy(TestStrategy, 287 | smaperiod=args.smaperiod, 288 | trade=args.trade, 289 | exectype=bt.Order.ExecType(args.exectype), 290 | stake=args.stake, 291 | stopafter=args.stopafter, 292 | valid=valid, 293 | cancel=args.cancel, 294 | donotcounter=args.donotcounter, 295 | sell=args.sell, 296 | usebracket=args.usebracket) 297 | 298 | # Live data ... avoid long data accumulation by switching to "exactbars" 299 | cerebro.run(exactbars=args.exactbars) 300 | if args.exactbars < 1: # plotting is possible 301 | if args.plot: 302 | pkwargs = dict(style='line') 303 | if args.plot is not True: # evals to True but is not True 304 | npkwargs = eval('dict(' + args.plot + ')') # args were passed 305 | pkwargs.update(npkwargs) 306 | 307 | cerebro.plot(**pkwargs) 308 | 309 | 310 | def parse_args(pargs=None): 311 | parser = argparse.ArgumentParser( 312 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 313 | description='Test Oanda v20 integration') 314 | 315 | parser.add_argument('--exactbars', default=1, type=int, 316 | required=False, action='store', 317 | help='exactbars level, use 0/-1/-2 to enable plotting') 318 | 319 | parser.add_argument('--stopafter', default=0, type=int, 320 | required=False, action='store', 321 | help='Stop after x lines of LIVE data') 322 | 323 | parser.add_argument('--no-store', 324 | required=False, action='store_true', 325 | help='Do not use the store pattern') 326 | 327 | parser.add_argument('--debug', 328 | required=False, action='store_true', 329 | help='Display all info received from source') 330 | 331 | parser.add_argument('--token', default=None, 332 | required=True, action='store', 333 | help='Access token to use') 334 | 335 | parser.add_argument('--account', default=None, 336 | required=True, action='store', 337 | help='Account identifier to use') 338 | 339 | parser.add_argument('--live', default=None, 340 | required=False, action='store', 341 | help='Go to live server rather than practice') 342 | 343 | parser.add_argument('--qcheck', default=0.5, type=float, 344 | required=False, action='store', 345 | help=('Timeout for periodic ' 346 | 'notification/resampling/replaying check')) 347 | 348 | parser.add_argument('--data0', default=None, 349 | required=True, action='store', 350 | help='data 0 into the system') 351 | 352 | parser.add_argument('--data1', default=None, 353 | required=False, action='store', 354 | help='data 1 into the system') 355 | 356 | parser.add_argument('--timezone', default=None, 357 | required=False, action='store', 358 | help='timezone to get time output into (pytz names)') 359 | 360 | parser.add_argument('--bidask', default=None, 361 | required=False, action='store_true', 362 | help='Use bidask ... if False use midpoint') 363 | 364 | parser.add_argument('--useask', default=None, 365 | required=False, action='store_true', 366 | help='Use the "ask" of bidask prices/streaming') 367 | 368 | parser.add_argument('--no-backfill_start', 369 | required=False, action='store_true', 370 | help='Disable backfilling at the start') 371 | 372 | parser.add_argument('--no-backfill', 373 | required=False, action='store_true', 374 | help='Disable backfilling after a disconnection') 375 | 376 | parser.add_argument('--historical', 377 | required=False, action='store_true', 378 | help='do only historical download') 379 | 380 | parser.add_argument('--fromdate', 381 | required=False, action='store', 382 | help=('Starting date for historical download ' 383 | 'with format: YYYY-MM-DD[THH:MM:SS]')) 384 | 385 | parser.add_argument('--smaperiod', default=5, type=int, 386 | required=False, action='store', 387 | help='Period to apply to the Simple Moving Average') 388 | 389 | pgroup = parser.add_mutually_exclusive_group(required=False) 390 | 391 | pgroup.add_argument('--replay', 392 | required=False, action='store_true', 393 | help='replay to chosen timeframe') 394 | 395 | pgroup.add_argument('--resample', 396 | required=False, action='store_true', 397 | help='resample to chosen timeframe') 398 | 399 | parser.add_argument('--timeframe', default=TIMEFRAMES[0], 400 | choices=TIMEFRAMES, 401 | required=False, action='store', 402 | help='TimeFrame for Resample/Replay') 403 | 404 | parser.add_argument('--compression', default=5, type=int, 405 | required=False, action='store', 406 | help='Compression for Resample/Replay') 407 | 408 | parser.add_argument('--timeframe1', default=None, 409 | choices=TIMEFRAMES, 410 | required=False, action='store', 411 | help='TimeFrame for Resample/Replay - Data1') 412 | 413 | parser.add_argument('--compression1', default=None, type=int, 414 | required=False, action='store', 415 | help='Compression for Resample/Replay - Data1') 416 | 417 | parser.add_argument('--no-takelate', 418 | required=False, action='store_true', 419 | help=('resample/replay, do not accept late samples')) 420 | 421 | parser.add_argument('--no-bar2edge', 422 | required=False, action='store_true', 423 | help='no bar2edge for resample/replay') 424 | 425 | parser.add_argument('--no-adjbartime', 426 | required=False, action='store_true', 427 | help='no adjbartime for resample/replay') 428 | 429 | parser.add_argument('--no-rightedge', 430 | required=False, action='store_true', 431 | help='no rightedge for resample/replay') 432 | 433 | parser.add_argument('--broker', 434 | required=False, action='store_true', 435 | help='Use Oanda as broker') 436 | 437 | parser.add_argument('--trade', 438 | required=False, action='store_true', 439 | help='Do Sample Buy/Sell operations') 440 | 441 | parser.add_argument('--sell', 442 | required=False, action='store_true', 443 | help='Start by selling') 444 | 445 | parser.add_argument('--usebracket', 446 | required=False, action='store_true', 447 | help='Test buy_bracket') 448 | 449 | parser.add_argument('--donotcounter', 450 | required=False, action='store_true', 451 | help='Do not counter the 1st operation') 452 | 453 | parser.add_argument('--exectype', default=bt.Order.ExecTypes[0], 454 | choices=bt.Order.ExecTypes, 455 | required=False, action='store', 456 | help='Execution to Use when opening position') 457 | 458 | parser.add_argument('--stake', default=10, type=int, 459 | required=False, action='store', 460 | help='Stake to use in buy operations') 461 | 462 | parser.add_argument('--valid', default=None, type=float, 463 | required=False, action='store', 464 | help='Seconds to keep the order alive (0 means DAY)') 465 | 466 | parser.add_argument('--cancel', default=0, type=int, 467 | required=False, action='store', 468 | help=('Cancel a buy order after n bars in operation,' 469 | ' to be combined with orders like Limit')) 470 | 471 | # Plot options 472 | parser.add_argument('--plot', '-p', nargs='?', required=False, 473 | metavar='kwargs', const=True, 474 | help=('Plot the read data applying any kwargs passed\n' 475 | '\n' 476 | 'For example (escape the quotes if needed):\n' 477 | '\n' 478 | ' --plot style="candle" (to plot candles)\n')) 479 | 480 | if pargs is not None: 481 | return parser.parse_args(pargs) 482 | 483 | return parser.parse_args() 484 | 485 | 486 | if __name__ == '__main__': 487 | runstrategy() 488 | -------------------------------------------------------------------------------- /btoandav20/feeds/oandav20feed.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | from datetime import datetime, timedelta, timezone, time 5 | 6 | import time as _time 7 | import threading 8 | 9 | from backtrader.feed import DataBase 10 | from backtrader import TimeFrame, date2num, num2date 11 | from backtrader.utils.py3 import queue, with_metaclass 12 | 13 | from btoandav20.stores import oandav20store 14 | 15 | 16 | class MetaOandaV20Data(DataBase.__class__): 17 | def __init__(self, name, bases, dct): 18 | '''Class has already been created ... register''' 19 | # Initialize the class 20 | super(MetaOandaV20Data, self).__init__(name, bases, dct) 21 | 22 | # Register with the store 23 | oandav20store.OandaV20Store.DataCls = self 24 | 25 | 26 | class OandaV20Data(with_metaclass(MetaOandaV20Data, DataBase)): 27 | 28 | '''Oanda v20 Data Feed. 29 | 30 | Params: 31 | 32 | - ``qcheck`` (default: ``0.5``) 33 | 34 | Time in seconds to wake up if no data is received to give a chance to 35 | resample/replay packets properly and pass notifications up the chain 36 | 37 | - ``historical`` (default: ``False``) 38 | 39 | If set to ``True`` the data feed will stop after doing the first 40 | download of data. 41 | 42 | The standard data feed parameters ``fromdate`` and ``todate`` will be 43 | used as reference. 44 | 45 | The data feed will make multiple requests if the requested duration is 46 | larger than the one allowed by IB given the timeframe/compression 47 | chosen for the data. 48 | 49 | - ``backfill_start`` (default: ``True``) 50 | 51 | Perform backfilling at the start. The maximum possible historical data 52 | will be fetched in a single request. 53 | 54 | - ``backfill`` (default: ``True``) 55 | 56 | Perform backfilling after a disconnection/reconnection cycle. The gap 57 | duration will be used to download the smallest possible amount of data 58 | 59 | - ``backfill_from`` (default: ``None``) 60 | 61 | An additional data source can be passed to do an initial layer of 62 | backfilling. Once the data source is depleted and if requested, 63 | backfilling from oanda will take place. This is ideally meant to 64 | backfill from already stored sources like a file on disk, but not 65 | limited to. 66 | 67 | - ``bidask`` (default: ``True``) 68 | 69 | If ``True``, then the historical/backfilling requests will request 70 | bid/ask prices from the server 71 | 72 | If ``False``, then *midpoint* will be requested 73 | 74 | - ``useask`` (default: ``False``) 75 | 76 | If ``True`` the *ask* part of the *bidask* prices will be used instead 77 | of the default use of *bid* 78 | 79 | - ``reconnect`` (default: ``True``) 80 | 81 | Reconnect when network connection is down 82 | 83 | - ``reconnections`` (default: ``-1``) 84 | 85 | Number of times to attempt reconnections: ``-1`` means forever 86 | 87 | - ``candles`` (default: ``False``) 88 | 89 | Return candles instead of streaming for current data, granularity 90 | needs to be higher than Ticks 91 | 92 | - ``candles_delay`` (default: ``1``) 93 | 94 | The delay in seconds when new candles should be fetched after a 95 | period is over 96 | 97 | 98 | - ``adjstarttime`` (default: ``False``) 99 | 100 | Allows to adjust the start time of a candle to the end of the 101 | candles period. This only affects backfill data and live data 102 | if candles=True 103 | 104 | 105 | This data feed supports only this mapping of ``timeframe`` and 106 | ``compression``, which comply with the definitions in the OANDA API 107 | Developer's Guide: 108 | 109 | (TimeFrame.Seconds, 5): 'S5', 110 | (TimeFrame.Seconds, 10): 'S10', 111 | (TimeFrame.Seconds, 15): 'S15', 112 | (TimeFrame.Seconds, 30): 'S30', 113 | (TimeFrame.Minutes, 1): 'M1', 114 | (TimeFrame.Minutes, 2): 'M3', 115 | (TimeFrame.Minutes, 3): 'M3', 116 | (TimeFrame.Minutes, 4): 'M4', 117 | (TimeFrame.Minutes, 5): 'M5', 118 | (TimeFrame.Minutes, 10): 'M10', 119 | (TimeFrame.Minutes, 15): 'M15', 120 | (TimeFrame.Minutes, 30): 'M30', 121 | (TimeFrame.Minutes, 60): 'H1', 122 | (TimeFrame.Minutes, 120): 'H2', 123 | (TimeFrame.Minutes, 180): 'H3', 124 | (TimeFrame.Minutes, 240): 'H4', 125 | (TimeFrame.Minutes, 360): 'H6', 126 | (TimeFrame.Minutes, 480): 'H8', 127 | (TimeFrame.Days, 1): 'D', 128 | (TimeFrame.Weeks, 1): 'W', 129 | (TimeFrame.Months, 1): 'M', 130 | 131 | Any other combination will be rejected 132 | ''' 133 | 134 | lines = ('mid_close', 'bid_close', 'ask_close',) 135 | 136 | params = dict( 137 | qcheck=0.5, 138 | historical=False, # do backfilling at the start 139 | backfill_start=True, # do backfilling at the start 140 | backfill=True, # do backfilling when reconnecting 141 | backfill_from=None, # additional data source to do backfill from 142 | bidask=True, 143 | useask=False, 144 | candles=False, 145 | candles_delay=1, 146 | adjstarttime=False, # adjust start time of candle 147 | # TODO readd tmout - set timeout in store 148 | reconnect=True, 149 | reconnections=-1, # forever 150 | ) 151 | 152 | _store = oandav20store.OandaV20Store 153 | 154 | # States for the Finite State Machine in _load 155 | _ST_FROM, _ST_START, _ST_LIVE, _ST_HISTORBACK, _ST_OVER = range(5) 156 | 157 | def islive(self): 158 | '''Returns ``True`` to notify ``Cerebro`` that preloading and runonce 159 | should be deactivated''' 160 | return True 161 | 162 | def __init__(self, **kwargs): 163 | self.o = self._store(**kwargs) 164 | self._candleFormat = 'ABM' 165 | 166 | def setenvironment(self, env): 167 | '''Receives an environment (cerebro) and passes it over to the store it 168 | belongs to''' 169 | super(OandaV20Data, self).setenvironment(env) 170 | env.addstore(self.o) 171 | 172 | def start(self): 173 | '''Starts the Oanda connection and gets the real contract and 174 | contractdetails if it exists''' 175 | super(OandaV20Data, self).start() 176 | 177 | # create attributes as soon as possible 178 | self._statelivereconn = False # if reconnecting in live state 179 | self._storedmsg = dict() # keep pending live message (under None) 180 | self.qlive = queue.Queue() 181 | self._state = self._ST_OVER 182 | self._reconns = self.p.reconnections 183 | self.contractdetails = None 184 | 185 | # kickstart store and get queue to wait on 186 | self.o.start(data=self) 187 | 188 | # check if the granularity is supported 189 | otf = self.o.get_granularity(self._timeframe, self._compression) 190 | if otf is None: 191 | self.put_notification(self.NOTSUPPORTED_TF) 192 | self._state = self._ST_OVER 193 | return 194 | 195 | self.contractdetails = cd = self.o.get_instrument(self.p.dataname) 196 | if cd is None: 197 | self.put_notification(self.NOTSUBSCRIBED) 198 | self._state = self._ST_OVER 199 | return 200 | 201 | if self.p.backfill_from is not None: 202 | self._state = self._ST_FROM 203 | self._st_start(True) 204 | self.p.backfill_from.setenvironment(self._env) 205 | self.p.backfill_from._start() 206 | else: 207 | self._start_finish() 208 | self._state = self._ST_START # initial state for _load 209 | self._st_start() 210 | 211 | self._reconns = 0 212 | 213 | def _st_start(self, instart=True): 214 | if self.p.historical: 215 | self.put_notification(self.DELAYED) 216 | dtend = None 217 | if self.todate < float('inf'): 218 | dtend = num2date(self.todate) 219 | 220 | dtbegin = None 221 | if self.fromdate > float('-inf'): 222 | dtbegin = num2date(self.fromdate, tz=timezone.utc) 223 | 224 | self.qhist = self.o.candles( 225 | self.p.dataname, dtbegin, dtend, 226 | self._timeframe, self._compression, 227 | candleFormat=self._candleFormat, 228 | includeFirst=True) 229 | 230 | self._state = self._ST_HISTORBACK 231 | return True 232 | 233 | # depending on candles, either stream or use poll 234 | if instart: 235 | self._statelivereconn = self.p.backfill_start 236 | else: 237 | self._statelivereconn = self.p.backfill 238 | if self._statelivereconn: 239 | self.put_notification(self.DELAYED) 240 | if not self.p.candles: 241 | # recreate a new stream on call 242 | self.qlive = self.o.streaming_prices( 243 | self.p.dataname) 244 | elif instart: 245 | # poll thread will never die, so no need to recreate it 246 | self.poll_thread() 247 | self._state = self._ST_LIVE 248 | return True # no return before - implicit continue 249 | 250 | def poll_thread(self): 251 | t = threading.Thread(target=self._t_poll) 252 | t.daemon = True 253 | t.start() 254 | 255 | def _t_poll(self): 256 | dtstart = self._getstarttime( 257 | self._timeframe, 258 | self._compression, 259 | offset=1) 260 | while True: 261 | dtcurr = self._getstarttime(self._timeframe, self._compression) 262 | # request candles in live instead of stream 263 | if dtcurr > dtstart: 264 | if len(self) > 1: 265 | # len == 1 ... forwarded for the 1st time 266 | dtbegin = self.datetime.datetime(-1) 267 | elif self.fromdate > float('-inf'): 268 | dtbegin = num2date(self.fromdate, tz=timezone.utc) 269 | else: # 1st bar and no begin set 270 | dtbegin = dtstart 271 | self.qlive = self.o.candles( 272 | self.p.dataname, dtbegin, None, 273 | self._timeframe, self._compression, 274 | candleFormat=self._candleFormat, 275 | onlyComplete=True, 276 | includeFirst=False) 277 | dtstart = dtbegin 278 | # sleep until next call 279 | dtnow = datetime.utcnow() 280 | dtnext = self._getstarttime( 281 | self._timeframe, 282 | self._compression, 283 | dt=dtnow, 284 | offset=-1) 285 | dtdiff = dtnext - dtnow 286 | tmout = (dtdiff.days * 24 * 60 * 60) + \ 287 | dtdiff.seconds + self.p.candles_delay 288 | if tmout <= 0: 289 | tmout = self.p.candles_delay 290 | _time.sleep(tmout) 291 | 292 | def _getstarttime(self, timeframe, compression, dt=None, offset=0): 293 | ''' 294 | This method will return the start of the period based on current 295 | time (or provided time). 296 | ''' 297 | sessionstart = self.p.sessionstart 298 | if sessionstart is None: 299 | # use UTC 22:00 (5:00 pm New York) as default 300 | sessionstart = time(hour=22, minute=0, second=0) 301 | if dt is None: 302 | dt = datetime.utcnow() 303 | if timeframe == TimeFrame.Seconds: 304 | dt = dt.replace( 305 | second=(dt.second // compression) * compression, 306 | microsecond=0) 307 | if offset: 308 | dt = dt - timedelta(seconds=compression*offset) 309 | elif timeframe == TimeFrame.Minutes: 310 | if compression >= 60: 311 | hours = 0 312 | minutes = 0 313 | # get start of day 314 | dtstart = self._getstarttime(TimeFrame.Days, 1, dt) 315 | # diff start of day with current time to get seconds 316 | # since start of day 317 | dtdiff = dt - dtstart 318 | hours = dtdiff.seconds//((60*60)*(compression//60)) 319 | minutes = compression % 60 320 | dt = dtstart + timedelta(hours=hours, minutes=minutes) 321 | else: 322 | dt = dt.replace( 323 | minute=(dt.minute // compression) * compression, 324 | second=0, 325 | microsecond=0) 326 | if offset: 327 | dt = dt - timedelta(minutes=compression*offset) 328 | elif timeframe == TimeFrame.Days: 329 | if dt.hour < sessionstart.hour: 330 | dt = dt - timedelta(days=1) 331 | if offset: 332 | dt = dt - timedelta(days=offset) 333 | dt = dt.replace( 334 | hour=sessionstart.hour, 335 | minute=sessionstart.minute, 336 | second=sessionstart.second, 337 | microsecond=sessionstart.microsecond) 338 | elif timeframe == TimeFrame.Weeks: 339 | if dt.weekday() != 6: 340 | # sunday is start of week at 5pm new york 341 | dt = dt - timedelta(days=dt.weekday() + 1) 342 | if offset: 343 | dt = dt - timedelta(days=offset * 7) 344 | dt = dt.replace( 345 | hour=sessionstart.hour, 346 | minute=sessionstart.minute, 347 | second=sessionstart.second, 348 | microsecond=sessionstart.microsecond) 349 | elif timeframe == TimeFrame.Months: 350 | if offset: 351 | dt = dt - timedelta(days=(min(28 + dt.day, 31))) 352 | # last day of month 353 | last_day_of_month = dt.replace(day=28) + timedelta(days=4) 354 | last_day_of_month = last_day_of_month - timedelta( 355 | days=last_day_of_month.day) 356 | last_day_of_month = last_day_of_month.day 357 | # start of month (1 at 0, 22 last day of prev month) 358 | if dt.day < last_day_of_month: 359 | dt = dt - timedelta(days=dt.day) 360 | dt = dt.replace( 361 | hour=sessionstart.hour, 362 | minute=sessionstart.minute, 363 | second=sessionstart.second, 364 | microsecond=sessionstart.microsecond) 365 | return dt 366 | 367 | def stop(self): 368 | ''' 369 | Stops and tells the store to stop 370 | ''' 371 | super(OandaV20Data, self).stop() 372 | self.o.stop() 373 | 374 | def replay(self, **kwargs): 375 | # save original timeframe and compression to fetch data 376 | # they will be overriden when calling replay 377 | orig_timeframe = self._timeframe 378 | orig_compression = self._compression 379 | # setting up replay configuration 380 | super(DataBase, self).replay(**kwargs) 381 | # putting back original timeframe and compression to fetch correct data 382 | # the replay configuration will still use the correct dataframe and 383 | # compression for strategy 384 | self._timeframe = orig_timeframe 385 | self._compression = orig_compression 386 | 387 | def haslivedata(self): 388 | return bool(self._storedmsg or self.qlive) # do not return the objs 389 | 390 | def _load(self): 391 | if self._state == self._ST_OVER: 392 | return False 393 | 394 | while True: 395 | if self._state == self._ST_LIVE: 396 | try: 397 | msg = (self._storedmsg.pop(None, None) or 398 | self.qlive.get(timeout=self._qcheck)) 399 | except queue.Empty: 400 | return None 401 | 402 | if 'msg' in msg: 403 | self.put_notification(self.CONNBROKEN) 404 | 405 | if not self.p.reconnect or self._reconns == 0: 406 | # Can no longer reconnect 407 | self.put_notification(self.DISCONNECTED) 408 | self._state = self._ST_OVER 409 | return False # failed 410 | # sleep only on reconnect 411 | if self._reconns != self.p.reconnections: 412 | _time.sleep(self.o.p.reconntimeout) 413 | # Can reconnect 414 | self._reconns -= 1 415 | self._st_start(instart=False) 416 | continue 417 | 418 | self._reconns = self.p.reconnections 419 | 420 | # Process the message according to expected return type 421 | if not self._statelivereconn: 422 | if self._laststatus != self.LIVE: 423 | if self.qlive.qsize() <= 1: # very short live queue 424 | self.put_notification(self.LIVE) 425 | if msg: 426 | if self.p.candles: 427 | ret = self._load_candle(msg) 428 | else: 429 | ret = self._load_tick(msg) 430 | if ret: 431 | return True 432 | 433 | # could not load bar ... go and get new one 434 | continue 435 | 436 | # Fall through to processing reconnect - try to backfill 437 | self._storedmsg[None] = msg # keep the msg 438 | 439 | # else do a backfill 440 | if self._laststatus != self.DELAYED: 441 | self.put_notification(self.DELAYED) 442 | 443 | dtend = None 444 | if len(self) > 1: 445 | # len == 1 ... forwarded for the 1st time 446 | dtbegin = self.datetime.datetime( 447 | -1).astimezone(timezone.utc) 448 | elif self.fromdate > float('-inf'): 449 | dtbegin = num2date(self.fromdate, tz=timezone.utc) 450 | else: # 1st bar and no begin set 451 | # passing None to fetch max possible in 1 request 452 | dtbegin = None 453 | if msg: 454 | dtend = datetime.utcfromtimestamp(float(msg['time'])) 455 | 456 | # TODO not sure if incomplete candles may destruct something 457 | self.qhist = self.o.candles( 458 | self.p.dataname, dtbegin, dtend, 459 | self._timeframe, self._compression, 460 | candleFormat=self._candleFormat, 461 | includeFirst=True, onlyComplete=False) 462 | 463 | self._state = self._ST_HISTORBACK 464 | self._statelivereconn = False # no longer in live 465 | continue 466 | 467 | elif self._state == self._ST_HISTORBACK: 468 | msg = self.qhist.get() 469 | if msg is None: 470 | continue 471 | 472 | elif 'msg' in msg: # Error 473 | if not self.p.reconnect or self._reconns == 0: 474 | # Can no longer reconnect 475 | self.put_notification(self.DISCONNECTED) 476 | self._state = self._ST_OVER 477 | return False # failed 478 | 479 | # Can reconnect 480 | self._reconns -= 1 481 | self._st_start(instart=False) 482 | continue 483 | 484 | if msg: 485 | if self._load_candle(msg): 486 | return True # loading worked 487 | continue # not loaded ... date may have been seen 488 | else: 489 | # End of histdata 490 | if self.p.historical: # only historical 491 | self.put_notification(self.DISCONNECTED) 492 | self._state = self._ST_OVER 493 | return False # end of historical 494 | 495 | # Live is also wished - go for it 496 | self._state = self._ST_LIVE 497 | continue 498 | 499 | elif self._state == self._ST_FROM: 500 | if not self.p.backfill_from.next(): 501 | # additional data source is consumed 502 | self._state = self._ST_START 503 | continue 504 | 505 | # copy lines of the same name 506 | for alias in self.l.getlinealiases(): 507 | lsrc = getattr(self.p.backfill_from.lines, alias) 508 | ldst = getattr(self.lines, alias) 509 | 510 | ldst[0] = lsrc[0] 511 | 512 | return True 513 | 514 | elif self._state == self._ST_START: 515 | if not self._st_start(instart=False): 516 | self._state = self._ST_OVER 517 | return False 518 | 519 | def _load_tick(self, msg): 520 | dtobj = datetime.utcfromtimestamp(float(msg['time'])) 521 | dt = date2num(dtobj) 522 | if dt <= self.l.datetime[-1]: 523 | return False # time already seen 524 | 525 | # common fields 526 | self.l.datetime[0] = dt 527 | self.l.volume[0] = 0.0 528 | self.l.openinterest[0] = 0.0 529 | 530 | # put the prices into the bar 531 | price = {} 532 | price['ask'] = float(msg['asks'][0]['price']) 533 | price['bid'] = float(msg['bids'][0]['price']) 534 | price['mid'] = round( 535 | (price['bid'] + price['ask']) / 2, 536 | self.contractdetails['displayPrecision']) 537 | if self.p.bidask: 538 | if self.p.useask: 539 | price[None] = 'ask' 540 | else: 541 | price[None] = 'bid' 542 | else: 543 | price[None] = 'mid' 544 | for t in ['open', 'high', 'low', 'close']: 545 | getattr(self.l, t)[0] = price[price[None]] 546 | for x in ['mid', 'bid', 'ask']: 547 | getattr(self.l, f'{x}_close')[0] = price[x] 548 | 549 | self.l.volume[0] = 0.0 550 | self.l.openinterest[0] = 0.0 551 | 552 | return True 553 | 554 | def _load_candle(self, msg): 555 | dtobj = datetime.utcfromtimestamp(float(msg['time'])) 556 | if self.p.adjstarttime: 557 | # move time to start time of next candle 558 | # and subtract 0.1 miliseconds (ensures no 559 | # rounding issues, 10 microseconds is minimum) 560 | dtobj = self._getstarttime( 561 | self.p.timeframe, 562 | self.p.compression, 563 | dtobj, 564 | -1) - timedelta(microseconds=100) 565 | dt = date2num(dtobj) 566 | if dt <= self.l.datetime[-1]: 567 | return False # time already seen 568 | 569 | # common fields 570 | self.l.datetime[0] = dt 571 | self.l.volume[0] = float(msg['volume']) 572 | self.l.openinterest[0] = 0.0 573 | 574 | # put the prices into the bar 575 | price = {'bid': {}, 'ask': {}, 'mid': {}} 576 | for price_side in price: 577 | price[price_side]['open'] = msg[price_side]['o'] 578 | price[price_side]['high'] = msg[price_side]['h'] 579 | price[price_side]['low'] = msg[price_side]['l'] 580 | price[price_side]['close'] = msg[price_side]['c'] 581 | 582 | # select default price side for ohlc values 583 | if self.p.bidask: 584 | if not self.p.useask: 585 | price[None] = price['bid'] 586 | else: 587 | price[None] = price['ask'] 588 | else: 589 | price[None] = price['mid'] 590 | # set ohlc values 591 | for x in price[None]: 592 | getattr(self.l, x)[0] = price[None][x] 593 | # set all close values 594 | for x in ['mid', 'bid', 'ask']: 595 | ident = f'{x}_close' 596 | getattr(self.l, ident)[0] = price[x]['close'] 597 | 598 | return True 599 | -------------------------------------------------------------------------------- /btoandav20/stores/oandav20store.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | 4 | import collections 5 | import threading 6 | import copy 7 | import json 8 | import time as _time 9 | from datetime import datetime, timezone 10 | 11 | import v20 12 | 13 | import backtrader as bt 14 | from backtrader.metabase import MetaParams 15 | from backtrader.utils.py3 import queue, with_metaclass 16 | from .oandaposition import OandaPosition 17 | 18 | class SerializableEvent(object): 19 | '''A threading.Event that can be serialized.''' 20 | 21 | def __init__(self): 22 | self.evt = threading.Event() 23 | 24 | def set(self): 25 | return self.evt.set() 26 | 27 | def clear(self): 28 | return self.evt.clear() 29 | 30 | def isSet(self): 31 | return self.evt.isSet() 32 | 33 | def wait(self, timeout=0): 34 | return self.evt.wait(timeout) 35 | 36 | def __getstate__(self): 37 | d = copy.copy(self.__dict__) 38 | if self.evt.isSet(): 39 | d['evt'] = True 40 | else: 41 | d['evt'] = False 42 | return d 43 | 44 | def __setstate__(self, d): 45 | self.evt = threading.Event() 46 | if d['evt']: 47 | self.evt.set() 48 | 49 | 50 | class MetaSingleton(MetaParams): 51 | '''Metaclass to make a metaclassed class a singleton''' 52 | def __init__(cls, name, bases, dct): 53 | super(MetaSingleton, cls).__init__(name, bases, dct) 54 | cls._singleton = None 55 | 56 | def __call__(cls, *args, **kwargs): 57 | if cls._singleton is None: 58 | cls._singleton = ( 59 | super(MetaSingleton, cls).__call__(*args, **kwargs)) 60 | 61 | return cls._singleton 62 | 63 | 64 | class OandaV20Store(with_metaclass(MetaSingleton, object)): 65 | '''Singleton class wrapping to control the connections to Oanda v20. 66 | 67 | Params: 68 | 69 | - ``token`` (default:``None``): API access token 70 | 71 | - ``account`` (default: ``None``): account id 72 | 73 | - ``practice`` (default: ``False``): use the test environment 74 | 75 | - ``account_poll_freq`` (default: ``5.0``): refresh frequency for 76 | account value/cash refresh 77 | 78 | - ``stream_timeout`` (default: ``2``): timeout for stream requests 79 | 80 | - ``poll_timeout`` (default: ``2``): timeout for poll requests 81 | 82 | - ``reconnections`` (default: ``-1``): try to reconnect forever 83 | connection errors 84 | 85 | - ``reconntimeout`` (default: ``5.0``): how long to wait to reconnect 86 | stream (feeds have own reconnection settings) 87 | 88 | - ``notif_transactions`` (default: ``False``): notify store of all recieved 89 | transactions 90 | ''' 91 | 92 | params = dict( 93 | token='', 94 | account='', 95 | practice=False, 96 | # account balance refresh timeout 97 | account_poll_freq=5.0, 98 | # stream timeout 99 | stream_timeout=2, 100 | # poll timeout 101 | poll_timeout=2, 102 | # count of reconnections, -1 unlimited, 0 none 103 | reconnections=-1, 104 | # timeout between reconnections 105 | reconntimeout=5.0, 106 | # send store notification with recieved transactions 107 | notif_transactions=False, 108 | ) 109 | 110 | BrokerCls = None # broker class will auto register 111 | DataCls = None # data class will auto register 112 | 113 | # Oanda supported granularities 114 | '''S5, S10, S15, S30, M1, M2, M3, M4, M5, M10, M15, M30, H1, 115 | H2, H3, H4, H6, H8, H12, D, W, M''' 116 | _GRANULARITIES = { 117 | (bt.TimeFrame.Seconds, 5): 'S5', 118 | (bt.TimeFrame.Seconds, 10): 'S10', 119 | (bt.TimeFrame.Seconds, 15): 'S15', 120 | (bt.TimeFrame.Seconds, 30): 'S30', 121 | (bt.TimeFrame.Minutes, 1): 'M1', 122 | (bt.TimeFrame.Minutes, 2): 'M2', 123 | (bt.TimeFrame.Minutes, 3): 'M3', 124 | (bt.TimeFrame.Minutes, 4): 'M4', 125 | (bt.TimeFrame.Minutes, 5): 'M5', 126 | (bt.TimeFrame.Minutes, 10): 'M10', 127 | (bt.TimeFrame.Minutes, 15): 'M15', 128 | (bt.TimeFrame.Minutes, 30): 'M30', 129 | (bt.TimeFrame.Minutes, 60): 'H1', 130 | (bt.TimeFrame.Minutes, 120): 'H2', 131 | (bt.TimeFrame.Minutes, 180): 'H3', 132 | (bt.TimeFrame.Minutes, 240): 'H4', 133 | (bt.TimeFrame.Minutes, 360): 'H6', 134 | (bt.TimeFrame.Minutes, 480): 'H8', 135 | (bt.TimeFrame.Minutes, 720): 'H12', 136 | (bt.TimeFrame.Days, 1): 'D', 137 | (bt.TimeFrame.Weeks, 1): 'W', 138 | (bt.TimeFrame.Months, 1): 'M', 139 | } 140 | 141 | # Order type matching with oanda 142 | _ORDEREXECS = { 143 | bt.Order.Market: 'MARKET', 144 | bt.Order.Limit: 'LIMIT', 145 | bt.Order.Stop: 'STOP', 146 | bt.Order.StopTrail: 'TRAILING_STOP_LOSS' 147 | } 148 | 149 | # transactions which will be emitted on creating/accepting a order 150 | _X_CREATE_TRANS = ['MARKET_ORDER', 151 | 'LIMIT_ORDER', 152 | 'STOP_ORDER', 153 | 'TAKE_PROFIT_ORDER', 154 | 'STOP_LOSS_ORDER', 155 | 'MARKET_IF_TOUCHED_ORDER', 156 | 'TRAILING_STOP_LOSS_ORDER'] 157 | # transactions which filled orders 158 | _X_FILL_TRANS = ['ORDER_FILL'] 159 | # transactions which cancelled orders 160 | _X_CANCEL_TRANS = ['ORDER_CANCEL'] 161 | # transactions which were rejected 162 | _X_REJECT_TRANS = ['MARKET_ORDER_REJECT', 163 | 'LIMIT_ORDER_REJECT', 164 | 'STOP_ORDER_REJECT', 165 | 'TAKE_PROFIT_ORDER_REJECT', 166 | 'STOP_LOSS_ORDER_REJECT', 167 | 'MARKET_IF_TOUCHED_ORDER_REJECT', 168 | 'TRAILING_STOP_LOSS_ORDER_REJECT'] 169 | # transactions which can be ignored 170 | _X_IGNORE_TRANS = ['DAILY_FINANCING', 171 | 'CLIENT_CONFIGURE'] 172 | 173 | # Date format used 174 | _DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f000Z' 175 | 176 | # Oanda api endpoints 177 | _OAPI_URL = ['api-fxtrade.oanda.com', 178 | 'api-fxpractice.oanda.com'] 179 | _OAPI_STREAM_URL = ['stream-fxtrade.oanda.com', 180 | 'stream-fxpractice.oanda.com'] 181 | 182 | @classmethod 183 | def getdata(cls, *args, **kwargs): 184 | '''Returns ``DataCls`` with args, kwargs''' 185 | return cls.DataCls(*args, **kwargs) 186 | 187 | @classmethod 188 | def getbroker(cls, *args, **kwargs): 189 | '''Returns broker with *args, **kwargs from registered ``BrokerCls``''' 190 | return cls.BrokerCls(*args, **kwargs) 191 | 192 | def __init__(self): 193 | '''Initialization''' 194 | super(OandaV20Store, self).__init__() 195 | 196 | self.notifs = collections.deque() # store notifications for cerebro 197 | 198 | self._cash = 0.0 # margin available, currently available cash 199 | self._value = 0.0 # account balance 200 | self._currency = None # account currency 201 | self._leverage = 1 # leverage 202 | self._client_id_prefix = str(datetime.now().timestamp()) 203 | 204 | self.broker = None # broker instance 205 | self.datas = list() # datas that have registered over start 206 | 207 | self._env = None # reference to cerebro for general notifications 208 | self._evt_acct = SerializableEvent() 209 | self._orders = collections.OrderedDict() # map order.ref to order id 210 | self._trades = collections.OrderedDict() # map order.ref to trade id 211 | self._server_positions = collections.defaultdict(OandaPosition) 212 | # init oanda v20 api context 213 | self.oapi = v20.Context( 214 | self._OAPI_URL[int(self.p.practice)], 215 | poll_timeout=self.p.poll_timeout, 216 | port=443, 217 | ssl=True, 218 | token=self.p.token, 219 | datetime_format='UNIX', 220 | ) 221 | 222 | # init oanda v20 api stream context 223 | self.oapi_stream = v20.Context( 224 | self._OAPI_STREAM_URL[int(self.p.practice)], 225 | stream_timeout=self.p.stream_timeout, 226 | port=443, 227 | ssl=True, 228 | token=self.p.token, 229 | datetime_format='UNIX', 230 | ) 231 | 232 | def start(self, data=None, broker=None): 233 | # datas require some processing to kickstart data reception 234 | if data is None and broker is None: 235 | self.cash = None 236 | return 237 | 238 | if data is not None: 239 | self._env = data._env 240 | # For datas simulate a queue with None to kickstart co 241 | self.datas.append(data) 242 | 243 | if self.broker is not None: 244 | self.broker.data_started(data) 245 | 246 | elif broker is not None: 247 | self.broker = broker 248 | self.streaming_events() 249 | self.broker_threads() 250 | 251 | def stop(self): 252 | # signal end of thread 253 | if self.broker is not None: 254 | self.q_ordercreate.put(None) 255 | self.q_orderclose.put(None) 256 | self.q_account.put(None) 257 | 258 | def put_notification(self, msg, *args, **kwargs): 259 | '''Adds a notification''' 260 | self.notifs.append((msg, args, kwargs)) 261 | 262 | def get_notifications(self): 263 | '''Return the pending "store" notifications''' 264 | self.notifs.append(None) # put a mark / threads could still append 265 | return [x for x in iter(self.notifs.popleft, None)] 266 | 267 | def get_positions(self): 268 | '''Returns the currently open positions''' 269 | try: 270 | response = self.oapi.position.list_open(self.p.account) 271 | pos = response.get('positions', 200) 272 | # convert positions to dict 273 | for idx, val in enumerate(pos): 274 | pos[idx] = val.dict() 275 | _utc_now = datetime.utcnow() 276 | for p in pos: 277 | size = float(p['long']['units']) + float(p['short']['units']) 278 | price = ( 279 | float(p['long']['averagePrice']) if size > 0 280 | else float(p['short']['averagePrice'])) 281 | self._server_positions[p['instrument']] = OandaPosition(size, price, dt=_utc_now) 282 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 283 | self.put_notification(str(e)) 284 | except Exception as e: 285 | self.put_notification( 286 | self._create_error_notif( 287 | e, response)) 288 | 289 | try: 290 | return pos 291 | except NameError: 292 | return None 293 | 294 | def get_server_position(self, update_latest = False): 295 | if update_latest: 296 | self.get_positions() 297 | 298 | return self._server_positions 299 | 300 | def get_granularity(self, timeframe, compression): 301 | '''Returns the granularity useable for oanda''' 302 | return self._GRANULARITIES.get((timeframe, compression), None) 303 | 304 | def get_instrument(self, dataname): 305 | '''Returns details about the requested instrument''' 306 | try: 307 | response = self.oapi.account.instruments( 308 | self.p.account, 309 | instruments=dataname) 310 | inst = response.get('instruments', 200) 311 | # convert instruments to dict 312 | for idx, val in enumerate(inst): 313 | inst[idx] = val.dict() 314 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 315 | self.put_notification(str(e)) 316 | except Exception as e: 317 | self.put_notification( 318 | self._create_error_notif( 319 | e, response)) 320 | 321 | try: 322 | return inst[0] 323 | except NameError: 324 | return None 325 | 326 | def get_instruments(self, dataname): 327 | '''Returns details about available instruments''' 328 | try: 329 | response = self.oapi.account.instruments( 330 | self.p.account, 331 | instruments=dataname) 332 | inst = response.get('instruments', 200) 333 | # convert instruments to dict 334 | for idx, val in enumerate(inst): 335 | inst[idx] = val.dict() 336 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 337 | self.put_notification(str(e)) 338 | except Exception as e: 339 | self.put_notification( 340 | self._create_error_notif( 341 | e, response)) 342 | 343 | try: 344 | return inst 345 | except NameError: 346 | return None 347 | 348 | def get_pricing(self, dataname): 349 | '''Returns details about current price''' 350 | try: 351 | response = self.oapi.pricing.get(self.p.account, 352 | instruments=dataname) 353 | prices = response.get('prices', 200) 354 | # convert prices to dict 355 | for idx, val in enumerate(prices): 356 | prices[idx] = val.dict() 357 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 358 | self.put_notification(str(e)) 359 | except Exception as e: 360 | self.put_notification( 361 | self._create_error_notif( 362 | e, response)) 363 | 364 | try: 365 | return prices[0] 366 | except NameError: 367 | return None 368 | 369 | def get_pricings(self, dataname): 370 | '''Returns details about current prices''' 371 | try: 372 | response = self.oapi.pricing.get(self.p.account, 373 | instruments=dataname) 374 | prices = response.get('prices', 200) 375 | # convert prices to dict 376 | for idx, val in enumerate(prices): 377 | prices[idx] = val.dict() 378 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 379 | self.put_notification(str(e)) 380 | except Exception as e: 381 | self.put_notification( 382 | self._create_error_notif( 383 | e, response)) 384 | 385 | try: 386 | return prices 387 | except NameError: 388 | return None 389 | 390 | def get_transactions_range(self, from_id, to_id, exclude_outer=False): 391 | '''Returns all transactions between range''' 392 | try: 393 | response = self.oapi.transaction.range( 394 | self.p.account, 395 | fromID=from_id, 396 | toID=to_id) 397 | transactions = response.get('transactions', 200) 398 | if exclude_outer: 399 | del transactions[0], transactions[-1] 400 | 401 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 402 | self.put_notification(str(e)) 403 | except Exception as e: 404 | self.put_notification( 405 | self._create_error_notif( 406 | e, response)) 407 | 408 | try: 409 | return transactions 410 | except NameError: 411 | return None 412 | 413 | def get_transactions_since(self, id): 414 | '''Returns all transactions since id''' 415 | try: 416 | response = self.oapi.transaction.since( 417 | self.p.account, 418 | id=id) 419 | transactions = response.get('transactions', 200) 420 | 421 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 422 | self.put_notification(str(e)) 423 | except Exception as e: 424 | self.put_notification( 425 | self._create_error_notif( 426 | e, response)) 427 | 428 | try: 429 | return transactions 430 | except NameError: 431 | return None 432 | 433 | def get_cash(self): 434 | '''Returns the available cash''' 435 | return self._cash 436 | 437 | def get_value(self): 438 | '''Returns the account balance''' 439 | return self._value 440 | 441 | def get_currency(self): 442 | '''Returns the currency of the account''' 443 | return self._currency 444 | 445 | def get_leverage(self): 446 | '''Returns the leverage of the account''' 447 | return self._leverage 448 | 449 | def broker_threads(self): 450 | '''Creates threads for broker functionality''' 451 | self.q_account = queue.Queue() 452 | self.q_account.put(True) # force an immediate update 453 | t = threading.Thread(target=self._t_account) 454 | t.daemon = True 455 | t.start() 456 | 457 | self.q_ordercreate = queue.Queue() 458 | t = threading.Thread(target=self._t_order_create) 459 | t.daemon = True 460 | t.start() 461 | 462 | self.q_orderclose = queue.Queue() 463 | t = threading.Thread(target=self._t_order_cancel) 464 | t.daemon = True 465 | t.start() 466 | 467 | # Wait once for the values to be set 468 | self._evt_acct.wait(self.p.account_poll_freq) 469 | 470 | def streaming_events(self): 471 | '''Creates threads for event streaming''' 472 | q = queue.Queue() 473 | kwargs = {'q': q} 474 | t = threading.Thread(target=self._t_streaming_events, kwargs=kwargs) 475 | t.daemon = True 476 | t.start() 477 | return q 478 | 479 | def streaming_prices(self, dataname): 480 | '''Creates threads for price streaming''' 481 | q = queue.Queue() 482 | kwargs = {'q': q, 'dataname': dataname} 483 | t = threading.Thread(target=self._t_streaming_prices, kwargs=kwargs) 484 | t.daemon = True 485 | t.start() 486 | return q 487 | 488 | def order_create(self, order, stopside=None, takeside=None, **kwargs): 489 | '''Creates an order''' 490 | okwargs = dict() 491 | okwargs['instrument'] = order.data._dataname 492 | okwargs['units'] = ( 493 | abs(int(order.created.size)) if order.isbuy() 494 | else -abs(int(order.created.size))) # negative for selling 495 | okwargs['type'] = self._ORDEREXECS[order.exectype] 496 | okwargs['replace'] = order.info.get('replace', None) 497 | okwargs['replace_type'] = order.info.get('replace_type', None) 498 | 499 | if order.exectype != bt.Order.Market: 500 | okwargs['price'] = format( 501 | order.created.price, 502 | '.%df' % order.data.contractdetails['displayPrecision']) 503 | if order.valid is None: 504 | okwargs['timeInForce'] = 'GTC' # good to cancel 505 | else: 506 | okwargs['timeInForce'] = 'GTD' # good to date 507 | gtdtime = order.data.num2date(order.valid, tz=timezone.utc) 508 | okwargs['gtdTime'] = gtdtime.strftime(self._DATE_FORMAT) 509 | 510 | if order.exectype == bt.Order.StopTrail: 511 | if 'replace' not in okwargs: 512 | raise Exception('replace param needed for StopTrail order') 513 | trailamount = order.trailamount 514 | if order.trailpercent: 515 | trailamount = order.price * order.trailpercent 516 | okwargs['distance'] = format( 517 | trailamount, 518 | '.%df' % order.data.contractdetails['displayPrecision']) 519 | 520 | if stopside is not None: 521 | if stopside.exectype == bt.Order.StopTrail: 522 | trailamount = stopside.trailamount 523 | if stopside.trailpercent: 524 | trailamount = order.price * stopside.trailpercent 525 | okwargs['trailingStopLossOnFill'] = v20.transaction.TrailingStopLossDetails( 526 | distance=format( 527 | trailamount, 528 | '.%df' % order.data.contractdetails['displayPrecision']), 529 | clientExtensions=v20.transaction.ClientExtensions( 530 | id=self._oref_to_client_id(stopside.ref), 531 | comment=json.dumps(order.info) 532 | ).dict() 533 | ).dict() 534 | else: 535 | okwargs['stopLossOnFill'] = v20.transaction.StopLossDetails( 536 | price=format( 537 | stopside.price, 538 | '.%df' % order.data.contractdetails['displayPrecision']), 539 | clientExtensions=v20.transaction.ClientExtensions( 540 | id=self._oref_to_client_id(stopside.ref), 541 | comment=json.dumps(order.info) 542 | ).dict() 543 | ).dict() 544 | 545 | if takeside is not None and takeside.price is not None: 546 | okwargs['takeProfitOnFill'] = v20.transaction.TakeProfitDetails( 547 | price=format( 548 | takeside.price, 549 | '.%df' % order.data.contractdetails['displayPrecision']), 550 | clientExtensions=v20.transaction.ClientExtensions( 551 | id=self._oref_to_client_id(takeside.ref), 552 | comment=json.dumps(order.info) 553 | ).dict() 554 | ).dict() 555 | 556 | # store backtrader order ref in client extensions 557 | okwargs['clientExtensions'] = v20.transaction.ClientExtensions( 558 | id=self._oref_to_client_id(order.ref), 559 | comment=json.dumps(order.info) 560 | ).dict() 561 | 562 | okwargs.update(**kwargs) # anything from the user 563 | self.q_ordercreate.put((order.ref, okwargs,)) 564 | 565 | # notify orders of being submitted 566 | self.broker._submit(order.ref) 567 | if stopside is not None: # don't make price on stopside mandatory 568 | self.broker._submit(stopside.ref) 569 | if takeside is not None and takeside.price is not None: 570 | self.broker._submit(takeside.ref) 571 | 572 | return order 573 | 574 | def order_cancel(self, order): 575 | '''Cancels a order''' 576 | self.q_orderclose.put(order.ref) 577 | return order 578 | 579 | def candles(self, dataname, dtbegin, dtend, timeframe, compression, 580 | candleFormat, includeFirst=True, onlyComplete=True): 581 | '''Returns historical rates''' 582 | q = queue.Queue() 583 | kwargs = {'dataname': dataname, 'dtbegin': dtbegin, 'dtend': dtend, 584 | 'timeframe': timeframe, 'compression': compression, 585 | 'candleFormat': candleFormat, 'includeFirst': includeFirst, 586 | 'onlyComplete': onlyComplete, 'q': q} 587 | t = threading.Thread(target=self._t_candles, kwargs=kwargs) 588 | t.daemon = True 589 | t.start() 590 | return q 591 | 592 | def _oref_to_client_id(self, oref): 593 | '''Converts a oref to client id''' 594 | id = '{}-{}'.format(self._client_id_prefix, oref) 595 | return id 596 | 597 | def _client_id_to_oref(self, client_id): 598 | '''Converts a client id to oref''' 599 | oref = None 600 | if str(client_id).startswith(self._client_id_prefix): 601 | oref = int(str(client_id)[len(self._client_id_prefix)+1:]) 602 | return oref 603 | 604 | def _t_account(self): 605 | '''Callback method for account request''' 606 | while True: 607 | try: 608 | msg = self.q_account.get(timeout=self.p.account_poll_freq) 609 | if msg is None: 610 | break # end of thread 611 | except queue.Empty: # tmout -> time to refresh 612 | pass 613 | 614 | try: 615 | response = self.oapi.account.summary(self.p.account) 616 | accinfo = response.get('account', 200) 617 | 618 | response = self.oapi.position.list_open(self.p.account) 619 | pos = response.get('positions', 200) 620 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 621 | self.put_notification(str(e)) 622 | if self.p.reconnections == 0: 623 | self.put_notification('Giving up fetching account summary') 624 | return 625 | continue 626 | except Exception as e: 627 | self.put_notification( 628 | self._create_error_notif( 629 | e, response)) 630 | return 631 | 632 | try: 633 | self._cash = accinfo.marginAvailable 634 | self._value = accinfo.balance 635 | self._currency = accinfo.currency 636 | self._leverage = 1/accinfo.marginRate 637 | 638 | #reset 639 | self._server_positions = collections.defaultdict(OandaPosition) 640 | #Position 641 | # convert positions to dict 642 | _utc_now = datetime.utcnow() 643 | for idx, val in enumerate(pos): 644 | pos[idx] = val.dict() 645 | for p in pos: 646 | size = float(p['long']['units']) + float(p['short']['units']) 647 | price = ( 648 | float(p['long']['averagePrice']) if size > 0 649 | else float(p['short']['averagePrice'])) 650 | self._server_positions[p['instrument']] = OandaPosition(size, price,dt=_utc_now) 651 | except KeyError: 652 | pass 653 | 654 | # notify of success, initialization waits for it 655 | self._evt_acct.set() 656 | 657 | def _t_streaming_events(self, q): 658 | '''Callback method for streaming events''' 659 | last_id = None 660 | reconnections = 0 661 | while True: 662 | try: 663 | response = self.oapi_stream.transaction.stream( 664 | self.p.account 665 | ) 666 | # process response 667 | for msg_type, msg in response.parts(): 668 | if msg_type == 'transaction.TransactionHeartbeat': 669 | if not last_id: 670 | last_id = msg.lastTransactionID 671 | # if a reconnection occurred 672 | if reconnections > 0: 673 | if last_id: 674 | # get all transactions between the last seen and first from 675 | # reconnected stream 676 | old_transactions = self.get_transactions_since( 677 | last_id) 678 | for t in old_transactions: 679 | if msg_type == 'transaction.Transaction': 680 | if t.id > last_id: 681 | self._transaction(t.dict()) 682 | last_id = t.id 683 | reconnections = 0 684 | if msg_type == 'transaction.Transaction': 685 | if not last_id or msg.id > last_id: 686 | self._transaction(msg.dict()) 687 | last_id = msg.id 688 | 689 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 690 | self.put_notification(str(e)) 691 | if (self.p.reconnections == 0 or self.p.reconnections > 0 692 | and reconnections > self.p.reconnections): 693 | # unable to reconnect after x times 694 | self.put_notification('Giving up reconnecting streaming events') 695 | return 696 | reconnections += 1 697 | if self.p.reconntimeout is not None: 698 | _time.sleep(self.p.reconntimeout) 699 | self.put_notification('Trying to reconnect streaming events ({} of {})'.format( 700 | reconnections, 701 | self.p.reconnections)) 702 | continue 703 | except Exception as e: 704 | self.put_notification( 705 | self._create_error_notif( 706 | e, response)) 707 | 708 | def _t_streaming_prices(self, dataname, q): 709 | '''Callback method for streaming prices''' 710 | try: 711 | response = self.oapi_stream.pricing.stream( 712 | self.p.account, 713 | instruments=dataname, 714 | ) 715 | # process response 716 | for msg_type, msg in response.parts(): 717 | if msg_type == 'pricing.ClientPrice': 718 | # put price into queue as dict 719 | q.put(msg.dict()) 720 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 721 | self.put_notification(str(e)) 722 | # notify feed of error 723 | q.put({'msg': 'CONNECTION_ISSUE'}) 724 | except Exception as e: 725 | self.put_notification( 726 | self._create_error_notif( 727 | e, response)) 728 | 729 | def _t_candles(self, dataname, dtbegin, dtend, timeframe, compression, 730 | candleFormat, includeFirst, onlyComplete, q): 731 | '''Callback method for candles request''' 732 | granularity = self.get_granularity(timeframe, compression) 733 | if granularity is None: 734 | q.put(None) 735 | return 736 | 737 | dtkwargs = {} 738 | if dtbegin is not None: 739 | dtkwargs['fromTime'] = dtbegin.strftime(self._DATE_FORMAT) 740 | dtkwargs['includeFirst'] = includeFirst 741 | 742 | count = 0 743 | reconnections = 0 744 | while True: 745 | if count > 1: 746 | dtkwargs['includeFirst'] = False 747 | try: 748 | response = self.oapi.instrument.candles( 749 | dataname, 750 | granularity=granularity, 751 | price=candleFormat, 752 | **dtkwargs) 753 | candles = response.get('candles', 200) 754 | reconnections = 0 755 | count += 1 756 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 757 | self.put_notification(str(e)) 758 | if (self.p.reconnections == 0 or self.p.reconnections > 0 759 | and reconnections > self.p.reconnections): 760 | self.put_notification('Giving up fetching candles') 761 | return 762 | reconnections += 1 763 | if self.p.reconntimeout is not None: 764 | _time.sleep(self.p.reconntimeout) 765 | self.put_notification( 766 | 'Trying to fetch candles ({} of {})'.format( 767 | reconnections, 768 | self.p.reconnections)) 769 | continue 770 | except Exception as e: 771 | self.put_notification( 772 | self._create_error_notif( 773 | e, response)) 774 | continue 775 | 776 | dtobj = None 777 | for candle in candles: 778 | # get current candle time 779 | dtobj = datetime.utcfromtimestamp(float(candle.time)) 780 | # if end time is provided, check if time is reached for 781 | # every candle 782 | if dtend is not None and dtobj > dtend: 783 | break 784 | # add candle 785 | if not onlyComplete or candle.complete: 786 | q.put(candle.dict()) 787 | 788 | if dtobj is not None: 789 | dtkwargs['fromTime'] = dtobj.strftime(self._DATE_FORMAT) 790 | elif dtobj is None: 791 | break 792 | if dtend is not None and dtobj > dtend: 793 | break 794 | if len(candles) == 0: 795 | break 796 | 797 | q.put({}) # end of transmission''' 798 | 799 | def _transaction(self, trans): 800 | if self.p.notif_transactions: 801 | self.put_notification(str(trans)) 802 | oid = None 803 | ttype = trans['type'] 804 | 805 | if ttype in self._X_CREATE_TRANS: 806 | # get order id (matches transaction id) 807 | oid = trans['id'] 808 | oref = None 809 | # identify backtrader order by checking client 810 | # extensions (this is set when creating a order) 811 | if 'clientExtensions' in trans: 812 | # assume backtrader created the order for this transaction 813 | oref = self._client_id_to_oref(trans['clientExtensions']['id']) 814 | if oref is not None: 815 | self._orders[oid] = oref 816 | 817 | elif ttype in self._X_FILL_TRANS: 818 | # order was filled, notify backtrader of it 819 | oid = trans['orderID'] 820 | 821 | elif ttype in self._X_CANCEL_TRANS: 822 | # order was cancelled, notify backtrader of it 823 | oid = trans['orderID'] 824 | 825 | elif ttype in self._X_REJECT_TRANS: 826 | # transaction was rejected, notify backtrader of it 827 | oid = trans['requestID'] 828 | 829 | elif ttype in self._X_IGNORE_TRANS: 830 | # transaction can be ignored 831 | msg = 'Received transaction {} with id {}. Ignoring transaction.' 832 | msg = msg.format(ttype, trans['id']) 833 | self.put_notification(msg, trans) 834 | 835 | else: 836 | msg = 'Received transaction {} with id {}. Unknown situation.' 837 | msg = msg.format(ttype, trans['id']) 838 | self.put_notification(msg, trans) 839 | return 840 | 841 | if oid in self._orders: 842 | # when an order id exists process transaction 843 | self._process_transaction(oid, trans) 844 | self._process_trades(self._orders[oid], trans) 845 | else: 846 | # external order created this transaction 847 | self.get_server_position(update_latest=True) 848 | if self.broker.p.use_positions and ttype in self._X_FILL_TRANS: 849 | size = float(trans['units']) 850 | price = float(trans['price']) 851 | for data in self.datas: 852 | if data._name == trans['instrument']: 853 | self.broker._fill_external(data, size, price) 854 | break 855 | elif ttype not in self._X_IGNORE_TRANS: 856 | # notify about unknown transaction 857 | if self.broker.p.use_positions: 858 | msg = 'Received external transaction {} with id {}. Skipping transaction.' 859 | else: 860 | msg = 'Received external transaction {} with id {}. Positions and trades may not match anymore.' 861 | msg = msg.format(ttype, trans['id']) 862 | self.put_notification(msg, trans) 863 | 864 | def _process_transaction(self, oid, trans): 865 | try: 866 | # get a reference to a backtrader order based on 867 | # the order id / trade id 868 | oref = self._orders[oid] 869 | except KeyError: 870 | return 871 | 872 | ttype = trans['type'] 873 | if ttype in self._X_CREATE_TRANS: 874 | self.broker._accept(oref) 875 | 876 | elif ttype in self._X_FILL_TRANS: 877 | size = float(trans['units']) 878 | price = float(trans['price']) 879 | self.broker._fill(oref, size, price, reason=trans['reason']) 880 | # store order ids which generated by the order 881 | if 'tradeOpened' in trans: 882 | self._orders[trans['tradeOpened']['tradeID']] = oref 883 | if 'tradeReduced' in trans: 884 | self._orders[trans['tradeReduced']['tradeID']] = oref 885 | 886 | elif ttype in self._X_CANCEL_TRANS: 887 | reason = trans['reason'] 888 | if reason == 'TIME_IN_FORCE_EXPIRED': 889 | self.broker._expire(oref) 890 | else: 891 | self.broker._cancel(oref) 892 | 893 | elif ttype in self._X_REJECT_TRANS: 894 | self.broker._reject(oref) 895 | 896 | def _process_trades(self, oref, trans): 897 | if 'tradeID' in trans: 898 | self._trades[oref] = trans['tradeID'] 899 | if 'tradeOpened' in trans: 900 | self._trades[oref] = trans['tradeOpened']['tradeID'] 901 | if 'tradeClosed' in trans: 902 | self._trades[oref] = trans['tradeClosed']['tradeID'] 903 | if 'tradesClosed' in trans: 904 | for t in trans['tradesClosed']: 905 | for key, value in self._trades.copy().items(): 906 | if value == t['tradeID']: 907 | del self._trades[key] 908 | 909 | def _t_order_create(self): 910 | while True: 911 | msg = self.q_ordercreate.get() 912 | if msg is None: 913 | break 914 | 915 | oref, okwargs = msg 916 | try: 917 | if okwargs['replace']: 918 | oid = '@{}'.format( 919 | self._oref_to_client_id(okwargs['replace'])) 920 | if okwargs['replace'] in self._trades: 921 | okwargs['tradeID'] = self._trades[okwargs['replace']] 922 | if okwargs['replace_type']: 923 | okwargs['type'] = okwargs['replace_type'] 924 | response = self.oapi.order.replace( 925 | self.p.account, 926 | oid, 927 | order=okwargs) 928 | else: 929 | response = self.oapi.order.create( 930 | self.p.account, 931 | order=okwargs) 932 | # get the transaction which created the order 933 | o = response.get('orderCreateTransaction', 201) 934 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 935 | self.put_notification(str(e)) 936 | self.broker._reject(oref) 937 | continue 938 | except Exception as e: 939 | self.put_notification( 940 | self._create_error_notif( 941 | e, response)) 942 | self.broker._reject(oref) 943 | continue 944 | 945 | def _t_order_cancel(self): 946 | while True: 947 | oref = self.q_orderclose.get() 948 | if oref is None: 949 | break 950 | 951 | oid = None 952 | for key, value in self._orders.items(): 953 | if value == oref: 954 | oid = key 955 | break 956 | 957 | if oid is None: 958 | continue # the order is no longer there 959 | try: 960 | # TODO either close pending orders or filled trades 961 | response = self.oapi.order.cancel(self.p.account, oid) 962 | except (v20.V20ConnectionError, v20.V20Timeout) as e: 963 | self.put_notification(str(e)) 964 | continue 965 | except Exception as e: 966 | self.put_notification( 967 | self._create_error_notif( 968 | e, response)) 969 | continue 970 | 971 | self.broker._cancel(oref) 972 | 973 | def _create_error_notif(self, e, response): 974 | try: 975 | notif = '{}: {} - {}'.format( 976 | response.status, 977 | response.reason, 978 | response.get('errorMessage')) 979 | except Exception: 980 | notif = str(e) 981 | return notif 982 | --------------------------------------------------------------------------------