diff --git a/python/fastquant/strategies/base.py b/python/fastquant/strategies/base.py old mode 100644 new mode 100755 index b8bc4d6d..dd6587eb --- a/python/fastquant/strategies/base.py +++ b/python/fastquant/strategies/base.py @@ -10,6 +10,8 @@ from pkg_resources import resource_filename import datetime import sys +import warnings +import logging # Import modules import backtrader as bt @@ -22,6 +24,7 @@ from fastquant.notification import trigger_bot import croniter + from fastquant.config import ( INIT_CASH, COMMISSION_PER_TRANSACTION, @@ -30,14 +33,60 @@ SELL_PROP, SHORT_MAX, ) +from fastquant.utils.strategy_logging import StrategyLogger class BaseStrategy(bt.Strategy): """ Base Strategy template for all strategies to be added to fastquant + + Attributes + ---------- + init_cash : float + inital cash + buy_prop : float + buy proportion, percentage of available units to buy, setting to 1.0 buys all afforded units + sell_prop : float + sell proportion, percentage of owned units to sell + commission: float + Percentage of comission + stop_loss : float + stop loss percent + stop_trail : float + stop trail percent + execution_type : str {`market`,`close`} (default=`market`) + If set to `market`, excecutes the action (buy or sell) at current bar (or day), + If set to `close`, executees the action on the next available bar + allow_short : bool (default=False) + allow short selling + short_max : float + The max incremental short allowed is the short that would lead to a cumulative short position + equal to the maximum short position (initial cash times the maximum short ratio, which is 1.5 by default) + add_cash_amount : int (default=0) + Added cash amount for periodic cash-ins. (can be used in cost averaging) + add_cash_frequency : str {`W` or `M`} (default=`W`) + Frequency of when to add cash (weekly or monthly) + channel: str (default=`""`) + Notification channel + symbol: str + Symbol of the asset traded + + Renamed in code: + init_cash :initial_cash + buy_prop :buy_proportion + sell_prop :sell_proportion + stop_loss :stop_loss_percent + stop_trail :stop_trail_percent + periodic_logging + strategy_logging + allow_short : allow_short_selling + short_max : max_short_ratio + add_cash_freq : + add_cash_frequency + """ - # Strategy level arguments + # Set default parameters # After initialization, the `params` variable becomes accessible as an attribute of the strategy object # with the properties of a `named tuple` params = ( @@ -48,11 +97,16 @@ class BaseStrategy(bt.Strategy): ("commission", COMMISSION_PER_TRANSACTION), ("stop_loss", 0), # Zero means no stop loss ("stop_trail", 0), # Zero means no stop loss +<<<<<<< HEAD + # Either open or close, to indicate if a purchase is executed based on the next open or close + ("execution_type", "market"), +======= ("take_profit", 0), # Zero means no take profit ( "execution_type", "close", ), # Either open or close, to indicate if a purchase is executed based on the next open or close +>>>>>>> master ("periodic_logging", False), ("transaction_logging", True), ("strategy_logging", True), @@ -65,29 +119,11 @@ class BaseStrategy(bt.Strategy): ("invest_div", True), ) - def log(self, txt, dt=None): - dt = dt or self.datas[0].datetime.date(0) - print("%s, %s" % (dt.isoformat(), txt)) - - def update_order_history(self, order): - self.order_history["dt"].append(self.datas[0].datetime.date(0)) - self.order_history["type"].append("buy" if order.isbuy() else "sell") - self.order_history["price"].append(order.executed.price) - self.order_history["size"].append(order.executed.size) - self.order_history["value"].append(order.executed.value) - self.order_history["commission"].append(order.executed.comm) - self.order_history["pnl"].append(order.executed.pnl) - - def update_periodic_history(self): - self.periodic_history["dt"].append(self.datas[0].datetime.date(0)) - self.periodic_history["portfolio_value"].append(self.broker.getvalue()) - self.periodic_history["cash"].append(self.broker.getcash()) - def __init__(self): - # Global variables - self.init_cash = self.params.init_cash - self.buy_prop = self.params.buy_prop - self.sell_prop = self.params.sell_prop + + self.initial_cash = self.params.init_cash + self.buy_proportion = self.params.buy_prop + self.sell_proportion = self.params.sell_prop self.execution_type = self.params.execution_type self.periodic_logging = self.params.periodic_logging self.transaction_logging = self.params.transaction_logging @@ -95,31 +131,45 @@ def __init__(self): self.fractional = self.params.fractional self.commission = self.params.commission self.channel = self.params.channel +<<<<<<< HEAD + self.stop_loss_percent = self.params.stop_loss + self.stop_trail_percent = self.params.stop_trail + self.allow_short_selling = self.params.allow_short + self.max_short_ratio = self.params.short_max + +======= self.stop_loss = self.params.stop_loss self.stop_trail = self.params.stop_trail self.take_profit = self.params.take_profit self.allow_short = self.params.allow_short self.short_max = self.params.short_max self.invest_div = self.params.invest_div +>>>>>>> master self.broker.set_coc(True) - add_cash_freq = self.params.add_cash_freq + add_cash_frequency = self.params.add_cash_freq # Longer term, we plan to add `freq` like notation, similar to pandas datetime # https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html # M means add cash at the first day of each month - if add_cash_freq == "M": - self.add_cash_freq = "0 0 1 * *" + if add_cash_frequency == "M": + self.add_cash_frequency = "0 0 1 * *" # W means add cash once a week on monday - elif add_cash_freq == "W": - self.add_cash_freq = "0 0 * * 1" + elif add_cash_frequency == "W": + self.add_cash_frequency = "0 0 * * 1" # Otherwise, it assumes the input is in cron notation (no change) else: - self.add_cash_freq = add_cash_freq + self.add_cash_frequency = add_cash_frequency self.add_cash_amount = self.params.add_cash_amount # Attribute that tracks how much cash was added over time self.total_cash_added = 0 +<<<<<<< HEAD + # Create logging handler + self.logging = StrategyLogger(self) + self.logging.strategy_argments() + +======= if self.strategy_logging: self.log("===Global level arguments===") self.log("init_cash : {}".format(self.init_cash)) @@ -144,6 +194,7 @@ def __init__(self): "portfolio_value": [], "cash": [], } +>>>>>>> master self.order_history_df = None self.periodic_history_df = None @@ -171,11 +222,18 @@ def __init__(self): # Initialize stoploss trail order self.stoploss_trail_order = None + # Warn if user forgets to set a value to commission + if self.commission == 0: + warnings.warn( + "Comission value is set to 0. This might yield unrealistic results. Set using the `commission` paramter in `backtest()`", + UserWarning, + ) + def buy_signal(self): - return True + return False # Safer to set default action to do nothing def sell_signal(self): - return True + return False # Safer to set default action to do nothing def take_profit_signal(self): return True @@ -185,8 +243,7 @@ def notify_order(self, order): return if order.status in [order.Completed]: - # Update order history whenever an order is completed - self.update_order_history(order) + if order.isbuy(): self.action = "buy" self.buyprice = order.executed.price @@ -196,59 +253,58 @@ def notify_order(self, order): self.action = "sell" self.bar_executed = len(self) + self.logging.order_executed(order, action=self.action.upper()) - if self.transaction_logging: - self.log( - "%s EXECUTED, Price: %.2f, Cost: %.2f, Comm: %.2f, Size: %.2f" - % ( - self.action.upper(), - order.executed.price, - order.executed.value, - order.executed.comm, - order.executed.size, - ) - ) + # Update order history whenever an order is completed + self.logging.update_order_history(order, self.execution_type) elif order.status in [order.Canceled, order.Margin, order.Rejected]: - if self.transaction_logging: - if not self.periodic_logging: - self.log("Cash %s Value %s" % (self.cash, self.value)) - self.log("Order Canceled/Margin/Rejected") - self.log("Canceled: {}".format(order.status == order.Canceled)) - self.log("Margin: {}".format(order.status == order.Margin)) - self.log("Rejected: {}".format(order.status == order.Rejected)) + self.logging.order_aborted(order) # Write down: no pending order self.order = None def notify_trade(self, trade): - if not trade.isclosed: + if trade.isclosed: + self.logging.trade_closed(trade) + else: return +<<<<<<< HEAD +======= if self.transaction_logging: self.log( "OPERATION PROFIT, GROSS: %.2f, NET: %.2f" % (trade.pnl, trade.pnlcomm) ) +>>>>>>> master def notify_cashvalue(self, cash, value): # Update cash and value every period - if self.periodic_logging: - self.log("Cash %s Value %s" % (cash, value)) self.cash = cash self.value = value + self.logging.cash_value() def stop(self): # Saving to self so it's accessible later during optimization self.final_value = self.broker.getvalue() # Note that PnL is the final portfolio value minus the initial cash balance minus the total cash added self.pnl = round( +<<<<<<< HEAD + self.final_value - self.initial_cash - self.total_cash_added, 2 +======= self.final_value - self.init_cash - self.total_cash_added, 2 +>>>>>>> master ) if self.strategy_logging: - self.log("Final Portfolio Value: {}".format(self.final_value)) - self.log("Final PnL: {}".format(self.pnl)) - self.order_history_df = pd.DataFrame(self.order_history) - self.periodic_history_df = pd.DataFrame(self.periodic_history) + self.logging.log( + "Final Portfolio Value: {}".format(self.final_value) + ) + self.logging.log("Final PnL: {}".format(self.pnl)) + + # Create dataframe from logging module + self.order_history_df = pd.DataFrame(self.logging.order_history) + self.periodic_history_df = pd.DataFrame(self.logging.periodic_history) + self.trade_history_df = pd.DataFrame(self.logging.trade_history) last_date = str(self.datas[0].datetime.date(0)) if self.channel: @@ -262,6 +318,13 @@ def start(self): # Used to signal setting the first iteration self.first_timepoint = True +<<<<<<< HEAD + def periodic_cash_in(self): + + if self.first_timepoint: + # Change state to indicate that the cash date iterator has been set + self.first_timepoint = False +======= def next(self): # add dividend to cash @@ -286,17 +349,37 @@ def next(self): self.next_cash_datetime.strftime("%Y-%m-%d") ) ) +>>>>>>> master - # Change state to indicate that the cash date iterator has been set - self.first_timepoint = False + # Initialize income date iterator, and set next + start_date = self.datas[0].datetime.datetime(0) + self.cron = croniter.croniter(self.add_cash_frequency, start_date) + self.next_cash_datetime = self.cron.get_next(datetime.datetime) - # Add cash to broker if date is same or later to the next income date - # This means if the dataset is only for weekdays, a date on a weekend will be executed on the next monday - if self.datas[0].datetime.datetime(0) >= self.next_cash_datetime: - self.broker.add_cash(self.add_cash_amount) - self.next_cash_datetime = self.cron.get_next(datetime.datetime) - self.total_cash_added += self.add_cash_amount + self.logging.initialize_periodic_cash_in( + start_date, self.next_cash_datetime + ) +<<<<<<< HEAD + # Add cash to broker if date is same or later to the next income date + # This means if the dataset is only for weekdays, a date on a weekend will be executed on the next monday + if self.datas[0].datetime.datetime(0) >= self.next_cash_datetime: + self.broker.add_cash(self.add_cash_amount) + self.next_cash_datetime = self.cron.get_next(datetime.datetime) + self.total_cash_added += self.add_cash_amount + + self.logging.periodic_cash_in( + self.add_cash_amount, + self.total_cash_added, + self.next_cash_datetime, + ) + + def next(self): + if self.add_cash_amount > 0: + self.periodic_cash_in() # Trigger only when amount is specified + + self.logging.update_periodic_history() # dt, position_value, position_size, cash, +======= if self.transaction_logging: self.log("Cash added: {}".format(self.add_cash_amount)) self.log( @@ -307,23 +390,88 @@ def next(self): self.next_cash_datetime.strftime("%Y-%m-%d") ) ) +>>>>>>> master - self.update_periodic_history() - if self.periodic_logging: - self.log("Close, %.2f" % self.dataclose[0]) if self.order: return - if self.periodic_logging: - self.log("CURRENT POSITION SIZE: {}".format(self.position.size)) - # Skip the last observation since purchases are based on next day closing prices (no value for the last observation) if len(self) + 1 >= self.len_data: return - # Only sell if you hold least one unit of the stock (and sell only that stock, so no short selling) - stock_value = self.value - self.cash + # Allow for backwards compatibilty; older strategies return only the signal, new strategies can return both signal and buy/sell proportion + buy_signal_response = self.buy_signal() + sell_signal_response = self.sell_signal() + if type(buy_signal_response) == bool: + buy_signal, buy_prop = buy_signal_response, None + else: + buy_signal, buy_prop = buy_signal_response + +<<<<<<< HEAD + if type(sell_signal_response) == bool: + sell_signal, sell_prop = sell_signal_response, None + else: + sell_signal, sell_prop = sell_signal_response + + if buy_signal: + self.create_buy_order(buy_prop) + # self.action will be set order is completed + + elif sell_signal: + self.create_sell_order(sell_prop) + # self.action will be set order is completed + + else: + self.action = "neutral" + + def create_buy_order(self, buy_proportion=None): + """ + Returns + ------- + order + """ + + # Buy based on the current closing price + if self.execution_type == "market": + market_price = self.dataclose[0] + + # Buy based on the next closing price + elif self.execution_type == "close": + market_price = self.dataclose[1] + + afforded_size = int(self.cash / (market_price * (1 + self.commission))) + # Compute number of units to buy based on the proportion + if buy_proportion is None: + buy_proportion_size = int(afforded_size * self.buy_proportion) + else: + buy_proportion_size = int(afforded_size * buy_proportion) + + final_size = min(buy_proportion_size, afforded_size) + + # Log that buy signal is triggered + self.logging.buy_signal( + self.cash, + market_price, + buy_proportion_size, + afforded_size, + final_size, + ) + + # Buy only if final size is > 0 + if final_size > 0: + + # Buy on today's closing price + if self.execution_type == "market": + self.order = self.buy( + size=final_size, exectype=bt.Order.Market + ) + self.logging.order_created( + order_type_str="buy", + text="Order type: %s Price: %.2f" + % (self.execution_type, market_price), + ) +======= # Only buy if there is enough cash for at least one stock if self.buy_signal(): # Alternative for fractional condition based in min amount of significant value: @@ -471,11 +619,135 @@ def next(self): self.order = self.sell( size=min(sell_prop_size, max_position_size) ) +>>>>>>> master + + # Buy on tomorrow's closing price + elif self.execution_type == "close": + self.order = self.buy(size=final_size, exectype=bt.Order.Close) + self.logging.order_created( + order_type_str="buy", + text="Order type: %s Price: %.2f" + % (self.execution_type, market_price), + ) - elif stock_value > 0: + # create stop loss order + if self.stop_loss_percent: + stop_price = market_price * (1.0 - self.stop_loss_percent) + self.sell( + exectype=bt.Order.Stop, + price=stop_price, + size=size, + ) + self.logging.order_created( + order_type_str="buy", + text="Order type: %s Price: %.2f" + % ("Stop Order", stop_price), + ) + # create trailing stop order + if self.stop_trail_percent: + self.sell( + exectype=bt.Order.StopTrail, + trailpercent=self.stop_trail_percent, + size=final_size, + ) + self.logging.order_created( + order_type_str="buy", + text="Order type: %s Trail Percent: %.2f" + % ("Trailing Stop", self.stop_trail_percent), + ) + + def create_short_sell_order(self, sell_proportion=None): + # Sell short based on the closing price of the previous day + if self.execution_type == "close": + + sell_proportion_size = int( + self.sell_proportion + * self.broker.getvalue() + / self.dataclose[1] + ) + # The max incremental short allowed is the short that would lead to a cumulative short position + # equal to the maximum short position (initial cash times the maximum short ratio, which is 1.5 by default) + max_position_size = max( + int( + self.broker.getvalue() + * self.max_short_ratio + / self.dataclose[1] + ) + + self.position.size, + 0, + ) + if max_position_size > 0: if self.transaction_logging: self.log("SELL CREATE, %.2f" % self.dataclose[1]) +<<<<<<< HEAD + self.order = self.sell( + size=min(sell_proportion_size, max_position_size) + ) + + # Buy based on the opening price of the next closing day (only works "open" data exists in the dataset) + else: + + sell_proportion_size = int( + self.sell_proportion + * self.broker.getvalue() + / self.dataopen[1] + ) + # The max incremental short allowed is the short that would lead to a cumulative short position + # equal to the maximum short position (initial cash times the maximum short ratio, which is 1.5 by default) + max_position_size = max( + int( + self.broker.getvalue() + * self.max_short_ratio + / self.dataopen[1] + ) + + self.position.size, + 0, + ) + if max_position_size > 0: + if self.transaction_logging: + self.log("SELL CREATE, %.2f" % self.dataopen[1]) + self.order = self.sell( + size=min(sell_proportion_size, max_position_size) + ) + + def create_sell_order(self, sell_proportion=None): + + if self.allow_short_selling: + self.create_short_sell_order(sell_proportion) + + # Only sell if you hold least one unit of the stock (and sell only that stock, so no short selling) + elif self.position.size > 0: + + # Verify that you could actually sell based on the sell proportion and the units owned + if sell_proportion is None: + sell_proportion = self.sell_proportion + + sell_proportion_size = int(self.position.size * sell_proportion) + + # Log that sell signal was triggered + self.logging.sell_signal( + size=sell_proportion_size, + price=( + self.dataclose[0] + if self.execution_type == "market" + else self.dataclose[1] + ), + ) + + if sell_proportion_size > 0: + + # Sell on current closing price + if self.execution_type == "market": + self.order = self.sell( + size=sell_proportion_size, exectype=bt.Order.Market + ) + sell_price = self.dataclose[0] + self.logging.order_created( + order_type_str="sell", + text="Order type: %s Price: %.2f" + % (self.execution_type, sell_price), +======= # Sell a 5% sell position (or whatever is afforded by the current stock holding) # "size" refers to the number of stocks to purchase if self.execution_type == "close": @@ -519,7 +791,17 @@ def next(self): if self.data.close[0] >= price_limit: self.sell( exectype=bt.Order.Close, price=price_limit, size=self.position.size, +>>>>>>> master ) - else: - self.action = "neutral" + # Sell on next closing price + elif self.execution_type == "close": + self.order = self.sell( + size=sell_proportion_size, exectype=bt.Order.Close + ) + sell_price = self.dataclose[1] + self.logging.order_created( + order_type_str="sell", + text="Order type: %s Price: %.2f" + % (self.execution_type, sell_price), + ) diff --git a/python/fastquant/utils/strategy_logging.py b/python/fastquant/utils/strategy_logging.py new file mode 100755 index 00000000..8d8ae339 --- /dev/null +++ b/python/fastquant/utils/strategy_logging.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +import logging +import sys + + +class StrategyLogger: + + # """ + # Handles the logging throughout the strategy life-cycle. It is called within the BaseStrategy class + # Responsible for the log messages + + # """ + + def __init__(self, strategy): + self.strategy = strategy + self.order_history = { + "dt": [], + "type": [], + "price": [], + "size": [], + "value": [], + "commission": [], + "pnl": [], + } + self.periodic_history = { + "dt": [], + "portfolio_value": [], + "position_size": [], + "cash": [], + } + self.trade_history = {"dt": [], "pnl": [], "pnl_comm": []} + + self.periodic_logging = strategy.periodic_logging + self.transaction_logging = strategy.transaction_logging + + # Configure logging module + logging.basicConfig( + level=logging.INFO, # Log everything above DEBUG level + format="%(message)s", # Use display only the message + stream=sys.stdout, # use output stream instead of the default error stream (could also be set to a file output) + ) + + def log(self, txt, dt=None, level=logging.INFO): + try: + dt = dt or self.strategy.datas[0].datetime.date(0) + dt = dt.isoformat() + except: + dt = "" + + if level == logging.DEBUG: + logging.debug("%s, %s" % (dt, txt)) + elif level == logging.INFO: + logging.info("%s, %s" % (dt, txt)) + elif level == logging.WARNING: + logging.warn("%s, %s" % (dt, txt)) + + def strategy_argments(self): + strategy = self.strategy + + if self.transaction_logging or self.periodic_logging: + self.log("===Global level arguments===") + self.log("init_cash : {}".format(strategy.initial_cash)) + self.log("buy_prop : {}".format(strategy.buy_proportion)) + self.log("sell_prop : {}".format(strategy.sell_proportion)) + self.log("commission : {}".format(strategy.commission)) + self.log( + "stop_loss_percent : {}".format(strategy.stop_loss_percent) + ) + self.log( + "stop_trail_percent : {}".format(strategy.stop_trail_percent) + ) + + def order_created(self, order_type_str, text): + + if self.transaction_logging: + self.log("ORDER CREATED: %s, %s" % (order_type_str.upper(), text)) + + def order_executed(self, order, action): + if self.transaction_logging: + self.log( + "ORDER EXECUTED %s , Price: %.2f, Cost: %.2f, Comm: %.2f, Size: %.2f" + % ( + action.upper(), + order.executed.price, + order.executed.value, + order.executed.comm, + order.executed.size, + ) + ) + + def order_aborted(self, order): + if self.transaction_logging: + self.log("Order Canceled/Margin/Rejected") + self.log("Canceled: {}".format(order.status == order.Canceled)) + self.log("Margin: {}".format(order.status == order.Margin)) + self.log("Rejected: {}".format(order.status == order.Rejected)) + self.log( + "Cash %s Value %s" % (self.strategy.cash, self.strategy.value) + ) + + def trade_closed(self, trade): + if self.transaction_logging: + self.log( + "TRADE CLOSED: OPERATION PROFIT, GROSS: %.2f, NET: %.2f" + % (trade.pnl, trade.pnlcomm) + ) + + def cash_value(self): + if self.periodic_logging: + self.log( + "Cash %.2f Value %.2f" + % (self.strategy.cash, self.strategy.value) + ) + + def initialize_periodic_cash_in(self, start_date, next_cash_datetime): + if self.transaction_logging: + self.log("Start date: {}".format(start_date.strftime("%Y-%m-%d"))) + self.log( + "Next cash date: {}".format( + next_cash_datetime.strftime("%Y-%m-%d") + ) + ) + + def periodic_cash_in( + self, add_cash_amount, total_cash_added, next_cash_datetime + ): + if self.transaction_logging: + self.log("Cash added: {}".format(add_cash_amount)) + self.log("Total cash added: {}".format(total_cash_added)) + self.log( + "Next cash date: {}".format( + next_cash_datetime.strftime("%Y-%m-%d") + ) + ) + + def buy_signal( + self, + cash, + market_price, + buy_proportion_size, + afforded_size, + final_size, + ): + if self.transaction_logging: + self.log("BUY SIGNAL GENERATED") + self.log("Cash: {}".format(cash)) + self.log("Price: {}".format(market_price)) + self.log("Buy prop size: {}".format(buy_proportion_size)) + self.log("Afforded size: {}".format(afforded_size)) + self.log("Final size: {}".format(final_size)) + + def sell_signal(self, size, price): + if self.transaction_logging: + self.log("SELL SIGNAL GENERATED") + self.log("Sell Size: {}".format(size)) + self.log("Sell Price: {}".format(price)) + + def update_order_history(self, order, order_execution_type="close"): + if order_execution_type == "close": + self.order_history["dt"].append( + self.strategy.datas[0].datetime.date(0) + ) + else: # for market order, use previous day (same day as the signal was generated) + self.order_history["dt"].append( + self.strategy.datas[0].datetime.datetime(-1) + ) + self.order_history["type"].append("buy" if order.isbuy() else "sell") + self.order_history["price"].append(order.executed.price) + self.order_history["size"].append(order.executed.size) + self.order_history["value"].append(order.executed.value) + self.order_history["commission"].append(order.executed.comm) + self.order_history["pnl"].append(order.executed.pnl) + + def update_periodic_history(self): + self.periodic_history["dt"].append( + self.strategy.datas[0].datetime.date(0) + ) + self.periodic_history["portfolio_value"].append( + self.strategy.broker.getvalue() + ) + self.periodic_history["position_size"].append( + self.strategy.position.size + ) + self.periodic_history["cash"].append(self.strategy.broker.getcash()) + + def update_trade_history(self, pnl, pnl_comm): + self.trade_history["dt"].append( + self.strategy.datas[0].datetime.date(0) + ) + self.trade_history["pnl"].append(pnl) + self.trade_history["pnl_comm"].append(pnl_comm)