The Logical-Invest “Universal Investment Strategy”–A Walk Forward Process on SPY and TLT

I’m sure we’ve all heard about diversified stock and bond portfolios. In its simplest, most diluted form, it can be comprised of the SPY and TLT etfs. The concept introduced by Logical Invest, in a Seeking Alpha article written by Frank Grossman (also see link here), essentially uses a walk-forward methodology of maximizing a modified Sharpe ratio, biased heavily in favor of the volatility rather than the returns. That is, it uses a 72-day moving window to maximize total returns between different weighting configurations of a SPY-TLT mix over the standard deviation raised to the power of 5/2. To put it into perspective, at a power of 1, this is the basic Sharpe ratio, and at a power of 0, just a momentum maximization algorithm.

The process for this strategy is simple: rebalance every month on some multiple of 5% between SPY and TLT that previously maximized the following quantity (returns/vol^2.5 on a 72-day window).

Here’s the code for obtaining the data and computing the necessary quantities:

getSymbols(c("SPY", "TLT"), from="1990-01-01")
returns <- merge(Return.calculate(Ad(SPY)), Return.calculate(Ad(TLT)), join='inner')
returns <- returns[-1,]
configs <- list()
for(i in 1:21) {
  weightSPY <- (i-1)*.05
  weightTLT <- 1-weightSPY
  config <- Return.portfolio(R = returns, weights=c(weightSPY, weightTLT), rebalance_on = "months")
  configs[[i]] <- config
configs <-, configs)
cumRets <- cumprod(1+configs)
period <- 72

roll72CumAnn <- (cumRets/lag(cumRets, period))^(252/period) - 1
roll72SD <- sapply(X = configs, runSD, n=period)*sqrt(252)

Next, the code for creating the weights:

sd_f_factor <- 2.5
modSharpe <- roll72CumAnn/roll72SD^sd_f_factor
monthlyModSharpe <- modSharpe[endpoints(modSharpe, on="months"),]

findMax <- function(data) {

weights <- t(apply(monthlyModSharpe, 1, findMax))
weights <- weights*1
weights <- xts(weights,
weights[] <- 0
weights$zeroes <- 1-rowSums(weights)
configs$zeroes <- 0

That is, simply take the setting that maximizes the monthly modified Sharpe Ratio calculation at each rebalancing date (the end of every month).

Next, here’s the performance:

stratRets <- Return.portfolio(R = configs, weights = weights)
rbind(table.AnnualizedReturns(stratRets), maxDrawdown(stratRets))

Which gives the results:

> rbind(table.AnnualizedReturns(stratRets), maxDrawdown(stratRets))
Annualized Return                 0.1317000
Annualized Std Dev                0.0990000
Annualized Sharpe (Rf=0%)         1.3297000
Worst Drawdown                    0.1683851

With the following equity curve:

Not perfect, but how does it compare to the ingredients?

Let’s take a look:

stratAndComponents <- merge(returns, stratRets, join='inner')
rbind(table.AnnualizedReturns(stratAndComponents), maxDrawdown(stratAndComponents))
apply.yearly(stratAndComponents, Return.cumulative)

Here are the usual statistics:

> rbind(table.AnnualizedReturns(stratAndComponents), maxDrawdown(stratAndComponents))
                          SPY.Adjusted TLT.Adjusted portfolio.returns
Annualized Return            0.0907000    0.0783000         0.1317000
Annualized Std Dev           0.1981000    0.1381000         0.0990000
Annualized Sharpe (Rf=0%)    0.4579000    0.5669000         1.3297000
Worst Drawdown               0.5518552    0.2659029         0.1683851

In short, it seems the strategy performs far better than either of the ingredients. Let’s see if the equity curve comparison reflects this.

Indeed, it does. While it does indeed have the drawdown in the crisis, both instruments were in drawdown at the time, so it appears that the strategy made the best of a bad situation.

Here are the annual returns:

> apply.yearly(stratAndComponents, Return.cumulative)
           SPY.Adjusted TLT.Adjusted portfolio.returns
2002-12-31  -0.02054891  0.110907611        0.01131366
2003-12-31   0.28179336  0.015936985        0.12566042
2004-12-31   0.10695067  0.087089794        0.09724221
2005-12-30   0.04830869  0.085918063        0.10525398
2006-12-29   0.15843880  0.007178861        0.05294557
2007-12-31   0.05145526  0.102972399        0.06230742
2008-12-31  -0.36794099  0.339612265        0.19590423
2009-12-31   0.26352114 -0.218105306        0.18826736
2010-12-31   0.15056113  0.090181150        0.16436950
2011-12-30   0.01890375  0.339915713        0.24562838
2012-12-31   0.15994578  0.024083393        0.06051237
2013-12-31   0.32303535 -0.133818884        0.13760060
2014-12-31   0.13463980  0.273123290        0.19637382
2015-02-20   0.02773183  0.006922893        0.02788726

2002 was an incomplete year. However, what’s interesting here is that on a whole, while the strategy rarely if ever does as well as the better of the two instruments, it always outperforms the worse of the two instruments–and not only that, but it has delivered a positive performance in every year of the backtest–even when one instrument or the other was taking serious blows to performance, such as SPY in 2008, and TLT in 2009 and 2013.

For the record, here is the weight of SPY in the strategy.

weightSPY <- apply(monthlyModSharpe, 1, which.max)
weightSPY <-, weightSPY)
weightSPY <- (weightSPY-1)*.05
align <- cbind(weightSPY, stratRets)
align <- na.locf(align)
chart.TimeSeries(align[,1], date.format="%Y", ylab="Weight SPY", main="Weight of SPY in SPY-TLT pair")

Now while this may serve as a standalone strategy for some people, the takeaway in my opinion from this is that dynamically re-weighting two return streams that share a negative correlation can lead to some very strong results compared to the ingredients from which they were formed. Furthermore, rather than simply rely on one number to summarize a relationship between two instruments, the approach that Frank Grossman took to actually model the combined returns was one I find interesting, and undoubtedly has applications as a general walk-forward process.

Thanks for reading.

NOTE: I am a freelance consultant in quantitative analysis on topics related to this blog. If you have contract or full time roles available for proprietary research that could benefit from my skills, please contact me through my LinkedIn here.

29 thoughts on “The Logical-Invest “Universal Investment Strategy”–A Walk Forward Process on SPY and TLT

  1. Not much better (13.4% for 2003-2014 vs. 13.2%) than the monthly paired switching between SPY and TLT based on the prior three months’ performance.

  2. I think Mr. Grossman’s idea was to use this as an alternative to the 60/40 portfolio, so shouldn’t that be the benchmark for comparison?

  3. Good point, gregor. Looking more closely, this strategy looks like a curve-fit. There is a clear inflection point at 2009-01. The strategy performance before that inflection point is uninteresting, so I’m uncertain how anyone would have decided to trade it.

    What do you think, Mr. TradeR? How long after Jan 2009 would it take before anyone would notice this was a reasonable strategy?

  4. I tried but failed to implement the following idea: from a broad basket of assets (say 50 assets), with a monthly basis, keep the TopN (say N=5) using whatever equally-weighted algorithm (say FAA) and carry out this WFO as described in your article among these TopN assets, trying to optimize for example the Sharpe Ratio.

    Do you think you can make it by mixing bits of your two algos?


  5. Thanks Ilya for yet one more awesome post!

    Few comments.

    1. Frank did a 20 year back test using VFINX/VUSTX proxies for SPY-TLT and posted an Excel with monthly allocations from his strategy. While the overall results look similar the monthly allocations between this implementation and Frank’s post are very different. This could be due to additional optimizations that Frank may have implemented. Also, in Frank’s case the rebalancing was done not on the 1st of the month but sometime during the first week of the month.

    Just to be clear, I don’t expect him to publish the secret sauce details and I appreciate what has already been shared. I am simply noting that even with different allocations over a long run the results seem to be similar.

    2. The lookback period seems to be sensitive to period changes. Changing the value from 72 to other values like 63 or 84 to mimic 3/4 Months look back periods change the drawdown considerably. haven’t tested other values yet. This means 72 might be an optimized number which is ok as long as it is in a stable region. I will check this later.

    3. While we are at making the SPY-TLT allocation adaptive why stop at that and why not make the lookback period and the F-Factor also adaptive to match the market conditions within a certain acceptable range like lookback can be anywhere from 2-6 months and F-Factor can be anywhere from 1.5-3.5. This should avoid any curve fitting and adapt to various market conditions. Not sure of this will improve the results though.


    • Sorry. Never mind my comment about the Rebalancing part. I must have looked at something else. In the 20 year backtest, rebalancing happened on the last of the month.

    • 1) I’m not sure I agree the allocations are drastically different. Are they? I mean I make it quite clear how I do my ranking, and my equity curve looks similar.

      2) I’ll be looking into that, thanks for the 63-84 suggestion.

      3) I actually haven’t had much luck doing something adaptive like this in the past. Can you elaborate on the particulars of your idea?

      • Ilya,

        1. Here is what I did. Replace SPY/TLT with fund proxies. VFINX/VUSTX. Change the Allocation step size to 0.10 instead of the 0.05 you used to match what Frank did in his 20 year test. He goes in 0.10 increments. As you can see below, the allocations are different. See the graphic below for allocation comparison. One reason could be source data differences along with other optimizations.

        Date Frank’s Ilya’s
        12/31/2012 30 40
        1/31/2013 70 40
        2/28/2013 60 40
        3/28/2013 50 50
        4/30/2013 50 60
        5/31/2013 60 60
        6/28/2013 90 40
        7/31/2013 100 40
        8/30/2013 100 40
        9/30/2013 100 50
        10/31/2013 70 50
        11/29/2013 60 70
        12/31/2013 70 40
        1/31/2014 60 40
        2/28/2014 40 40
        3/31/2014 40 70
        4/30/2014 40 60
        5/30/2014 40 50
        6/30/2014 50 50
        7/31/2014 60 60
        8/29/2014 50 90
        9/30/2014 50 100
        10/31/2014 40 100
        11/28/2014 40 100
        12/31/2014 40 70

        3. So the idea for making lookback period adaptive is, you created 20 portfolios with each step size being 5 and then you are using a 72 period lookback to identify the modified sharp. Lets call this layer1. Now create a second layer with the same 20 portfolios from layer1 but in the second layer use 63 as look back period and in the third layer same 20 portfolios but 84 as lookback period to calculate the Mod sharp ratios.

        With this setup you now have 3 layers of modified sharp ratios that correspond to 3 lookback periods and 20 different allocation combinations.

        Now, Pick the MAX modified sharp across all layers and use the combination for next month. Example could be MAX mod sharp is on Layer 2 with a split of 70(SPY)/ 30(TLT) and Layer2 we know corresponds to 63 as lookback period. So for the subsequent month your values are 63 lookback period and 70/30 split.

        Another way of doing this could be, find the allocation from layer1 with 72 lookback and then keep the allocation constant and scan across other layers to see if any other lookback has higher mod sharp ratio. If so use that combination of allocation split and lookback.

        Sorry for the long post.


  6. Hi, I can’t understand this code. Could you help me to translate in common language how to calculate this highest sharpe ratio. I know the formula is this:
    sharpe=72 day return/72 day standard deviation ^ 2.5. I need to loop through all the variations.
    Buy I haven’t manage it to work. I work with c sharp, amibroker or tradestation code.
    Thank you in advance, this is great!

    • Tania,

      What don’t you understand? 72-day annualized return divided by 72-day annualized standard deviation raised to the power of 2.5 Unfortunately, I do not work in C#, Amibroker, or TS.

  7. Simple monthly switching (based on prior three months’ returns) works quite well for even the period 1987-2015 (with VFINX and VUSTX). [Full year data for both is available only since 1987].

    CAGR 12%
    Worst Year 2002 -3.5%
    Number of yearly losses 2 (2002, and 1987 -3%)
    MaxDD 21.8%
    Sharpe .73
    Sortino 1.23
    One Factor Beta ..36 (R^2 .21)

    Switching every two months works almost just as well.

  8. I took the SPY weights vector and the TLT vector (1-SPY weight) and applied it to the SPY and TLT returns I calculated separately from the yahoo feed. I’m concerned there may be a future leak, but I hope I’m just misinterpreting the data: In your code you calculate the SPY weight vector. If I create a vector: weightTLT= weightSPY-1 and apply it and the weightSPY vectors to the component returns for each period my return values match the return values the function returns for the prior day.

    In detail:
    you have: stratRets <- Return.portfolio(R = configs, weights = weights)
    stratRets(t) = rtnSPY(t+1)*weightSPY(t) + rtnTLT(t+1)*weightTLT(t)

    Am I looking at this incorrectly?

    I'd appreciate your suggestions.

    • Almost. It’s stratRets(t+1) = the rest of your suggestion.

      That is, if I allocate my assets on Nov. 30, my first realized returns will be on Dec. 1. That said, that return is Close(Dec .1)/Close(Nov. 30), which comes from the close/close nature of the return calculation, but the things you’re concerned about have been taken care of long ago by the PerformanceAnalytics package, which is one of the best in the business. (It’s written in part by Peter Carl, a prominent portfolio manager).

  9. Ilya,

    Thanks for your reply. Perhaps I didn’t express my thoughts clearly. I’ve no doubt that the PerformanceAnalytics package is calculating properly and correctly, however my concern is that since the selection of which of the 20 weight(spy,tlt) pairs for a given day is based on the calculations that include a given day, then for a given date the weights used to calculate the startRets are using the current date’s return as input to the selection process.
    I believe the code: stratRets <- Return.portfolio(R = configs, weights = weights)
    should be modified to be something like: stratRets <- Return.portfolio(R = configs, weights = lag(weights)), but when I attempt to run with the lag, the returns are all NaNs.

    My thought is that the optimal weight combination chosen from the 20 sets of weights should be applied to the next day's return calculation, not the current day.

    • I just tested it out. No, it doesn’t incorporate the day of the allocation into the returns. Try this:

      dates <- as.Date(c("2001-12-31", "2002-01-01"))
      rets <- matrix(c(-.5, -.5, .1, .1), byrow=TRUE, nrow=2)
      rets <- xts(rets,
      weights <- xts(t(c(.5, .5)),"2001-12-31"))
      test <- Return.portfolio(R = rets, weights = weights)

      I'm not sure whether because this is a tiny example that the indexing was off, but the salient point is that if it incorporated the same day's returns, I'd see a -.5 return on the first print, which I don't.

  10. Pingback: Rolling Sharpe Ratios | QuantStrat TradeR

  11. Pingback: The Logical Invest “Hell On Fire” Replication Attempt | QuantStrat TradeR

  12. Pingback: The Logical Invest Enhanced Bond Rotation Strategy (And the Importance of Dividends) | QuantStrat TradeR

  13. Pingback: Dinamikus ETF portfóliók: kapcsolgatás | lustaport

  14. Hi again, got trough the last one, so moving on to next. I got error in last post and got same error in this one as well.

    > roll72CumAnn <- (cumRets/lag(cumRets, period))^(252/period) – 1
    Error in `[.xts`(x, seq_len(xlen – n)) : subscript out of bounds

    I was able to go around it last time just with different calculation method, but not sure how to go around it here. Am I missing some package, or something else? cumRets object looks fine to me and not sure why it throws that error here. Any suggestions?

  15. Hi Ilya,

    Great blog and contributions.

    A comment to his post: Impact of commissions.
    Period: 13 years = 156 months.
    Monthly rebalancing for 2 instruments: 156 sells + 156 buys = 312 transactions.

    Question: What happens to the 250% gains during this period? (There is no indication about the impact).

    Would appreciate your input here.


  16. Pingback: Hell on Fire: The 3x leveraged Universal Investment Strategy

  17. I very much like your approach to blogging and to markets. I have only just come across this post and am just putting it into Python. My approach will be to use a solver and a hell on fire fitness facto but the end results should be similar. I like your approach to simulating these leveraged instruments for missing past history.

    On a broader note I have come to realise that I actually know very little about anything, markets included. I am taking the approach in our GBA Workbench product that it is better therefor to take other people’s approaches and to present them without comment rather than to attempt to force my own approaches which have proved just as fallible in the past as those of others.

    Nice work as ever Ilya.

  18. Pingback: The Logical-Invest “Universal Investment Strategy” – Anthony FJ Garner

  19. Pingback: Universal Investment Strategy – by Logical Invest – None too Sure! – Anthony FJ Garner

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s