ffn icon indicating copy to clipboard operation
ffn copied to clipboard

Sortino ratio incorrectly uses np.minimum

Open SamuelLivingstone opened this issue 3 months ago • 0 comments

Hi all,

It looks like calc_stats uses ffn.core.calc_sortino_ratio and this has the below calc:

negative_returns = np.minimum(er[1:], 0.0)

In this case, it inadvertently includes zeroes when a value is not negative. As such, this artificially decreases the downside deviation and inflates the sortino ratio.

Example code below.

import pandas as pd
import numpy as np
import ffn

def calculate_sortino_ratios(prices, risk_free_rate=0.0, periods_per_year=252):
    """
    Calculate Sortino ratio using different methods to demonstrate the differences
    
    Parameters:
    prices: DataFrame with asset prices
    risk_free_rate: Annual risk-free rate (default 0)
    periods_per_year: Number of trading periods per year (default 252 for daily data)
    """
    # Get returns
    returns = prices.pct_change().dropna()
    
    # Calculate excess returns over risk-free rate
    rf_daily = (1 + risk_free_rate) ** (1/periods_per_year) - 1
    excess_returns = returns - rf_daily
    
    # Method 1: FFN's built-in calculation
    stats = prices.calc_stats()
    ffn_sortino = stats.stats.loc['daily_sortino']
    
    # Method 2: Matching FFN's calculation
    avg_excess_return = excess_returns.mean()
    neg_returns_ffn = np.minimum(excess_returns, 0.0)
    downside_std_ffn = np.sqrt(np.mean(neg_returns_ffn ** 2))
    
    annualized_return = (1 + avg_excess_return) ** periods_per_year - 1
    annualized_downside_std_ffn = downside_std_ffn * np.sqrt(periods_per_year)
    
    matching_ffn_sortino = annualized_return / annualized_downside_std_ffn
    
    # Method 3: Traditional calculation (only negative returns)
    neg_returns = excess_returns[excess_returns < 0]
    downside_std = np.sqrt(np.mean(neg_returns ** 2))
    annualized_downside_std = downside_std * np.sqrt(periods_per_year)
    
    traditional_sortino = annualized_return / annualized_downside_std
    
    return {
        'FFN Built-in': ffn_sortino['aapl'],
        'Matching FFN': matching_ffn_sortino['aapl'],
        'Traditional': traditional_sortino['aapl']
    }

# Example usage:
prices = ffn.get('aapl', start='2010-01-01')
sortino_ratios = calculate_sortino_ratios(prices)
print("\nSortino Ratios:")
for name, value in sortino_ratios.items():
    print(f"{name}: {value:.4f}")

SamuelLivingstone avatar Nov 15 '24 01:11 SamuelLivingstone