Market-Neutral Portfolio

Market (and dollar) neutral strategy on the NDX100 universe.

We use standard historical means and factor covariances to build a simple example of a market neutral strategy.

We use symbolic hyper-parameters (to be improved, see also examples.risk_models) to choose the values that maximize Sharpe ratio of the back-test, for illustrative purposes only.

We use realistic values for transaction cost and holding cost (stocks borrow fees) models. These are used both for the optimization and the back-test in cvxportfolio.MarketSimulator.

To improve the Sharpe ratio of this kind of strategies, in practice, one could use returns forecasts produced by some machine learning model. It is very easy to plug such forecasts into this strategy, either by providing them as a Dataframe or by coding the forecasting logic as a Cvxportfolio native forecaster class, and passing either as argument to cvxportfolio.ReturnsForecast.

Note

Running this example may take some time, a few minutes on a modern workstation with a well-maintained Linux distribution, about half an hour (or longer!) on a laptop or a virtual machine in the cloud.

import numpy as np

import cvxportfolio as cvx

from .universes import NDX100 as UNIVERSE

# times
START = '2016-01-01'
END = None # today

# Currently (~2024) shorting large cap US stocks costs about this,
# in annualized percentages
BORROW_FEES = 0.25

# We set the bid-ask spreads at 5 basis points
SPREAD = 5E-4

# This is the b multiplier of the (3/2) power term in TransactionCost
MARKET_IMPACT = 1.

policy = cvx.SinglePeriodOptimization(
    objective=cvx.ReturnsForecast()
        - cvx.Gamma() * cvx.FactorModelCovariance(num_factors=10)
        - cvx.Gamma() * cvx.TransactionCost(a=SPREAD/2, b=MARKET_IMPACT)
        - cvx.Gamma() * cvx.HoldingCost(short_fees=BORROW_FEES),
    constraints = [
        cvx.DollarNeutral(), cvx.MarketNeutral(), cvx.LeverageLimit(7)],
    # this solver is somewhat more robust than ECOS, but less efficient
    solver='CLARABEL',
    # this is a CVXPY compilation flag that disables a feature that is very
    # useful (cache a semi-compiled problem) but its implementation scales
    # badly with the problem size; if you increase number of factors or
    # universe size, you may have to uncomment the next line
    # ignore_dpp=True,
)

simulator = cvx.MarketSimulator(
    universe=UNIVERSE,
    costs = [
        cvx.TransactionCost(a=SPREAD/2, b=MARKET_IMPACT),
        cvx.HoldingCost(short_fees=BORROW_FEES)])

# automatic hyper-parameter optimization (by greedy grid search)
simulator.optimize_hyperparameters(
    policy, start_time=START, end_time=END,
    objective='sharpe_ratio')

print('Optimized policy hyper-parameters:')
print(policy)

# back-test the policy with optimized hyper-parameters
result = simulator.backtest(policy, start_time=START, end_time=END)

print("Optimized policy back-test result:")
print(result)

# plot
result_figure = result.plot()

# check that back-tested returns of the strategy are uncorrelated with the
# market benchmark
market_benchmark_returns = simulator.backtest(
    cvx.MarketBenchmark(), start_time=START, end_time=END).returns

print('Correlation of strategy returns with benchmark:')
print(np.corrcoef(result.returns, market_benchmark_returns)[0, 1])

This is the output printed to screen when executing this script. You can see the steadly increasing Sharpe ratio during the hyper-parameter optimization loop; at each iteration, small changes in hyper-parameter values are tried and the combination with largest increase is chosen (the routine ends when no increase is found). Finally, the optimized policy is printed. When you do so, you can observe the values of all parameters that are contained in the policy object and its components, including many that were set to their defaults, in this case. Finally, you can see that the resulting strategy back-tested returns have low correlation with the market benchmark, thanks to the cvxportfolio.constraints.MarketNeutral constraint.

Updating data.....................................................................................................
iteration 0
Current objective:
0.6005046029534814
iteration 1
Current objective:
0.6171139234851384
iteration 2
Current objective:
0.6247585131044959
iteration 3
Current objective:
0.6331797111859923
iteration 4
Current objective:
0.646250516194385
iteration 5
Current objective:
0.6576934195407363
iteration 6
Current objective:
0.6657143552713986
iteration 7
Current objective:
0.6720274705379033
iteration 8
Current objective:
0.6802259829875591
iteration 9
Current objective:
0.6855364861604615
iteration 10
Current objective:
0.6922642475911701
iteration 11
Current objective:
0.6999128639209367
iteration 12
Current objective:
0.7050025826558192
iteration 13
Current objective:
0.7050046146281316
iteration 14
Current objective:
0.7050115241448068
Optimized policy hyper-parameters:
SinglePeriodOptimization(objective=ReturnsForecast(r_hat=HistoricalMeanReturn(half_life=inf, rolling=inf), decay=1.0) - Gamma(current_value=2.8531167061100025) * FactorModelCovariance(Sigma=HistoricalFactorizedCovariance(half_life=inf, rolling=inf, kelly=True), num_factors=10) - Gamma(current_value=0.9090909090909091) * TransactionCost(a=0.00025, b=1.0, market_volumes=VolumeHatOrRealized(volume_hat=HistoricalMeanVolume(half_life=inf, rolling=Timedelta('365 days 05:45:36'))), sigma=HistoricalStandardDeviation(half_life=inf, rolling=Timedelta('365 days 05:45:36'), kelly=True), exponent=1.5) - Gamma(current_value=0.8264462809917354) * HoldingCost(short_fees=0.25, periods_per_year=YearDividedByTradingPeriod()) + CashReturn(), constraints=[DollarNeutral(), MarketNeutral(benchmark=MarketBenchmark(mean_volume_forecast=HistoricalMeanVolume(half_life=inf, rolling=Timedelta('365 days 05:45:36'))), covariance_forecaster=HistoricalFactorizedCovariance(half_life=inf, rolling=inf, kelly=True)), LeverageLimit(limit=7)], benchmark=AllCash(), cvxpy_kwargs={'solver': 'CLARABEL'})
Optimized policy back-test result:

#################################################################
Universe size                                                 102
Initial timestamp                       2016-01-04 14:30:00+00:00
Final timestamp                         2024-03-19 13:30:00+00:00
Number of periods                                            2066
Initial value (USDOLLAR)                                1.000e+06
Final value (USDOLLAR)                                  6.391e+06
Profit (USDOLLAR)                                       5.391e+06
                                                                 
Avg. return (annualized)                                    31.7%
Volatility (annualized)                                     42.6%
Avg. excess return (annualized)                             30.0%
Avg. active return (annualized)                             30.0%
Excess volatility (annualized)                              42.6%
Active volatility (annualized)                              42.6%
                                                                 
Avg. growth rate (annualized)                               22.6%
Avg. excess growth rate (annualized)                        21.0%
Avg. active growth rate (annualized)                        21.0%
                                                                 
Avg. TransactionCost                                          0bp
Max. TransactionCost                                          4bp
Avg. HoldingCost                                              0bp
Max. HoldingCost                                              1bp
                                                                 
Sharpe ratio                                                 0.71
Information ratio                                            0.71
                                                                 
Avg. drawdown                                              -13.5%
Min. drawdown                                              -49.4%
Avg. leverage                                              629.9%
Max. leverage                                              786.0%
Avg. turnover                                                2.2%
Max. turnover                                               43.6%
                                                                 
Avg. policy time                                           0.017s
Avg. simulator time                                        0.018s
    Of which: market data                                  0.004s
Total time                                                71.472s
#################################################################

Correlation of strategy returns with benchmark:
0.16478970446546193

And this is the figure that is plotted, the result of hyper-parameter optimization with largest Sharpe ratio.

examples/market_neutral.py result figure

This figure is made by the cvxportfolio.result.BacktestResult.plot() method.