Reinvestment Strategy#
By using the ReinvestmentStrategy
class, you can easily create an exposure to a specific stock and handle all the corporate actions as they occur.
Amongst other things, the Reinvestment Strategy will process the following corporate actions:
Cash dividends (both regular and special cash)
Stock dividends
Stock splits
Spinoffs
Rights issues
Mergers/takeover
Learn more: Example notebooks
Sections#
Set up the environment#
Learn more: [Setting up an environment](/essentials/environment-setup
import sigtech.framework as sig
import datetime as dtm
import uuid
import seaborn as sns
import pandas as pd
sns.set(rc={'figure.figsize': (18, 6)})
start_date = dtm.date(2019, 1, 2)
end_date = dtm.date(2022, 10, 31)
sig.init(env_date=end_date)
Stock prices#
Fetch the SingleStock
object for the primary listing of Apple in the US.
Learn more: how to find stock internal identifiers within the platform, on the SingleStock
and EquityUniverseFilter
pages.
ticker = '1000045.SINGLE_STOCK.TRADABLE'
stock_obj = sig.obj.get(ticker)
View unadjusted price series:
stock_obj.history(field='LastPrice').plot()
The large drops correspond to stock splits. Corporate action objects can be examined to get more detail.
Input:
ca = stock_obj.corporate_actions()
ca[:10]
Output:
[StockSplit(Stock Split on 2000-06-21),
StockSplit(Stock Split on 2005-02-28),
RegularCashDividend(Quarterly on 2012-08-09),
RegularCashDividend(Quarterly on 2012-11-07),
RegularCashDividend(Quarterly on 2013-02-07),
RegularCashDividend(Quarterly on 2013-05-09),
RegularCashDividend(Quarterly on 2013-08-08),
RegularCashDividend(Quarterly on 2013-11-06),
RegularCashDividend(Quarterly on 2014-02-06),
RegularCashDividend(Quarterly on 2014-05-08)]
Input:
from sigtech.framework.internal.equities.corporate_actions.corporate_actions import StockSplit
stock_splits = [c for c in ca if isinstance(c, StockSplit)]
stock_splits
Output:
[StockSplit(Stock Split on 2000-06-21),
StockSplit(Stock Split on 2005-02-28),
StockSplit(Stock Split on 2014-06-09),
StockSplit(Stock Split on 2020-08-31)]
stock_splits[-2]
It is possible to fetch price series data from SingleStock
objects that handle reinvestment following corporate events with the adjusted_price_history
method.
stock_obj.adjusted_price_history?
The arguments to adjusted_price_history
determine whether regular and special dividends should be reinvested, respectively. Events like stock splits are automatically handled by this method: relevant adjustment factors are computed and then incorporated in building price series.
adjusted_data = pd.concat({
'Unadjusted': stock_obj.history(),
'AdjustedAll': stock_obj.adjusted_price_history(
adjust_regular_cash=True,
adjust_special_cash=True),
'AdjustedRegularCashOnly': stock_obj.adjusted_price_history(True, False),
'AdjustedSpecialOnly': stock_obj.adjusted_price_history(False, True),
'AdjustedNoDivs': stock_obj.adjusted_price_history(False, False),
}, axis=1)
adjusted_data /= adjusted_data.iloc[0]
adjusted_data.plot(title='AAPL Prices Series')
In this case, there are stock splits and regular cash dividends, but no special cash dividends. This is reflected in the values of the adjusted_price_history
series. Regardless of argument choice, the stock splits no longer impact the adjusted price series.
adjusted_data.tail()
The following example using TSLA
shows what happens when no dividends have been received by the holders of the security.
Utilise the SIGMaster
to find the primary US listing of TSLA
:
search_ticker = 'TSLA'
sm = sig.env().sig_master().filter_to_last_pit_record().filter('US', 'EXCHANGE_COUNTRY_ISO2')
sm = sm.filter_exchange_tickers([search_ticker]).filter_primary_fungible().filter_primary_tradable()
tesla_ticker = sm.get_tradable_ids([search_ticker])[search_ticker]
Only one corporate action - August 2020 stock split.
Input:
t = sig.obj.get(tesla_ticker)
ca_tesla = t.corporate_actions()
ca_tesla
Output:
[StockSplit(Stock Split on 2020-08-31)]
ca_tesla[0]
adjusted_data = pd.concat({
'Unadjusted': t.history(),
'AdjustedAll': t.adjusted_price_history(True, True),
'AdjustedRegularCashOnly': t.adjusted_price_history(True, False),
'AdjustedSpecialOnly': t.adjusted_price_history(False, True),
'AdjustedNoDivs': t.adjusted_price_history(False, False),
}, axis=1)
adjusted_data /= adjusted_data.iloc[0]
As no dividends have been paid by TSLA, altering the input arguments to adjusted_price_history
does not impact the resulting time series. As expected, the adjusted price series does not show a division by the split factor (5) following the August 2020 stock split.
adjusted_data.tail()
The ratio of the final adjusted/unadjusted prices is exactly the stock split ratio of 5:
Input:
final_price = adjusted_data.iloc[-1]
round(final_price['AdjustedAll'] / final_price['Unadjusted'], 5)
Output:
5.0
ReinvestmentStrategy
objects#
The ReinvestmentStrategy building block creates objects that are tradable and accurately mimics the experience of an Equity Portfolio Manager.
Whilst SingleStock historical price series data is not adjusted for corporate actions, such as stock splits, and does not assume reinvestment of dividends, these adjusted price series are available from ReinvestmentStrategy objects. ReinvestmentStrategy objects can also account for transaction costs, while the adjusted_price_history
method does not.
To build a simple basket trading a SingleStock object (to illustrate the problem with trading these directly):
stock_basket = sig.BasketStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
weights=[1.0],
constituent_names=[stock_obj.name],
rebalance_dates=[start_date],
instant_sizing=True
)
stock_basket.history().plot()
Overview of the ReinvestmentStrategy
class
sig.ReinvestmentStrategy?
Creation of a simple ReinvestmentStrategy
(‘RS’ henceforth).
rs = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
ticker=f'{ticker}_RS',
)
As with any strategy object, calling build
triggers the backtest specified by the object’s parameters to be run. It is then possible to view strategy performance through calling history
.
Input:
rs.build()
rs.history().head()
Output:
2019-01-02 1000.000000
2019-01-03 999.166274
2019-01-04 1037.609979
2019-01-07 1035.540069
2019-01-08 1053.403799
Name: (LastPrice, EOD, 1000045.SINGLE_STOCK.TRADABLE_RS STRATEGY), dtype: float64
The days displayed in the strategy’s history
are determined by its holiday calendar. The holiday calendar can be obtained from the underlying stock.
Input:
underlyer_holidays = rs.underlyer_object.holidays()
underlyer_holidays
Output:
'NYSE(T) CALENDAR'
Input:
rs_holidays = [
d for d in sig.obj.get(underlyer_holidays).holidays
if (d.year >= start_date.year) & (d.year <= end_date.year)
]
[d for d in rs_holidays if d in rs.history()]
Output:
[]
The following code block is a comparison between the RS and the SingleStock object - adjusted and unadjusted.
df = pd.concat({
'SingleStock Unadjusted': stock_obj.history(),
'SingleStock Adjusted': stock_obj.adjusted_price_history(True, True),
'SingleStock Basket': stock_basket.history(),
'RS': rs.history(),
}, axis=1)
df = df.dropna().iloc[1:]
df /= df.iloc[0]
df.plot();
Notes:
Since the RS has been defined to begin in 2019, whereas the history of the stock object starts in 2000, it was necessary to drop nans when combining histories.
The time series were rescaled to all begin at 1: the value of any Strategy object will begin at the level of its
initial_cash
parameter, which defaults to1000
.The first day of history of the RS was ignored as orders need to be placed and executed before the strategy attains its exposure to the underlying. Since this is not completed until 03/01/2019, the first return of the RS is essentially that due to cash. Learn more about customising this behaviour.
Relationship to Strategy class#
ReinvestmentStrategy
inherits from the Strategy
class and inherits its fields, methods, and more.
Input:
issubclass(sig.ReinvestmentStrategy, sig.Strategy)
Output:
True
In particular, it can utilise the PlotWrapper
and AnalyticsWrapper
components of a strategy:
Input:
plot_wrapper = rs.plot
plot_wrapper
Output:
<sigtech.framework.strategies.components.plot_wrapper.PlotWrapper at 0x7fd24ce10fd0>
Input:
analytics_wrapper = rs.analytics
analytics_wrapper
Output:
<sigtech.framework.strategies.components.analytics_wrapper.AnalyticsWrapper at 0x7fd24cee7bd0>
plot_wrapper.performance()
It can also utilise parameters defined at the strategy level, such as:
direction
initial_cash
include_trading_costs
total_return
Strategy details and parameters#
Analysing the basic strategy at the level of TOP_POSITION_CHG_PTS
:
Generation of an initial order on the underlying stock [2019/01/03, 09:00:00].
Execution of the initial order on the underlying stock [2019/01/03, 16:00:00].
Cash dividend going ex and being considered part of ‘UNREALISED CASH’ [2019/02/08, 09:00:00].
Generation of an order to reinvest dividends paid by the stock back into the stock [2019/02/14, 09:00:00].
Execution of the above order to reinvest dividend distribution [2019/02/14, 16:00:00].
To ensure that strategy action times are displayed locally, set timezone to be that of the security.
Note: The default is UTC.
Input:
import pytz
local_timezone = pytz.timezone(stock_obj.exchange().timezone)
local_timezone.zone
Output:
'America/New_York'
rs.plot.portfolio_table(
dts='TOP_POSITION_CHG_PTS',
start_dt=start_date,
end_dt=start_date + dtm.timedelta(days=60),
tzinfo=local_timezone
)
Dividends#
Continuing from the above outline, the position in 1000045.SINGLE_STOCK.TRADABLE UNREALISED CASH
at [2019/02/14, 09:00:00] is dividend proceeds.
ca = rs.underlyer_object.corporate_actions()
first_dividend_effective_dtm = dtm.datetime(2019, 2, 8, 9, tzinfo=local_timezone)
dividend = [c for c in ca \
if c.effective_date == first_dividend_effective_dtm.date()][0]
dividend
Input:
print(f'Effective Date: {dividend.effective_date}')
print(f'Payment Date: {dividend.payment_date}')
Output:
Effective Date: 2019-02-08
Payment Date: 2019-02-14
Verify dividend cash calculation:
stock_position = rs.inspect.positions_df().tz_convert(local_timezone)
stock_position.head()
Input:
units = stock_position.loc[:first_dividend_effective_dtm].iloc[-1, 0]
print(f'Stock Units: {round(units, 3)}')
Output:
Stock units: 6.332
Input:
default_wht = 0.3 # default 30% withholding tax
dividend_cash = units * dividend.dividend_amount * (1 - default_wht)
print(f'Dividend Cash: {round(dividend_cash, 3)}')
Output:
Dividend cash: 3.236
Reinvesting cash dividends#
By default, dividends received are reinvested in the underlying stock. The parameter reinvest_cash_dividends
provides user control over this logic.
rs_no_cdr = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
# specify no cash dividend reinvestment
reinvest_cash_dividends=False,
ticker=f'{ticker}_RS_NO_CDR'
)
rs_no_cdr.build()
With this change, the order to reinvest cash from the dividend on 14th February has disappeared. Instead, the unrealised cash from the dividend effective date becomes part of realised ‘USD CASH’ on the dividend payment date.
rs_no_cdr.plot.portfolio_table(
dts='TOP_POSITION_CHG_PTS',
start_dt=start_date,
end_dt=start_date + dtm.timedelta(days=60),
tzinfo=local_timezone
)
Withholding tax rates#
Withholding tax (or WHT) time series can be accessed from a stock object.
Input:
stock_obj.dividend_withholding_tax_ts()
Output:
1990-01-01 0.3
Name: Dividend Withholding Tax, dtype: float64
A particular withholding tax (WHT) rate can be set by using the withholding_tax_override
parameter.
Create an RS with no WHT:
rs_no_wht = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
# specify no withholding tax rate
withholding_tax_override=0.0,
ticker=f'{ticker}_RS_NO_WHT'
)
rs_no_wht.build()
The following plot shows the ratio of the value of the RS with 30% WHT to the RHS with 0% WHT:
df = pd.concat({
'RS_WHT': rs.history(),
'RS_NO_WHT': rs_no_wht.history(),
}, axis=1)
df['RATIO'] = df['RS_WHT'] / df['RS_NO_WHT']
df['RATIO'].plot(title='Impact of WHT')
rs_no_wht.plot.portfolio_table(
dts='TOP_POSITION_CHG_PTS',
start_dt=start_date,
end_dt=start_date + dtm.timedelta(days=60),
tzinfo=local_timezone
)
The jumps in ratio between the two versions of the RS occur on dividend effective dates.
Fetch regular cash dividends:
from sigtech.framework.internal.equities.corporate_actions.corporate_actions import RegularCashDividend
dividends = [
c for c in ca if isinstance(c, RegularCashDividend)
if c.payment_date >= start_date
]
ratio_changes = sorted(df.RATIO.pct_change().sort_values(). \
head(len(dividends)).index.tolist())
payment_dates = [c.effective_date for c in dividends \
if c.effective_date < end_date]
Tracking#
Revisiting the first Reinvestment Strategy created, the portfolio table shows that the initial position in stock is actually 90.114% [2019/01/03 16:00:00]:
rs.plot.portfolio_table(
dts='TOP_ORDER_PTS',
start_dt=start_date,
end_dt=start_date + dtm.timedelta(days=1),
tzinfo=local_timezone
)
The weight of an instrument held within a strategy, assuming the instrument is in the same currency as the strategy, can be computed as:
units_held x unit_price / strategy_price
Check this calculation directly:
Input:
value_date = pd.Timestamp(2019, 1, 3)
rs_price = rs.valuation_price(value_date)
rs_price
Output:
999.1662739994936
Input:
stock_price = stock_obj.valuation_price(value_date)
stock_price
Output:
142.19001
Input:
stock_obj.history().loc[value_date]
Output:
142.19001
Input:
value_dt = rs.valuation_dt(value_date)
stock_units = stock_position.loc[:value_dt].iloc[-1, 0]
stock_units
Output:
6.332320162107397
Agreement with the above portfolio table:
Input:
print(
f'Stock Exposure: {round(100 * (stock_price * stock_units) / rs_price, 3)}%'
)
Output:
Stock Exposure: 90.114%
The reason this exposure does not exactly match the target exposure of 100% is that the sizing, or determination of the number of shares to trade, is done using the prior day’s close. This closely resembles the experience of a trader with access to EOD prices.
In most cases, the difference between target weight and realised weight would not be this large. In this particular case, AAPL experienced a large negative move on the day of execution (-9.96%).
Input:
100 * stock_obj.history().pct_change().loc[value_date]
Output:
-9.960733282674761
The enable_tracking
parameter will cause an RS to trade toward full exposure on a schedule provided by the user (tracking_frequency
, default SOM
).
An optional tracking_threshold
can also be set (default 1bp
) that controls whether these tracking trades should be executed. For example, the threshold is 1%, the current exposure is 99.5%, so no action takes place.
rs_et = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
# enable tracking
enable_tracking=True,
tracking_frequency='EOM',
tracking_threshold=0.05,
ticker=f'{ticker}_ET_5%_EOM',
)
rs_et.build()
An additional trade is now scheduled on 2019/01/04.
Since this brings the exposure to within the threshold of the target, no further trades are implemented until the dividend is reinvested in February.
rs_et.plot.portfolio_table(
dts='TOP_ORDER_PTS',
start_dt=start_date,
end_dt=start_date + dtm.timedelta(days=60),
tzinfo=local_timezone
)
Setting very low thresholds and a very regular threshold_frequency
can impact performance.
Input:
rs_et_1bd = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
enable_tracking=True,
tracking_frequency='1BD',
tracking_threshold=0.0001,
ticker=f'{ticker}_ET_1bp_1BD',
)
%time rs_et_1bd.build()
Output:
CPU times: user 734 ms, sys: 17 µs, total: 734 ms
Wall time: 732 ms
Input:
rs_et_1m = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
enable_tracking=True,
tracking_frequency='EOM',
tracking_threshold=0.01,
ticker=f'{ticker}_ET_1%_EOM',
)
%time rs_et_1m.build()
Output:
CPU times: user 108 ms, sys: 8.76 ms, total: 117 ms
Wall time: 111 ms
Overriding strategy execution time:
hours, minutes = 21, 56
decision_time = dtm.time(hours, minutes)
size_time = dtm.time(hours, minutes + 1)
execution_time = dtm.time(hours, minutes + 2)
Input:
decision_time
Output:
datetime.time(21, 56)
Input:
size_time
Output:
datetime.time(21, 57)
Input:
execution_time
Output:
datetime.time(21, 58)
local_zone = local_timezone.zone
rs_instant = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
withholding_tax_override=0.0,
size_time_input=size_time,
decision_time_input=decision_time,
execution_time_input=size_time,
size_timezone_input=local_zone,
decision_timezone_input=local_zone,
execution_timezone_input=local_zone,
initial_shares=1,
initial_cash=100,
include_trading_costs=False,
total_return=False,
)
rs_instant.build()
rs_instant.plot.portfolio_table(
dts='TOP_ORDER_PTS',
start_dt=start_date,
end_dt=start_date + dtm.timedelta(days=10),
tzinfo=local_timezone
)
Trading out of stocks that will delist#
An example of a stock that was delisted due to bankruptcy:
Input:
delisted_stock_obj = sig.obj.get('1002181.SINGLE_STOCK.TRADABLE')
delisted_stock_obj.company_name
Output:
'EASTMAN KODAK'
Input:
delisted_stock_obj.expiry_date
Output:
datetime.date(2013, 8, 30)
Input:
delisted_stock_obj.delist_date
Output:
datetime.date(2013, 9, 3)
The final_trade_out
parameter causes a Reinvestment Strategy to sell out of the underlying before it is delisted. The RS will then hold cash.
This can be very useful when another strategy, such as SignalStrategy, is trading a universe of RS. The onus will no longer be on the user to ensure that no trades on the Reinvestment Strategy, corresponding to the delisted security, are initiated.
fto_start_date = dtm.date(2012, 1, 4)
fto_end_date = dtm.date(2014, 1, 4)
rs_no_fto = sig.ReinvestmentStrategy(
start_date=fto_start_date,
end_date=fto_end_date,
currency=delisted_stock_obj.currency,
underlyer=delisted_stock_obj.name,
ticker=f'{delisted_stock_obj.name}_NO_FTO',
)
rs_no_fto.build()
rs_fto = sig.ReinvestmentStrategy(
start_date=fto_start_date,
end_date=fto_end_date,
currency=delisted_stock_obj.currency,
underlyer=delisted_stock_obj.name,
# final trade out
final_trade_out=True,
ticker=f'{delisted_stock_obj.name}_FTO',
)
rs_fto.build()
The version of the RS with final_trade_out
set to False
has nan prices from the delisting date onward, rendering it impossible to trade, which includes the closing of positions in it. The version with final_trade_out
set to True
does not have this problem.
df = pd.concat({
'FTO': rs_fto.history(),
'NO FTO': rs_no_fto.history(),
}, axis=1)
df.loc[delisted_stock_obj.expiry_date:].head()
df.plot()
Borrow costs#
Shorting costs can be specified with the borrow_cost_ts
parameter. The following example assumes there is no borrowing cost:
rs_short = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
ticker=f'{ticker}_RS_SHORT',
# specific to shorting
direction='short',
# cash given opposite sign as we are short
initial_cash=-1000,
borrow_cost_ts=0,
# should not specify dividend reinvestment when short
reinvest_cash_dividends=False,
)
rs_short.build()
The following example uses a fixed annual cost of 1%:
Note: a time series could also be supplied.
rs_short_borrow = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
ticker=f'{ticker}_RS_SHORT_BORROW',
# specific to shorting
direction='short',
# cash given opposite sign as we are short
initial_cash=-1000,
borrow_cost_ts=1,
# should not specify dividend reinvestment when short
reinvest_cash_dividends=False,
)
rs_short_borrow.build()
df = pd.concat({
'Borrow': rs_short_borrow.history(),
'No Borrow': rs_short.history(),
}, axis=1)
df.plot()
rs_short.plot.portfolio_table(
dts='TOP_ORDER_PTS'
)
Trading in swap format#
Exposure to the underlying can also be obtained in swap format, with the relevant parameters being:
trade_swap_format
: equity traded as swap if set toTrue
.swap_reset_frequency
: frequency at which swap is reset, which leads to margin cleanup.swap_include_financing
: financing, as determined by thelibor_instrument_name
andlibor_spread
parameters, is charged ifTrue
.
rs_swap = sig.ReinvestmentStrategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency,
underlyer=ticker,
# swap specific parameters
trade_swap_format=True,
swap_reset_frequency='3M',
swap_include_financing=True,
reinvest_cash_dividends=False,
libor_instrument_name='US0003M INDEX',
libor_spread=0.001,
ticker=f'{ticker}_RS_SWAP_L3M',
)
rs_swap.build()
rs_swap.plot.portfolio_table(
dts='TOP_ORDER_PTS',
end_dt=dtm.date(2019, 4, 4),
tzinfo=local_timezone
)
Data dictionary#
Note: this section is optional reading.
By comparing the data_dict
of an RS with its parent class, Strategy
, we can see which of its arguments are unique to this building block.
Creates a general Strategy
object:
general_strategy = sig.Strategy(
start_date=start_date,
end_date=end_date,
currency=stock_obj.currency
)
Access elements of the RS data_dict
that are not present in the data_dict
of a generic Strategy:
Input:
rs_only_keys = sorted([
key for key in rs.data_dict()
if key not in general_strategy.data_dict()
])
rs_only_keys
Output:
['borrow_cost_ts',
'corporate_actions_take_up_voluntary',
'enable_tracking', 'final_trade_out',
'initial_shares', 'libor_instrument_name',
'libor_spread', '
net_dividend_override',
'rebalance_bdc',
'rebalance_offset',
'rebalance_offset_sticky_end',
'reinvest_cash_dividends',
'spinoff_receive_cash',
'swap_include_financing',
'swap_reset_frequency',
'tracking_frequency',
'tracking_threshold',
'trade_swap_format',
'underlyer',
'underlyer_type',
'universe_filters',
'use_substrats',
'withholding_tax_override']