This section provides examples of the optimisers available and how they can be used.
The following example will optimise a basket of large cap stocks through different objectives and constraints, comparing each problem to a minimum variance portfolio.
import numpy as npimport pandas as pdimport seaborn as snsfrom uuid import uuid4import datetime as dtmfrom tqdm.notebook import tqdmimport matplotlib.pyplot as pltsns.set(rc = {'figure.figsize': (18, 6)})import sigtech.framework as sigfrom sigtech.framework.default_strategy_objects.single_stock_strategies import get_single_stock_strategyifnot sig.config.is_initialised(): env = sig.init(env_date=dtm.date(2022, 1, 4))
Define universe
The following example considers a basket of large cap stocks from the SPX index. We start by building a reinvestment strategy for each stock:
START_DATE = dtm.date(2020, 1, 4)equity_filter = sig.EquityUniverseFilter('SPX INDEX')equity_filter.add('Market Cap', 'Top', 10, frequency='Quarterly')universe = equity_filter.apply_filter(START_DATE).iloc[0]rs = [get_single_stock_strategy(ss)for ss in universe]
The total returns are then computed for each stock and mapped to company names:
company_names =list(set([sig.obj.get(ss).company_name for ss in universe]))single_stock_returns ={n: s.history().pct_change().dropna()for s, n inzip(rs, company_names)}single_stock_returns_df = pd.concat(single_stock_returns, 1)[START_DATE:]universe_mapping ={n:f'{ss} REINV STRATEGY'for ss, n inzip(universe, company_names)}single_stock_mapping ={n: ss for ss, n inzip(universe, company_names)}
Helper functions
Two variables are defined to store our optimised weights and strategy objects for each problem we solve:
STRATEGIES ={}PROBLEM_WEIGHTS ={}
Two helper functions are also defined. run_optimisation_on_basket will build our basket of stocks via a SignalStrategy using weights stored in PROBLEM_WEIGHTS for each problem name:
defrun_optimisation_on_basket(problem_name:str):""" Build the basket as a signal strategy with the optimised weights for the given problem_name The optimised weights are retrieved from the problem_weights dictionary """ weights = PROBLEM_WEIGHTS[problem_name] signal_df = weights.to_frame(START_DATE).T optimised_signal_strategy = sig.SignalStrategy( currency='USD', start_date=START_DATE, signal_name=sig.signal_library.core.from_ts(signal_df).name, rebalance_frequency='EOM', convert_long_short_weight=False, instrument_mapping=universe_mapping, ticker=f'{problem_name.upper()} OPTIMISED BASKET {str(uuid4())[:4]}' ) optimised_signal_strategy.build() STRATEGIES[problem_name]= optimised_signal_strategy
plot_optimisation_results plots visualisations to illustrate the optimised weights and performance of the problem, relative to a minimum variance portfolio:
defplot_optimisation_results(problem_name:str):""" Plot the weights for each holding and the resulting basket performance. If the problem name != "Min Variance", add Min Variance strategy to the plots for comparison. """ return_ts = STRATEGIES[problem_name].history() weights = PROBLEM_WEIGHTS[problem_name] fig, (ax1, ax2) = plt.subplots(1, 2) weights.plot(ax=ax1, kind='bar', label=problem_name) return_ts.plot(ax=ax2, label=problem_name) mv ='Min Variance'if problem_name != mv: min_var_return_ts = STRATEGIES[mv].history() min_var_weights = PROBLEM_WEIGHTS[mv] min_var_weights.plot(ax=ax1, kind='bar', fill=False, edgecolor='C1', ls='--', label=mv) min_var_return_ts.plot(ax=ax2, alpha=0.6, label=mv) ax1.legend() ax2.legend()return ax1, ax2
Optimisations
Optimisers are built through the sig.PortfolioOptimizer class, which acts as a wrapper around the Optimizer class.
The methods on this wrapper will build an OptimizationProblem object, adding the necessary constraints and objectives, and running it through Optimizer via the calculate_weights method.
Users are encouraged to use the wrapper, as it provides a more efficient interface, but can equally define their own OptimisationProblems at a lower level, as shown in Equity factor optimisation.
Minimum variance
PortfolioOptimizer is instantiated and an objective is added to minimise returns variance. A constraint is then added to ensure the portfolio weights sum to 100%:
max_return_opt = sig.PortfolioOptimizer().require_maximum_return()# This would be un-bound if we didn't add the L2 penaltymax_return_opt = max_return_opt.require_weights_l2_penalty(value=1e-3)# Can set custom maximum return in .require_maximum_return() via the expected_returns= parameterPROBLEM_WEIGHTS['Max Return']= max_return_opt.calculate_weights(single_stock_returns_df)
The weight change, of the portfolio instruments to a percent of median daily volume, can be limited.
Note: this requirement cannot be used with the SignalStrategy allocation setup as the identity of all assets in the portfolio needs to be known before accessing the MDVs. This requirement can only be used on a step-by-step basis.
An example initial weights is generated to rebalance from:
Turnover control requires starting from pervious weights. A function is defined to apply the optimisation using the previous optimised weights on a rolling basis.
If a max_weight_change is passed, the function will apply the turnover optimisation:
Two sets of weights have been calculated on a rolling basis:
Mean variance
Turnover optimised mean variance
The two weights data frames are compared in the following plots. The turnover controlled version of the signal limits the daily trading but still closely matches the raw signal. The solid lines show the turnover controlled weights in comparison to the dotted lines of the raw signal.
Within numerical accuracy, the net leverage of the optimiser is held to zero, as there is a long-short neutral requirement. The gross leverage is allowed to fluctuate but the turnover controlled version takes some time to build up from zero.
fig, (ax1, ax2) = plt.subplots(1, 2)mean_var_signal_df.sum(axis=1).plot(label='Raw', ax=ax1)mean_var_to_signal_df.sum(axis=1).plot(label='Turnover Controlled', ax=ax1)ax1.set_title('Net Leverage')mean_var_signal_df.abs().sum(axis=1).plot(label='Raw', ax=ax2)mean_var_to_signal_df.abs().sum(axis=1).plot(label='Turnover Controlled', ax=ax2)ax2.set_title('Gross Leverage')[a.legend()for a in (ax1, ax2)];
Benchmark tracking
An equally weighted benchmark is defined:
PROBLEM_WEIGHTS['Benchmark']= pd.Series( index= company_names, data = [1/len(company_names)] *len(company_names))run_optimisation_on_basket('Benchmark')
The require_tracking_error_upper_bound method allows optimisation of a portfolio within a tracking error, applied via the ann_te_pct variable as 10%:
Different covariance_estimation_methods can be applied to our PortfolioOptimizer to yield different solutions. Minimum variance is used in the following example: