diff --git a/.travis.yml b/.travis.yml index bc6d7cd7..6af2d333 100644 --- a/.travis.yml +++ b/.travis.yml @@ -43,6 +43,10 @@ install: - pip install multipledispatch - pip install requests_cache - pip install pandas_datareader + - git clone https://github.com/ig-python/ig-markets-api-python-library.git + - cd ig-markets-api-python-library + - python setup.py install + - cd .. - conda list - python setup.py install diff --git a/qstrader/base_handler.py b/qstrader/base_handler.py new file mode 100644 index 00000000..3c273de5 --- /dev/null +++ b/qstrader/base_handler.py @@ -0,0 +1,87 @@ +from qstrader.order.suggested import SuggestedOrder + + +class AbstractPortfolioHandler(object): + def _create_order_from_signal(self, signal_event): + """ + Take a SignalEvent object and use it to form a + SuggestedOrder object. These are not OrderEvent objects, + as they have yet to be sent to the RiskManager object. + At this stage they are simply "suggestions" that the + RiskManager will either verify, modify or eliminate. + """ + if signal_event.suggested_quantity is None: + quantity = 0 + else: + quantity = signal_event.suggested_quantity + order = SuggestedOrder( + signal_event.ticker, + signal_event.action, + quantity=quantity + ) + return order + + def _place_orders_onto_queue(self, order_list): + """ + Once the RiskManager has verified, modified or eliminated + any order objects, they are placed onto the events queue, + to ultimately be executed by the ExecutionHandler. + """ + for order_event in order_list: + self.events_queue.put(order_event) + + def _convert_fill_to_portfolio_update(self, fill_event): + """ + Upon receipt of a FillEvent, the PortfolioHandler converts + the event into a transaction that gets stored in the Portfolio + object. This ensures that the broker and the local portfolio + are "in sync". + + In addition, for backtesting purposes, the portfolio value can + be reasonably estimated in a realistic manner, simply by + modifying how the ExecutionHandler object handles slippage, + transaction costs, liquidity and market impact. + """ + action = fill_event.action + ticker = fill_event.ticker + quantity = fill_event.quantity + price = fill_event.price + commission = fill_event.commission + # Create or modify the position from the fill info + self.portfolio.transact_position( + action, ticker, quantity, + price, commission + ) + + def on_signal(self, signal_event): + """ + This is called by the backtester or live trading architecture + to form the initial orders from the SignalEvent. + + These orders are sized by the PositionSizer object and then + sent to the RiskManager to verify, modify or eliminate. + + Once received from the RiskManager they are converted into + full OrderEvent objects and sent back to the events queue. + """ + raise NotImplementedError("on_signal is not implemented in the base class!") + + def on_fill(self, fill_event): + """ + This is called by the backtester or live trading architecture + to take a FillEvent and update the Portfolio object with new + or modified Positions. + + In a backtesting environment these FillEvents will be simulated + by a model representing the execution, whereas in live trading + they will come directly from a brokerage (such as Interactive + Brokers). + """ + raise NotImplementedError("on_fill is not implemented in the base class!") + + def update_portfolio_value(self): + """ + Update the portfolio to reflect current market value as + based on last bid/ask of each ticker. + """ + raise NotImplementedError("update_portfolio_value is not implemented in the base class!") diff --git a/qstrader/execution_handler/ig.py b/qstrader/execution_handler/ig.py new file mode 100644 index 00000000..5119eb8a --- /dev/null +++ b/qstrader/execution_handler/ig.py @@ -0,0 +1,120 @@ +import logging + +from trading_ig import IGStreamService + +from qstrader import setup_logging +from .base import AbstractExecutionHandler +from ..event import (FillEvent, EventType) +from ..price_parser import PriceParser + +setup_logging + + +class IGExecutionHandler(AbstractExecutionHandler): + """ + The execution handler for IG Markets + executes Market orders at the moment. + + This classes establishes a REST cached IG session and also stream subscription + to get real time updates and confirm when the order has been filled and the spread/commission + """ + # TODO Working orders will be implemented in the future + + def __init__(self, events_queue, ig_service, config, compliance=None): + """ + Initialises the handler, setting the event queue + as well as accessing the pricing handler and setting a new cached session against IG Markets API. + + Parameters: + events_queue - The Queue of Event objects. + price_handler - The price handlers to obtain price details before executing order + compliance - Compliance object + config - Configuration object + """ + self.events_queue = events_queue + self.fill_event = None + self.ig_service = ig_service + self.compliance = compliance + self.config = config + # Set up logging + self.logger = logging.getLogger(__name__) + self.ig_stream_service, self.ig_stream_session = self._create_streaming_session(ig_service) + + def _create_streaming_session(self, ig_service): + + # Cretes Streaming service and session + ig_stream_service = IGStreamService(ig_service) + ig_stream_session = ig_stream_service.create_session() + + return ig_stream_service, ig_stream_session + + def _create_fill_event(self, executed_order): + executed_order["dealReference"] + epic = executed_order["epic"] + # ticker = epic[5:8] + exchange = epic[1:4] + action = executed_order["direction"] + quantity = executed_order["size"] + fill_price = PriceParser.parse(executed_order["level"]) / 10000 + timestamp = executed_order["date"] + commission = self.calculate_ig_commission(quantity, fill_price) + self.logger.info("Created filled event") + self.logger.debug('Data received: %s' % executed_order) + self.fill_event = FillEvent(timestamp, epic, action, quantity, exchange, fill_price, commission) + self.events_queue.put(self.fill_event) + + def calculate_ig_commission(self, quantity, fill_price): + """ + Calculate the IG Markets commission for + a transaction. + """ + # TODO implement logic if required (IG Market doesn't have commission as it is included in spread) + commission = 0 + return PriceParser.parse(commission) + + def execute_order(self, event): + """ + Executes Market orders directly, OrderEvents into FillEvents through IG Markets API, + this means it has to track the order synchronously and return confirmation + + Parameters: + event - An Event object with order information. + """ + + if event.type == EventType.ORDER: + # Obtain values from the OrderEvent + ticker = event.ticker + # Mapping original IB order action to IG terminology + if event.action == "BOT": + action = "BUY" + else: + action = "SELL" + quantity = event.quantity + + # Execute Market order + executed_order = self.ig_service.create_open_position("GBP", + action, + ticker, + # str(datetime.now() + timedelta(hours=3)), + "DFB", + False, + False, + None, + None, + None, + "MARKET", + None, + quantity, + None, + None + ) + + # Track confirmation from IG and update fill price with + if executed_order["dealStatus"] == "ACCEPTED": + self._create_fill_event(executed_order) + if self.compliance is not None: + self.compliance.record_trade(self.fill_event) + else: + self.logger.error('Order execution failed, reason: %s, quantity: %s, ticker: %s' % (executed_order["reason"], quantity, ticker)) + + return executed_order diff --git a/qstrader/ig_handler.py b/qstrader/ig_handler.py new file mode 100644 index 00000000..2fb7f734 --- /dev/null +++ b/qstrader/ig_handler.py @@ -0,0 +1,77 @@ +from qstrader.base_handler import AbstractPortfolioHandler +from qstrader.ig_portfolio import Portfolio + + +class PortfolioHandler(AbstractPortfolioHandler): + def __init__( + self, initial_cash, events_queue, + price_handler, position_sizer, risk_manager + ): + """ + The PortfolioHandler is designed to interact with the + backtesting or live trading overall event-driven + architecture. It exposes two methods, on_signal and + on_fill, which handle how SignalEvent and FillEvent + objects are dealt with. + + Each PortfolioHandler contains a Portfolio object, + which stores the actual Position objects. + + The PortfolioHandler takes a handle to a PositionSizer + object which determines a mechanism, based on the current + Portfolio, as to how to size a new Order. + + The PortfolioHandler also takes a handle to the + RiskManager, which is used to modify any generated + Orders to remain in line with risk parameters. + """ + self.initial_cash = initial_cash + self.events_queue = events_queue + self.price_handler = price_handler + self.position_sizer = position_sizer + self.risk_manager = risk_manager + self.portfolio = Portfolio(price_handler, initial_cash) + + def on_signal(self, signal_event): + """ + This is called by the backtester or live trading architecture + to form the initial orders from the SignalEvent. + + These orders are sized by the PositionSizer object and then + sent to the RiskManager to verify, modify or eliminate. + + Once received from the RiskManager they are converted into + full OrderEvent objects and sent back to the events queue. + """ + # Create the initial order list from a signal event + initial_order = self._create_order_from_signal(signal_event) + # Size the quantity of the initial order + sized_order = self.position_sizer.size_order( + self.portfolio, initial_order + ) + # Refine or eliminate the order via the risk manager overlay + order_events = self.risk_manager.refine_orders( + self.portfolio, sized_order + ) + # Place orders onto events queue + self._place_orders_onto_queue(order_events) + + def on_fill(self, fill_event): + """ + This is called by the backtester or live trading architecture + to take a FillEvent and update the Portfolio object with new + or modified Positions. + + In a backtesting environment these FillEvents will be simulated + by a model representing the execution, whereas in live trading + they will come directly from a brokerage (such as Interactive + Brokers). + """ + self._convert_fill_to_portfolio_update(fill_event) + + def update_portfolio_value(self): + """ + Update the portfolio to reflect current market value as + based on last bid/ask of each ticker. + """ + self.portfolio._update_portfolio() diff --git a/qstrader/ig_portfolio.py b/qstrader/ig_portfolio.py new file mode 100644 index 00000000..ccf7bddb --- /dev/null +++ b/qstrader/ig_portfolio.py @@ -0,0 +1,154 @@ +import logging + +from qstrader import setup_logging +from qstrader.position import Position +from qstrader.price_parser import PriceParser + +setup_logging + + +class Portfolio(object): + def __init__(self, price_handler, cash): + """ + On creation, the Portfolio object contains no + positions and all values are "reset" to the initial + cash, with no PnL - realised or unrealised. + + Note that realised_pnl is the running tally pnl from closed + positions (closed_pnl), as well as realised_pnl + from currently open positions. + """ + self.price_handler = price_handler + self.init_cash = cash + self.equity = cash + self.cur_cash = cash + self.positions = {} + self.closed_positions = [] + self.realised_pnl = 0 + # Set up logging + self.logger = logging.getLogger(__name__) + + def _update_portfolio(self): + """ + Updates the value of all positions that are currently open. + Value of closed positions is tallied as self.realised_pnl. + """ + self.unrealised_pnl = 0 + self.equity = self.realised_pnl + self.equity += self.init_cash + + for ticker in self.positions: + pt = self.positions[ticker] + if self.price_handler.istick(): + bid, ask = self.price_handler.get_best_bid_ask(ticker) + else: + close_price = self.price_handler.get_last_close(ticker) + bid = close_price + ask = close_price + pt.update_market_value(bid, ask) + self.logger.debug('Updating market value with bid: %s and ask: %s' % (bid / PriceParser.PRICE_MULTIPLIER, ask / PriceParser.PRICE_MULTIPLIER)) + self.unrealised_pnl += pt.unrealised_pnl + self.equity += ( + pt.market_value - pt.cost_basis + pt.realised_pnl + ) + self.logger.debug('Unrealised PnL: %s' % (float(self.unrealised_pnl) / PriceParser.PRICE_MULTIPLIER)) + self.logger.debug('Realised PnL: %s' % (float(self.realised_pnl) / PriceParser.PRICE_MULTIPLIER)) + self.logger.debug('Equity: %s' % (float(self.equity) / PriceParser.PRICE_MULTIPLIER)) + + def _add_position( + self, action, ticker, + quantity, price, commission + ): + """ + Adds a new Position object to the Portfolio. This + requires getting the best bid/ask price from the + price handler in order to calculate a reasonable + "market value". + + Once the Position is added, the Portfolio values + are updated. + """ + if ticker not in self.positions: + if self.price_handler.istick(): + bid, ask = self.price_handler.get_best_bid_ask(ticker) + else: + close_price = self.price_handler.get_last_close(ticker) + bid = close_price + ask = close_price + position = Position( + action, ticker, quantity, + price, commission, bid, ask + ) + self.positions[ticker] = position + self._update_portfolio() + else: + print( + "Ticker %s is already in the positions list. " + "Could not add a new position." % ticker + ) + + def _modify_position( + self, action, ticker, + quantity, price, commission + ): + """ + Modifies a current Position object to the Portfolio. + This requires getting the best bid/ask price from the + price handler in order to calculate a reasonable + "market value". + + Once the Position is modified, the Portfolio values + are updated. + """ + if ticker in self.positions: + self.positions[ticker].transact_shares( + action, quantity, price, commission + ) + if self.price_handler.istick(): + bid, ask = self.price_handler.get_best_bid_ask(ticker) + else: + close_price = self.price_handler.get_last_close(ticker) + bid = close_price + ask = close_price + self.positions[ticker].update_market_value(bid, ask) + + if self.positions[ticker].quantity == 0: + closed = self.positions.pop(ticker) + self.realised_pnl += closed.realised_pnl + self.closed_positions.append(closed) + + self._update_portfolio() + else: + print( + "Ticker %s not in the current position list. " + "Could not modify a current position." % ticker + ) + + def transact_position( + self, action, ticker, + quantity, price, commission + ): + """ + Handles any new position or modification to + a current position, by calling the respective + _add_position and _modify_position methods. + + Hence, this single method will be called by the + PortfolioHandler to update the Portfolio itself. + """ + + if action == "BOT": + self.cur_cash -= ((quantity * price) + commission) + elif action == "SLD": + self.cur_cash += ((quantity * price) - commission) + + if ticker not in self.positions: + self._add_position( + action, ticker, quantity, + price, commission + ) + else: + self._modify_position( + action, ticker, quantity, + price, commission + ) diff --git a/qstrader/logging.conf b/qstrader/logging.conf new file mode 100644 index 00000000..da3dd782 --- /dev/null +++ b/qstrader/logging.conf @@ -0,0 +1,28 @@ +[loggers] +keys=root,qstrader + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=INFO +handlers=consoleHandler + +[logger_qstrader] +level=INFO +handlers=consoleHandler +qualname=qstrader +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=DEBUG +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +format=%(asctime)s - %(name)s - %(levelname)s - %(message)s +datefmt= \ No newline at end of file diff --git a/qstrader/price_handler/ig.py b/qstrader/price_handler/ig.py index 38c46b12..f5037ce2 100644 --- a/qstrader/price_handler/ig.py +++ b/qstrader/price_handler/ig.py @@ -1,55 +1,84 @@ -import pandas as pd +import logging +import traceback +import pandas as pd +from trading_ig import IGStreamService from trading_ig.lightstreamer import Subscription -from ..price_parser import PriceParser -from ..event import TickEvent +from qstrader import setup_logging from .base import AbstractTickPriceHandler +from ..event import TickEvent +from ..price_parser import PriceParser + +setup_logging class IGTickPriceHandler(AbstractTickPriceHandler): - def __init__(self, events_queue, ig_stream_service, tickers): + def __init__(self, events_queue, ig_service, tickers, config): self.price_event = None self.events_queue = events_queue - self.continue_backtest = True - self.ig_stream_service = ig_stream_service + self.ig_stream_service, self.ig_stream_session = self._create_streaming_session(ig_service, accountId=config.IG_ACC_ID) self.tickers_lst = tickers + self.tickers_subs = ["MARKET:" + ticker for ticker in tickers] self.tickers = {} for ticker in self.tickers_lst: self.tickers[ticker] = {} + # Set up logging + self.logger = logging.getLogger(__name__) + def subscribe(self): # Making a new Subscription in MERGE mode subcription_prices = Subscription( mode="MERGE", - items=tickers, - fields=["UPDATE_TIME", "BID", "OFFER", "CHANGE", "MARKET_STATE"], - # adapter="QUOTE_ADAPTER", + items=self.tickers_subs, + fields=["BID", "OFFER", "CHANGE", "MARKET_STATE", "UPDATE_TIME"], + # adapter="QUOTE_ADAPTER" ) # Adding the "on_price_update" function to Subscription - subcription_prices.addlistener(self.on_prices_update) + subcription_prices.addlistener(self._on_prices_update) # Registering the Subscription - self.ig_stream_service.ls_client.subscribe(subcription_prices) + self.logger.info("Registering the Subscription") + try: + self.ig_stream_service.ls_client.subscribe(subcription_prices) + except Exception: + self.logger.error("Error while subscribing") + print(traceback.format_exc()) + + def _create_streaming_session(self, ig_service, accountId): + + # Cretes Streaming service and session + ig_stream_service = IGStreamService(ig_service) + ig_stream_session = ig_stream_service.create_session() + + # Connect with specified Listener + ig_stream_service.connect(accountId) + + return ig_stream_service, ig_stream_session - def on_prices_update(self, data): - tev = self._create_event(data) - if self.price_event is not None: - print("losing %s" % self.price_event) - self.price_event = tev + def _on_prices_update(self, data): + if data["values"]["MARKET_STATE"] == "TRADEABLE": + tick_event = self._create_event(data) + if tick_event is not None: + self.logger.debug("Price update received") + self.logger.debug('Data received: %s' % data) + self.logger.debug("Storing price update as instrument is tradeable") + self._store_price_event(tick_event) + else: + self.logger.info("Event not stored as market is closed/instrument is not tradeable") def _create_event(self, data): - ticker = data["name"] + ticker = data["name"][7:] index = pd.to_datetime(data["values"]["UPDATE_TIME"]) - bid = PriceParser.parse(data["values"]["BID"]) - ask = PriceParser.parse(data["values"]["OFFER"]) + bid = PriceParser.parse(data["values"]["BID"]) / 10000 + ask = PriceParser.parse(data["values"]["OFFER"]) / 10000 return TickEvent(ticker, index, bid, ask) - def stream_next(self): + def _store_price_event(self, tick_event): """ Place the next PriceEvent (BarEvent or TickEvent) onto the event queue. """ - if self.price_event is not None: - self._store_event(self.price_event) - self.events_queue.put(self.price_event) - self.price_event = None + if tick_event is not None: + self._store_event(tick_event) + self.events_queue.put(tick_event) diff --git a/qstrader/providers/__init__.py b/qstrader/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qstrader/providers/ig.py b/qstrader/providers/ig.py new file mode 100644 index 00000000..2f2a20c2 --- /dev/null +++ b/qstrader/providers/ig.py @@ -0,0 +1,26 @@ +import logging +import logging.config +from datetime import timedelta + +import requests_cache +from trading_ig import IGService + +from qstrader import setup_logging + +setup_logging + + +class IGClient(object): + # Initialises IG Service and Session to support REST API or Streaming services from IG Markets + def __init__(self, config): + CACHE_NAME = 'igcache' + # Set up logging + self.logger = logging.getLogger(__name__) + self.logger.info("Establishing cached session with IG Markets") + session_cached = requests_cache.CachedSession(cache_name=CACHE_NAME, + backend='sqlite', + expire_after=timedelta(hours=1)) + self.ig_service = IGService(config.IG_USERNAME, config.IG_PASSWORD, config.IG_API_KEY, + config.IG_ACC_TYPE, session_cached) + # Creates REST session + self.ig_session = self.ig_service.create_session() diff --git a/qstrader/setup_logging.py b/qstrader/setup_logging.py new file mode 100644 index 00000000..2eb58901 --- /dev/null +++ b/qstrader/setup_logging.py @@ -0,0 +1,22 @@ +import logging.config +import os.path + + +default_path = os.path.join(os.path.dirname(__file__), "logging.conf") +# default_path = 'logging.conf', +default_level = logging.INFO +env_key = 'LOG_CFG' +"""Setup logging configuration + +""" +path = default_path +value = os.getenv(env_key, None) +if value: + path = value +if os.path.exists(path[0]): + # with open(path, 'rt') as f: + # config = yaml.safe_load(f.read()) + # logging.config.dictConfig(config) + logging.config.fileConfig(path, disable_existing_loggers=False) +else: + logging.basicConfig(level=default_level) diff --git a/requirements.txt b/requirements.txt index 860a956b..2e02b65a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ pyyaml>=3.11 munch>=2.0.4 enum34>=1.1.6 multipledispatch>=0.4.8 +trading_ig=0.0.7 diff --git a/tests/test_ig.py b/tests/test_ig.py new file mode 100644 index 00000000..52f56018 --- /dev/null +++ b/tests/test_ig.py @@ -0,0 +1,34 @@ +import unittest + +from qstrader.event import OrderEvent +from qstrader.providers.ig import IGClient + +try: + import Queue as queue +except ImportError: + import queue + +from qstrader import settings + +from qstrader.execution_handler.ig import IGExecutionHandler + + +class TestIGExecutionHandler(unittest.TestCase): + """ + Test that the IG connection can be set up. + """ + def setUp(self): + self.test_sell_event = OrderEvent("CS.D.AUDUSD.TODAY.IP", "SELL", 1) + self.test_buy_event = OrderEvent("CS.D.AUDUSD.TODAY.IP", "BOT", 1) + self.events_queue = queue.Queue() + self.config = settings.from_file(settings.DEFAULT_CONFIG_FILENAME, testing=True) + self.igclient = IGClient(self.config) + + def test_can_connect(self): + igexecutionhandler = IGExecutionHandler(self.events_queue, self.igclient.ig_service, self.config) + self.assertIsInstance(igexecutionhandler, IGExecutionHandler) + self.assertIsNotNone(igexecutionhandler.execute_order(self.test_sell_event)) + self.assertIsNotNone(igexecutionhandler.execute_order(self.test_buy_event)) + + if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ig_portfolio.py b/tests/test_ig_portfolio.py new file mode 100644 index 00000000..12c9cbd5 --- /dev/null +++ b/tests/test_ig_portfolio.py @@ -0,0 +1,104 @@ +import unittest + +from qstrader.ig_portfolio import Portfolio +from qstrader.price_handler.base import AbstractTickPriceHandler +from qstrader.price_parser import PriceParser + + +class PriceHandlerMock(AbstractTickPriceHandler): + def get_best_bid_ask(self, ticker): + prices = { + "GOOG": (PriceParser.parse(705.46), PriceParser.parse(705.46)), + "AMZN": (PriceParser.parse(564.14), PriceParser.parse(565.14)), + } + return prices[ticker] + + +class TestAmazonGooglePortfolio(unittest.TestCase): + """ + Test a portfolio consisting of Amazon and + Google/Alphabet with various orders to create + round-trips for both. + + These orders were carried out in the Interactive Brokers + demo account and checked for cash, equity and PnL + equality. + """ + def setUp(self): + """ + Set up the Portfolio object that will store the + collection of Position objects, supplying it with + $500,000.00 USD in initial cash. + """ + ph = PriceHandlerMock() + cash = PriceParser.parse(500000.00) + self.portfolio = Portfolio(ph, cash) + + def test_calculate_round_trip(self): + """ + Purchase/sell multiple lots of AMZN and GOOG + at various prices/commissions to check the + arithmetic and cost handling. + """ + # Buy 300 of AMZN over two transactions + self.portfolio.transact_position( + "BOT", "AMZN", 100, + PriceParser.parse(566.56), PriceParser.parse(1.00) + ) + self.portfolio.transact_position( + "BOT", "AMZN", 200, + PriceParser.parse(566.395), PriceParser.parse(1.00) + ) + # Buy 200 GOOG over one transaction + self.portfolio.transact_position( + "BOT", "GOOG", 200, + PriceParser.parse(707.50), PriceParser.parse(1.00) + ) + # Add to the AMZN position by 100 shares + self.portfolio.transact_position( + "SLD", "AMZN", 100, + PriceParser.parse(565.83), PriceParser.parse(1.00) + ) + # Add to the GOOG position by 200 shares + self.portfolio.transact_position( + "BOT", "GOOG", 200, + PriceParser.parse(705.545), PriceParser.parse(1.00) + ) + # Sell 200 of the AMZN shares + self.portfolio.transact_position( + "SLD", "AMZN", 200, + PriceParser.parse(565.59), PriceParser.parse(1.00) + ) + # Multiple transactions bundled into one (in IB) + # Sell 300 GOOG from the portfolio + self.portfolio.transact_position( + "SLD", "GOOG", 100, + PriceParser.parse(704.92), PriceParser.parse(1.00) + ) + self.portfolio.transact_position( + "SLD", "GOOG", 100, + PriceParser.parse(704.90), PriceParser.parse(0.00) + ) + self.portfolio.transact_position( + "SLD", "GOOG", 100, + PriceParser.parse(704.92), PriceParser.parse(0.50) + ) + # Finally, sell the remaining GOOG 100 shares + self.portfolio.transact_position( + "SLD", "GOOG", 100, + PriceParser.parse(704.78), PriceParser.parse(1.00) + ) + + # The figures below are derived from Interactive Brokers + # demo account using the above trades with prices provided + # by their demo feed. + self.assertEqual(len(self.portfolio.positions), 0) + self.assertEqual(len(self.portfolio.closed_positions), 2) + self.assertEqual(PriceParser.display(self.portfolio.cur_cash), 499100.50) + self.assertEqual(PriceParser.display(self.portfolio.equity), 499100.50) + self.assertEqual(PriceParser.display(self.portfolio.unrealised_pnl), 0.00) + self.assertEqual(PriceParser.display(self.portfolio.realised_pnl), -899.50) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ig_portfolio_handler.py b/tests/test_ig_portfolio_handler.py new file mode 100644 index 00000000..cf30a90f --- /dev/null +++ b/tests/test_ig_portfolio_handler.py @@ -0,0 +1,134 @@ +import datetime +import unittest +from decimal import Decimal + +from qstrader.ig_handler import PortfolioHandler +from qstrader.compat import queue +from qstrader.event import FillEvent, OrderEvent, SignalEvent +from qstrader.price_handler.base import AbstractTickPriceHandler + + +class PriceHandlerMock(AbstractTickPriceHandler): + def __init__(self): + pass + + def get_best_bid_ask(self, ticker): + prices = { + "MSFT": (Decimal("50.28"), Decimal("50.31")), + "GOOG": (Decimal("705.46"), Decimal("705.46")), + "AMZN": (Decimal("564.14"), Decimal("565.14")), + } + return prices[ticker] + + +class PositionSizerMock(object): + def __init__(self): + pass + + def size_order(self, portfolio, initial_order): + """ + This PositionSizerMock object simply modifies + the quantity to be 100 of any share transacted. + """ + initial_order.quantity = 100 + return initial_order + + +class RiskManagerMock(object): + def __init__(self): + pass + + def refine_orders(self, portfolio, sized_order): + """ + This RiskManagerMock object simply lets the + sized order through, creates the corresponding + OrderEvent object and adds it to a list. + """ + order_event = OrderEvent( + sized_order.ticker, + sized_order.action, + sized_order.quantity + ) + return [order_event] + + +class TestSimpleSignalOrderFillCycleForPortfolioHandler(unittest.TestCase): + """ + Tests a simple Signal, Order and Fill cycle for the + PortfolioHandler. This is, in effect, a sanity check. + """ + def setUp(self): + """ + Set up the PortfolioHandler object supplying it with + $500,000.00 USD in initial cash. + """ + initial_cash = Decimal("500000.00") + events_queue = queue.Queue() + price_handler = PriceHandlerMock() + position_sizer = PositionSizerMock() + risk_manager = RiskManagerMock() + # Create the PortfolioHandler object from the rest + self.portfolio_handler = PortfolioHandler( + initial_cash, events_queue, price_handler, + position_sizer, risk_manager + ) + + def test_create_order_from_signal_basic_check(self): + """ + Tests the "_create_order_from_signal" method + as a basic sanity check. + """ + signal_event = SignalEvent("MSFT", "BOT") + order = self.portfolio_handler._create_order_from_signal(signal_event) + self.assertEqual(order.ticker, "MSFT") + self.assertEqual(order.action, "BOT") + self.assertEqual(order.quantity, 0) + + def test_place_orders_onto_queue_basic_check(self): + """ + Tests the "_place_orders_onto_queue" method + as a basic sanity check. + """ + order = OrderEvent("MSFT", "BOT", 100) + order_list = [order] + self.portfolio_handler._place_orders_onto_queue(order_list) + ret_order = self.portfolio_handler.events_queue.get() + self.assertEqual(ret_order.ticker, "MSFT") + self.assertEqual(ret_order.action, "BOT") + self.assertEqual(ret_order.quantity, 100) + + def test_convert_fill_to_portfolio_update_basic_check(self): + """ + Tests the "_convert_fill_to_portfolio_update" method + as a basic sanity check. + """ + fill_event_buy = FillEvent( + datetime.datetime.utcnow(), "MSFT", "BOT", + 100, "ARCA", Decimal("50.25"), Decimal("1.00") + ) + self.portfolio_handler._convert_fill_to_portfolio_update(fill_event_buy) + # Check the Portfolio values within the PortfolioHandler + port = self.portfolio_handler.portfolio + self.assertEqual(port.cur_cash, Decimal("494974.00")) + + # TODO: Finish this off and check it works via Interactive Brokers + fill_event_sell = FillEvent( + datetime.datetime.utcnow(), "MSFT", "SLD", + 100, "ARCA", Decimal("50.25"), Decimal("1.00") + ) + self.portfolio_handler._convert_fill_to_portfolio_update(fill_event_sell) + + def test_on_signal_basic_check(self): + """ + Tests the "on_signal" method as a basic sanity check. + """ + signal_event = SignalEvent("MSFT", "BOT") + self.portfolio_handler.on_signal(signal_event) + ret_order = self.portfolio_handler.events_queue.get() + self.assertEqual(ret_order.ticker, "MSFT") + self.assertEqual(ret_order.action, "BOT") + self.assertEqual(ret_order.quantity, 100) + + +if __name__ == "__main__": + unittest.main()