Skip to content

Incorrect DownsideDeviation with Time-Varying MAR #48

@gcpoole

Description

@gcpoole

Summary

DownsideDeviation() returns incorrect results when MAR is a time-varying xts object. The function uses the wrong MAR values because the matrix conversion loses the time index.

Affected Functions

  • DownsideDeviation()
  • SortinoRatio() (which calls DownsideDeviation())

Description

When a time-varying MAR (e.g., monthly risk-free rates) is passed to DownsideDeviation(), the function incorrectly matches MAR values to return periods. Instead of using the MAR value for each specific downside date, it uses the first N values of MAR sequentially.

Root Cause

In DownsideDeviation(), the following code path is problematic:

R = checkData(R, method = "matrix")  # Converts R to plain matrix, losing time index
...
r = subset(R, R < MAR)               # r is a plain matrix with no time index
if (!is.null(dim(MAR))) {
    if (is.timeBased(index(MAR))) {
        MAR <- MAR[index(r)]         # index(r) returns 1,2,3... not dates!
    }
}

When R is converted to a matrix, it loses its date index. Therefore, index(r) returns integer row numbers (1, 2, 3, ...) rather than dates. The subsequent MAR[index(r)] then returns the first N values of MAR instead of the MAR values at the actual downside dates.

Minimal Reproducible Example

library(PerformanceAnalytics)
library(xts)

# Create simple return series
dates <- seq(as.Date("2020-01-01"), by = "month", length.out = 12)
returns <- xts(c(-0.05, 0.03, -0.02, 0.04, -0.01, 0.02,
                 -0.03, 0.05, -0.04, 0.01, -0.02, 0.03), dates)

# Time-varying MAR (e.g., different risk-free rate each month)
mar <- xts(c(0.001, 0.001, 0.001, 0.002, 0.002, 0.002,
             0.003, 0.003, 0.003, 0.001, 0.001, 0.001), dates)

# PA's DownsideDeviation
pa_dd <- DownsideDeviation(returns, MAR = mar)

# Manual calculation (correct)
excess <- returns - mar
manual_dd <- sqrt(mean(pmin(excess, 0)^2))

cat("PA DownsideDeviation:    ", pa_dd, "\n")
cat("Manual (correct):        ", manual_dd, "\n")
cat("Values match:            ", abs(pa_dd - manual_dd) < 1e-10, "\n")

Expected Output

Both calculations should return the same value.

Actual Output

PA DownsideDeviation:     0.02629956
Manual (correct):         0.02715329
Values match:             FALSE

Demonstrating the Index Problem

# Show what PA actually does
R <- checkData(returns, method = "matrix")
r <- subset(R, R < mar)

cat("index(r) returns row numbers, not dates:\n")
print(index(r))

# PA uses first N values of MAR
cat("\nPA uses these MAR values (first", length(r), "sequential):\n
print(as.numeric(mar[1:length(r)]))

# But should use MAR at actual downside dates
downside_mask <- as.numeric(returns) < as.numeric(mar)
correct_dates <- index(returns)[downside_mask]
cat("\nCorrect MAR values (at actual downside dates):\n")
print(as.numeric(mar[correct_dates]))

Impact

  • SortinoRatio() with time-varying MAR returns incorrect values
  • The error magnitude depends on how scattered the downside periods are and how much MAR varies over time
  • Fixed scalar MAR works correctly; only time-varying MAR is affected

Suggested Fix

Preserve the time index when subsetting, or extract dates before converting to matrix:

# Option 1: Store dates before matrix conversion
original_dates <- index(R)
R = checkData(R, method = "matrix")
...
r_indices <- which(R < MAR)
r = R[r_indices]
if (!is.null(dim(MAR))) {
    if (is.timeBased(index(MAR))) {
        MAR <- MAR[original_dates[r_indices]]
    }
}

Environment

  • R version: 4.x
  • PerformanceAnalytics version: 2.0.4 (current CRAN)

Workaround

Use a fixed scalar MAR (e.g., average risk-free rate) instead of time-varying:

# Instead of:
SortinoRatio(returns, MAR = rf_returns)

# Use:
SortinoRatio(returns, MAR = mean(rf_returns))

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions