Sharpe Ratio, Sortino Ratio and Calmar Ratio | by Shuo Wang | Towards Data Science

In portfolio performance analysis, sharpe ratio is the usually the first number that people look at. However, it does not tell us the whole story (nothing does...). So, let’s spend some time looking at a few more metrics that can be very helpful at times.
Sharpe ratio is the ratio of average return divided by the standard deviation of returns annualized. We had an introduction to it in a previous story.
Let’s take a look at it again with a test price time series.
import pandas as pdimport numpy as npfrom pandas.tseries.offsets import BDaydef daily_returns(prices):    res = (prices/prices.shift(1) - 1.0)[1:]    res.columns = ['return']    return resdef sharpe(returns, risk_free=0):    adj_returns = returns - risk_free    return (np.nanmean(adj_returns) * np.sqrt(252)) \        / np.nanstd(adj_returns, ddof=1)def test_price1():    start_date = pd.Timestamp(2020, 1, 1) + BDay()    len = 100    bdates = [start_date + BDay(i) for i in range(len)]    price = [10.0 + i/10.0 for i in range(len)]    return pd.DataFrame(data={'date': bdates,                              'price1': price}).set_index('date')def test_price2():    start_date = pd.Timestamp(2020, 1, 1) + BDay()    len = 100    bdates = [start_date + BDay(i) for i in range(len)]    price = [10.0 + i/10.0 for i in range(len)]    price[40:60] = [price[40] for i in range(20)]    return pd.DataFrame(data={'date': bdates,                              'price2': price}).set_index('date')def test_price3():    start_date = pd.Timestamp(2020, 1, 1) + BDay()    len = 100    bdates = [start_date + BDay(i) for i in range(len)]    price = [10.0 + i/10.0 for i in range(len)]    price[40:60] = [price[40] - i/10.0 for i in range(20)]    return pd.DataFrame(data={'date': bdates,                              'price3': price}).set_index('date')def test_price4():    start_date = pd.Timestamp(2020, 1, 1) + BDay()    len = 100    bdates = [start_date + BDay(i) for i in range(len)]    price = [10.0 + i/10.0 for i in range(len)]    price[40:60] = [price[40] - i/8.0 for i in range(20)]    return pd.DataFrame(data={'date': bdates,                              'price4': price}).set_index('date')price1 = test_price1()return1 = daily_returns(price1)price2 = test_price2()return2 = daily_returns(price2)price3 = test_price3()return3 = daily_returns(price3)price4 = test_price4()return4 = daily_returns(price4)print('price1')print(f'sharpe: {sharpe(return1)}')print('price2')print(f'sharpe: {sharpe(return2)}')print('price3')print(f'sharpe: {sharpe(return3)}')print('price4')print(f'sharpe: {sharpe(return4)}')
As you can see in this example, I have 4 test price time series. First one, price1, is simply a straight line (never mind the wiggles, it’s due to weekends that were left out). The second price time series, price2, has a flat region. The third price time series, price3, has a downward sloping region. The last time series, price4, has a slighter larger downward slope.
The sharpe ratio for each price is calculated below:
'''price1sharpe: 78.59900981328562price2sharpe: 7.9354707022912825price3sharpe: 3.61693599695678price4sharpe: 3.151996500460301'''
As you can see, price1 has a very high sharpe ratio, due to almost non-existent volatility. Price2 has a sharpe ratio of almost 10 times less, due to the presence of a flat region. Price3 and Price4 are about two times lower than price2 due to the downward slope, and Price4 sharpe ratio is slightly lower than price3’s sharpe ratio.
Looking at these numbers, one thing that might seem a little strange is how much the sharpe ratio decreased simply due to the presence of a flat region. After all, the total return didn’t change, and there is no drawdown at all. For an investor, price1 and price2 are not all that different.
On the other hand, price3 and price4 have sizable drawdowns, yet the sharpe ratio decreased by about half, compared to a 10 times drop between price1 and price2. This seems to be a deficiency in the ability of sharpe ratio to tell us how desirable the price time series is.
In order to address the issues with Sharpe ratio not reflecting downside of a time series linearly, Sortino ratio can sometimes be a good metric to look at.
Sortino Ratio = average returns / downside risk
where downside risk is the average negative returns within the time series.
def downside_risk(returns, risk_free=0):    adj_returns = returns - risk_free    sqr_downside = np.square(np.clip(adj_returns, np.NINF, 0))    return np.sqrt(np.nanmean(sqr_downside) * 252)def sortino(returns, risk_free=0):    adj_returns = returns - risk_free    drisk = downside_risk(adj_returns)    if drisk == 0:        return np.nan    return (np.nanmean(adj_returns) * np.sqrt(252)) \        / drisk
Applying sortino ratio to our test prices:
'''price1sortino: nanprice2sortino: nanprice3sortino: 2.210224924119601price4sortino: 1.7688718895750382'''
prices price1 and price2 does not have negative returns at all, so the sortino ratio does not exist for them. for price3 the sortino ratio is 2.2 and for price4 the sortino ratio is 1.8, due to a more deep downward slope, as desired.
Let’s take a look at one more example:
def test_price5():    start_date = pd.Timestamp(2020, 1, 1) + BDay()    len = 100    bdates = [start_date + BDay(i) for i in range(len)]    price = [10.0 + i/10.0 for i in range(len)]    price[40:60] = [price[40] - i/10.1 for i in range(20)]    price[80] = 15    return pd.DataFrame(data={'date': bdates,                              'price5': price}).set_index('date')
In this new price time series, price5, we added a dip on top of the price3, you would think that this dip should decrease the sortino ratio, right? Let’s take a look:
'''price3sharpe: 3.61693599695678sortino: 2.210224924119601price4sharpe: 3.151996500460301sortino: 1.7688718895750382price5sharpe: 2.945329220777157sortino: 0.4660330265232954'''
Yes! Sortino ratio does reflect the dip. But don’t you think it is a little too big of a change? due to the dip in price, sortino ratio dropped from 1.8 to 0.5, a drop of more than 3 times.
In an effort to control the distortions caused by outliers like we showed earlier, another ratio is helpful in detecting them. That is the calmar ratio.
Calmar Ratio = geometric mean of returns / maximum drawdown
We have explained maximum drawdown in a previous story.
Geometric mean of returns is the cumulative return scaled by the time period length.
def annual_returns(returns):    num_years = len(returns) / 252    cum_ret_final = (returns + 1).prod().squeeze()    return cum_ret_final ** (1 / num_years) - 1def calmar(returns):    max_dd = max_drawdown(cumulative_returns(returns))    if max_dd < 0:        return annual_returns(returns) / abs(max_dd)    return np.nan'''price1sharpe: 78.59900981328562sortino: nancalmar: nanprice2sharpe: 7.9354707022912825sortino: nancalmar: nanprice3sharpe: 3.61693599695678sortino: 2.210224924119601calmar: 35.10246841328119price4sharpe: 3.151996500460301sortino: 1.7688718895750382calmar: 28.081974730625276price5sharpe: 2.945329220777157sortino: 0.4660330265232954calmar: 29.404801742258666'''
As you can see in the results, for price4 and price5, calmar ratios are 28 and 29, respectively. It was not affected by the outlier excessively, and also because the downward slope in price5 is actually a tiny bit less than price4 (notice the 10.1 in price5 definition!), it’s calmar ratio actually increased a little!
Note from Towards Data Science’s editors: While we allow independent authors to publish articles in accordance with our rules and guidelines, we do not endorse each author’s contribution. You should not rely on an author’s works without seeking professional advice. See our Reader Terms for details.