diff --git a/quantstats/reports.py b/quantstats/reports.py
index 49345303..315411db 100644
--- a/quantstats/reports.py
+++ b/quantstats/reports.py
@@ -1,10 +1,9 @@
#!/usr/bin/env python
-# -*- coding: UTF-8 -*-
#
# QuantStats: Portfolio analytics for quants
# https://github.com/ranaroussi/quantstats
#
-# Copyright 2019 Ran Aroussi
+# Copyright 2019-2025 Ran Aroussi
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,615 +19,2499 @@
import pandas as _pd
import numpy as _np
-from datetime import (
- datetime as _dt, timedelta as _td
-)
+from math import sqrt as _sqrt, ceil as _ceil
+from datetime import datetime as _dt
+from base64 import b64encode as _b64encode
import re as _regex
from tabulate import tabulate as _tabulate
-from . import (
- __version__, stats as _stats,
- utils as _utils, plots as _plots
-)
+from . import __version__
+
+# Lazy imports to avoid circular dependency during package initialization
+_stats = None
+_utils = None
+_plots = None
+
+
+def _get_stats():
+ global _stats
+ if _stats is None:
+ from . import stats
+ _stats = stats
+ return _stats
+
+
+def _get_utils():
+ global _utils
+ if _utils is None:
+ from . import utils
+ _utils = utils
+ return _utils
+
+
+def _get_plots():
+ global _plots
+ if _plots is None:
+ from . import plots
+ _plots = plots
+ return _plots
+from dateutil.relativedelta import relativedelta
+from io import StringIO
+from pathlib import Path
+import tempfile
+import webbrowser
try:
- from IPython.core.display import (
- display as iDisplay, HTML as iHTML
- )
+ from IPython.display import display as iDisplay, HTML as iHTML
except ImportError:
- pass
+ pass # IPython not available, display functions won't be used
+
+
+def _get_trading_periods(periods_per_year=252):
+ """
+ Calculate trading periods for different time windows.
+
+ This helper function computes the number of trading periods for full year
+ and half year periods, which are commonly used in financial calculations
+ for annualization and rolling window analysis.
+
+ Parameters
+ ----------
+ periods_per_year : int, default 252
+ Number of trading periods in a year (e.g., 252 for daily data,
+ 12 for monthly data)
+
+ Returns
+ -------
+ tuple
+ A tuple containing (periods_per_year, half_year_periods)
+
+ Examples
+ --------
+ >>> _get_trading_periods(252) # Daily data
+ (252, 126)
+ >>> _get_trading_periods(12) # Monthly data
+ (12, 6)
+ """
+ # Calculate half year periods using ceiling to ensure we get at least half
+ half_year = _ceil(periods_per_year / 2)
+ return periods_per_year, half_year
+
+
+def _print_parameters_table(
+ benchmark_title=None,
+ periods_per_year=252,
+ rf=0.0,
+ compounded=True,
+ match_dates=True,
+):
+ """
+ Print a formatted parameters table for terminal/console output.
+
+ Parameters
+ ----------
+ benchmark_title : str or None
+ Benchmark name/ticker
+ periods_per_year : int
+ Number of trading periods per year
+ rf : float
+ Risk-free rate
+ compounded : bool
+ Whether returns are compounded
+ match_dates : bool
+ Whether dates are matched with benchmark
+ """
+ width = 40
+ print("=" * width)
+ print(" Parameters")
+ print("-" * width)
+ if benchmark_title:
+ print(f"{'Benchmark':<25}{benchmark_title.upper():>15}")
+ print(f"{'Periods/Year':<25}{periods_per_year:>15}")
+ print(f"{'Risk-Free Rate':<25}{rf:>14.1%}")
+ print(f"{'Compounded':<25}{'Yes' if compounded else 'No':>15}")
+ if benchmark_title:
+ print(f"{'Match Dates':<25}{'Yes' if match_dates else 'No':>15}")
+ print("=" * width)
+ print()
+
+
+def _match_dates(returns, benchmark):
+ """
+ Align returns and benchmark data to start from the same date.
+
+ This function ensures that both the returns and benchmark series start
+ from the same date by finding the latest start date where both series
+ have non-zero values. This is crucial for accurate performance comparisons.
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data that may be a Series or DataFrame with multiple columns
+ benchmark : pd.Series
+ Benchmark returns data
+
+ Returns
+ -------
+ tuple
+ A tuple containing (aligned_returns, aligned_benchmark) both starting
+ from the same date
+
+ Examples
+ --------
+ >>> returns_aligned, bench_aligned = _match_dates(returns, benchmark)
+ """
+ # Handle different types of returns data (Series vs DataFrame)
+ if isinstance(returns, _pd.DataFrame):
+ # For DataFrame, use the first column to find the start date
+ loc = max(returns[returns.columns[0]].ne(0).idxmax(), benchmark.ne(0).idxmax())
+ else:
+ # For Series, find the maximum of start dates for both series
+ loc = max(returns.ne(0).idxmax(), benchmark.ne(0).idxmax())
+
+ # Slice both series to start from the latest common start date
+ returns = returns.loc[loc:]
+ benchmark = benchmark.loc[loc:]
+
+ return returns, benchmark
+
+
+def html(
+ returns,
+ benchmark=None,
+ rf=0.0,
+ grayscale=False,
+ title="Strategy Tearsheet",
+ output=None,
+ compounded=True,
+ periods_per_year=252,
+ download_filename="quantstats-tearsheet.html",
+ figfmt="svg",
+ template_path=None,
+ match_dates=True,
+ **kwargs,
+):
+ """
+ Generate an HTML tearsheet report for portfolio performance analysis.
+
+ This function creates a comprehensive HTML report containing performance
+ metrics, visualizations, and analysis of investment returns. The report
+ includes comparisons with benchmarks, drawdown analysis, and various
+ performance charts.
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Daily returns data for the strategy/portfolio
+ benchmark : pd.Series, str, or None, default None
+ Benchmark returns for comparison. Can be a Series of returns,
+ a ticker symbol string, or None for no benchmark
+ rf : float, default 0.0
+ Risk-free rate for calculations (as decimal, e.g., 0.02 for 2%)
+ grayscale : bool, default False
+ Whether to generate charts in grayscale instead of color
+ title : str, default "Strategy Tearsheet"
+ Title to display at the top of the HTML report
+ output : str or None, default None
+ File path to save the HTML report. If None, downloads in browser
+ compounded : bool, default True
+ Whether to compound returns for calculations
+ periods_per_year : int, default 252
+ Number of trading periods per year for annualization
+ download_filename : str, default "quantstats-tearsheet.html"
+ Filename for browser download if output is None
+ figfmt : str, default "svg"
+ Format for embedded charts ('svg', 'png', 'jpg')
+ template_path : str or None, default None
+ Path to custom HTML template file. Uses default if None
+ match_dates : bool, default True
+ Whether to align returns and benchmark start dates
+ **kwargs
+ Additional keyword arguments for customization:
+ - strategy_title: Custom name for the strategy
+ - benchmark_title: Custom name for the benchmark
+ - active_returns: Whether to show active returns vs benchmark
+
+ Returns
+ -------
+ None
+ Generates HTML file either as download or saved to specified path
+
+ Examples
+ --------
+ >>> html(returns, benchmark='^GSPC', title='My Strategy')
+ >>> html(returns, output='report.html', grayscale=True)
+
+ Raises
+ ------
+ FileNotFoundError
+ If custom template_path doesn't exist
+ """
+ # Clean returns data by removing NaN values if date matching is enabled
+ if match_dates:
+ returns = returns.dropna()
+
+ # Get trading periods for calculations
+ win_year, win_half_year = _get_trading_periods(periods_per_year)
+
+ # Secure file path handling for HTML template
+ if template_path is None:
+ # Use default template path - report.html in same directory
+ template_path = Path(__file__).parent / 'report.html'
+ else:
+ template_path = Path(template_path)
+ # Resolve to absolute path and validate template file existence
+ template_path = template_path.resolve()
-def html(returns, benchmark=None, rf=0.,
- grayscale=False, title='Strategy Tearsheet',
- output=None, compounded=True):
+ if not template_path.exists():
+ raise FileNotFoundError(f"Template file not found: {template_path}")
+ if not template_path.is_file():
+ raise ValueError(f"Template path is not a file: {template_path}")
- if output is None and not _utils._in_notebook():
- raise ValueError("`file` must be specified")
+ # Read template securely with UTF-8 encoding
+ tpl = template_path.read_text(encoding='utf-8')
- tpl = ""
- with open(__file__[:-4] + '.html') as f:
- tpl = f.read()
- f.close()
+ # prepare timeseries
+ if match_dates:
+ returns = returns.dropna()
+ # Clean and prepare returns data for analysis
+ returns = _get_utils()._prepare_returns(returns)
- date_range = returns.index.strftime('%e %b, %Y')
- tpl = tpl.replace('{{date_range}}', date_range[0] + ' - ' + date_range[-1])
- tpl = tpl.replace('{{title}}', title)
- tpl = tpl.replace('{{v}}', __version__)
+ # Handle strategy title - can be single string or list for multiple columns
+ strategy_title = kwargs.get("strategy_title", "Strategy")
+ if isinstance(returns, _pd.DataFrame):
+ if len(returns.columns) > 1 and isinstance(strategy_title, str):
+ strategy_title = list(returns.columns)
- mtrx = metrics(returns=returns, benchmark=benchmark,
- rf=rf, display=False, mode='full',
- sep=True, internal="True",
- compounded=compounded)[2:]
- mtrx.index.name = 'Metric'
- tpl = tpl.replace('{{metrics}}', _html_table(mtrx))
- tpl = tpl.replace('
| | |
',
- '
|
')
- tpl = tpl.replace('
| |
',
- '
|
')
+ # Process benchmark data if provided
+ if benchmark is not None:
+ benchmark_title = kwargs.get("benchmark_title", "Benchmark")
+ # Auto-determine benchmark title if not provided
+ if kwargs.get("benchmark_title") is None:
+ if isinstance(benchmark, str):
+ benchmark_title = benchmark
+ elif isinstance(benchmark, _pd.Series):
+ benchmark_title = benchmark.name if benchmark.name else "Benchmark"
+ elif isinstance(benchmark, _pd.DataFrame):
+ col_name = benchmark[benchmark.columns[0]].name
+ benchmark_title = col_name if col_name else "Benchmark"
+
+ # Ensure benchmark_title is a string for .upper() call
+ if benchmark_title is None:
+ benchmark_title = "Benchmark"
+ # Store original benchmark before any alignment for accurate EOY calculations
+ # This preserves the full benchmark data including non-trading days
+ if isinstance(benchmark, str):
+ # Download the full benchmark data
+ benchmark_original = _get_utils().download_returns(benchmark)
+ if rf != 0:
+ benchmark_original = _get_utils().to_excess_returns(
+ benchmark_original, rf, nperiods=periods_per_year
+ )
+ elif isinstance(benchmark, _pd.Series):
+ benchmark_original = benchmark.copy()
+ else:
+ benchmark_original = benchmark
+ # Prepare benchmark data to match returns index and risk-free rate
+ benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf)
+ # Align dates between returns and benchmark if requested
+ if match_dates is True:
+ returns, benchmark = _match_dates(returns, benchmark)
+ else:
+ benchmark_title = None
+ benchmark_original = None
+
+ # Format date range for display in template
+ date_range = returns.index.strftime("%e %b, %Y")
+ tpl = tpl.replace("{{date_range}}", date_range[0] + " - " + date_range[-1])
+
+ # Build title with compounding indicator (only show if compounded)
+ full_title = f"{title} (Compounded)" if compounded else title
+ tpl = tpl.replace("{{title}}", full_title)
+ tpl = tpl.replace("{{v}}", __version__)
+
+ # Build parameters string for subtitle
+ params_parts = []
+
+ # Add user-provided parameters first if present
+ user_params = kwargs.get("parameters", {})
+ if user_params:
+ for key, value in user_params.items():
+ params_parts.append(f"{key}: {value}")
+
+ # Add auto-detected parameters (always show key params)
+ if benchmark_title:
+ params_parts.append(f"Benchmark: {benchmark_title.upper()}")
+ params_parts.append(f"Periods/Year: {periods_per_year}")
+ params_parts.append(f"RF: {rf:.1%}")
+
+ params_str = " • ".join(params_parts)
+ if params_str:
+ params_str += " | "
+ tpl = tpl.replace("{{params}}", params_str)
+
+ # Add matched dates indicator
+ matched_dates_str = " (matched dates)" if match_dates and benchmark is not None else ""
+ tpl = tpl.replace("{{matched_dates}}", matched_dates_str)
+
+ # Set names for data series to be used in charts and tables
+ if benchmark is not None:
+ benchmark.name = benchmark_title
+ if isinstance(returns, _pd.Series):
+ returns.name = strategy_title
+ elif isinstance(returns, _pd.DataFrame):
+ returns.columns = strategy_title
+
+ # Generate comprehensive performance metrics table
+ mtrx = metrics(
+ returns=returns,
+ benchmark=benchmark,
+ rf=rf,
+ display=False,
+ mode="full",
+ sep=True,
+ internal="True",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ )[2:]
+
+ # Format metrics table for HTML display
+ mtrx.index.name = "Metric"
+ tpl = tpl.replace("{{metrics}}", _html_table(mtrx))
+
+ # Handle table formatting for multiple columns
+ if isinstance(returns, _pd.DataFrame):
+ num_cols = len(returns.columns)
+ # Replace empty table rows with horizontal rule separators
+ for i in reversed(range(num_cols + 1, num_cols + 3)):
+ str_td = "
| " * i
+ tpl = tpl.replace(
+ f"
{str_td}
", '
|
'.format(i)
+ )
+
+ # Clean up table formatting with horizontal rules
+ tpl = tpl.replace(
+ "
| | |
", '
|
'
+ )
+ tpl = tpl.replace(
+ "
| |
", '
|
'
+ )
+ # Generate end-of-year (EOY) returns comparison table
if benchmark is not None:
- yoy = _stats.compare(returns, benchmark, "A", compounded=compounded)
- yoy.columns = ['Benchmark', 'Strategy', 'Multiplier', 'Won']
- yoy.index.name = 'Year'
- tpl = tpl.replace('{{eoy_title}}', '
EOY Returns vs Benchmark
')
- tpl = tpl.replace('{{eoy_table}}', _html_table(yoy))
+ # Use original benchmark for EOY comparison to preserve accurate yearly returns
+ # This prevents loss of benchmark returns on non-trading days
+ benchmark_for_eoy = benchmark_original if benchmark_original is not None else benchmark
+ yoy = _get_stats().compare(
+ returns, benchmark_for_eoy, "YE", compounded=compounded, prepare_returns=False
+ )
+ # Set appropriate column names based on data type
+ if isinstance(returns, _pd.Series):
+ yoy.columns = [benchmark_title, strategy_title, "Multiplier", "Won"]
+ elif isinstance(returns, _pd.DataFrame):
+ yoy.columns = list(
+ _pd.core.common.flatten([benchmark_title, strategy_title])
+ )
+ yoy.index.name = "Year"
+ tpl = tpl.replace("{{eoy_title}}", "
EOY Returns vs Benchmark
")
+ tpl = tpl.replace("{{eoy_table}}", _html_table(yoy))
else:
+ # Generate EOY returns table without benchmark comparison
# pct multiplier
- yoy = _pd.DataFrame(
- _utils.group_returns(returns, returns.index.year) * 100)
- yoy.columns = ['Return']
- yoy['Cumulative'] = _utils.group_returns(
- returns, returns.index.year, True)
- yoy['Return'] = yoy['Return'].round(2).astype(str) + '%'
- yoy['Cumulative'] = (yoy['Cumulative'] *
- 100).round(2).astype(str) + '%'
- yoy.index.name = 'Year'
- tpl = tpl.replace('{{eoy_title}}', '
EOY Returns
')
- tpl = tpl.replace('{{eoy_table}}', _html_table(yoy))
-
- dd = _stats.to_drawdown_series(returns)
- dd_info = _stats.drawdown_details(dd).sort_values(
- by='max drawdown', ascending=True)[:10]
-
- dd_info = dd_info[['start', 'end', 'max drawdown', 'days']]
- dd_info.columns = ['Started', 'Recovered', 'Drawdown', 'Days']
- tpl = tpl.replace('{{dd_info}}', _html_table(dd_info, False))
-
+ yoy = _pd.DataFrame(_get_utils().group_returns(returns, returns.index.year) * 100)
+ if isinstance(returns, _pd.Series):
+ yoy.columns = ["Return"]
+ yoy["Cumulative"] = _get_utils().group_returns(returns, returns.index.year, True) * 100
+ # Don't add "%" here - the CSS in report.html handles it via :after pseudo-element
+ # Adding "%" in Python causes double "%" display (bug #475)
+ elif isinstance(returns, _pd.DataFrame):
+ # Don't show cumulative for multiple strategy portfolios
+ # just show compounded like when we have a benchmark
+ yoy.columns = list(_pd.core.common.flatten(strategy_title))
+
+ yoy.index.name = "Year"
+ tpl = tpl.replace("{{eoy_title}}", "
EOY Returns
")
+ tpl = tpl.replace("{{eoy_table}}", _html_table(yoy))
+
+ # Generate drawdown analysis table
+ if isinstance(returns, _pd.Series):
+ # Calculate drawdown series and get worst drawdown periods
+ dd = _get_stats().to_drawdown_series(returns)
+ dd_info = _get_stats().drawdown_details(dd).sort_values(
+ by="max drawdown", ascending=True
+ )[:10]
+ dd_info = dd_info[["start", "end", "max drawdown", "days"]]
+ dd_info.columns = ["Started", "Recovered", "Drawdown", "Days"]
+ tpl = tpl.replace("{{dd_info}}", _html_table(dd_info, False))
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ dd_info_list = []
+ for col in returns.columns:
+ dd = _get_stats().to_drawdown_series(returns[col])
+ dd_info = _get_stats().drawdown_details(dd).sort_values(
+ by="max drawdown", ascending=True
+ )[:10]
+ dd_info = dd_info[["start", "end", "max drawdown", "days"]]
+ dd_info.columns = ["Started", "Recovered", "Drawdown", "Days"]
+ dd_info_list.append(_html_table(dd_info, False))
+
+ # Combine all drawdown tables with headers
+ dd_html_table = ""
+ for html_str, col in zip(dd_info_list, returns.columns):
+ dd_html_table = (
+ dd_html_table + f"
{col}
" + StringIO(html_str).read()
+ )
+ tpl = tpl.replace("{{dd_info}}", dd_html_table)
+
+ # Get active returns setting for plots
+ active = kwargs.get("active_returns", False)
+
+ # Generate all the performance plots and embed them in the HTML
# plots
- figfile = _utils._file_stream()
- _plots.returns(returns, benchmark, grayscale=grayscale,
- figsize=(8, 5), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, cumulative=compounded)
- tpl = tpl.replace('{{returns}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.log_returns(returns, benchmark, grayscale=grayscale,
- figsize=(8, 4), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, cumulative=compounded)
- tpl = tpl.replace('{{log_returns}}', figfile.getvalue().decode())
+ figfile = _get_utils()._file_stream()
+ _get_plots().returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 5),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compound=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{returns}}", _embed_figure(figfile, figfmt))
+
+ # Log returns plot for better visualization of performance
+ figfile = _get_utils()._file_stream()
+ _get_plots().log_returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compound=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{log_returns}}", _embed_figure(figfile, figfmt))
+ # Volatility-matched returns plot (only if benchmark exists)
if benchmark is not None:
- figfile = _utils._file_stream()
- _plots.returns(returns, benchmark, match_volatility=True,
- grayscale=grayscale, figsize=(8, 4), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, cumulative=compounded)
- tpl = tpl.replace('{{vol_returns}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.yearly_returns(returns, benchmark, grayscale=grayscale,
- figsize=(8, 4), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, compounded=compounded)
- tpl = tpl.replace('{{eoy_returns}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.histogram(returns, grayscale=grayscale,
- figsize=(8, 4), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, compounded=compounded)
- tpl = tpl.replace('{{monthly_dist}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.daily_returns(returns, grayscale=grayscale,
- figsize=(8, 3), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False)
- tpl = tpl.replace('{{daily_returns}}', figfile.getvalue().decode())
+ figfile = _get_utils()._file_stream()
+ _get_plots().returns(
+ returns,
+ benchmark,
+ match_volatility=True,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compound=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{vol_returns}}", _embed_figure(figfile, figfmt))
+
+ # Yearly returns comparison chart
+ figfile = _get_utils()._file_stream()
+ _get_plots().yearly_returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{eoy_returns}}", _embed_figure(figfile, figfmt))
+
+ # Returns distribution histogram
+ figfile = _get_utils()._file_stream()
+ _get_plots().histogram(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(7, 4),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{monthly_dist}}", _embed_figure(figfile, figfmt))
+
+ # Daily returns scatter plot
+ figfile = _get_utils()._file_stream()
+ _get_plots().daily_returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 3),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ prepare_returns=False,
+ active=active,
+ )
+ tpl = tpl.replace("{{daily_returns}}", _embed_figure(figfile, figfmt))
+ # Rolling beta analysis (only if benchmark exists)
if benchmark is not None:
- figfile = _utils._file_stream()
- _plots.rolling_beta(returns, benchmark, grayscale=grayscale,
- figsize=(8, 3), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False)
- tpl = tpl.replace('{{rolling_beta}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.rolling_volatility(returns, benchmark, grayscale=grayscale,
- figsize=(8, 3), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False)
- tpl = tpl.replace('{{rolling_vol}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.rolling_sharpe(returns, grayscale=grayscale,
- figsize=(8, 3), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False)
- tpl = tpl.replace('{{rolling_sharpe}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.rolling_sortino(returns, grayscale=grayscale,
- figsize=(8, 3), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False)
- tpl = tpl.replace('{{rolling_sortino}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.drawdowns_periods(returns, grayscale=grayscale,
- figsize=(8, 4), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, compounded=compounded)
- tpl = tpl.replace('{{dd_periods}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.drawdown(returns, grayscale=grayscale,
- figsize=(8, 3), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False)
- tpl = tpl.replace('{{dd_plot}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.monthly_heatmap(returns, grayscale=grayscale,
- figsize=(8, 4), cbar=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, compounded=compounded)
- tpl = tpl.replace('{{monthly_heatmap}}', figfile.getvalue().decode())
-
- figfile = _utils._file_stream()
- _plots.distribution(returns, grayscale=grayscale,
- figsize=(8, 4), subtitle=False,
- savefig={'fname': figfile, 'format': 'svg'},
- show=False, ylabel=False, compounded=compounded)
- tpl = tpl.replace('{{returns_dist}}', figfile.getvalue().decode())
-
- tpl = _regex.sub(r'\{\{(.*?)\}\}', '', tpl)
- tpl = tpl.replace('white-space:pre;', '')
-
+ figfile = _get_utils()._file_stream()
+ _get_plots().rolling_beta(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 3),
+ subtitle=False,
+ window1=win_half_year,
+ window2=win_year,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{rolling_beta}}", _embed_figure(figfile, figfmt))
+
+ # Rolling volatility analysis
+ figfile = _get_utils()._file_stream()
+ _get_plots().rolling_volatility(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 3),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ period=win_half_year,
+ periods_per_year=win_year,
+ )
+ tpl = tpl.replace("{{rolling_vol}}", _embed_figure(figfile, figfmt))
+
+ # Rolling Sharpe ratio analysis
+ figfile = _get_utils()._file_stream()
+ _get_plots().rolling_sharpe(
+ returns,
+ grayscale=grayscale,
+ figsize=(8, 3),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ period=win_half_year,
+ periods_per_year=win_year,
+ )
+ tpl = tpl.replace("{{rolling_sharpe}}", _embed_figure(figfile, figfmt))
+
+ # Rolling Sortino ratio analysis
+ figfile = _get_utils()._file_stream()
+ _get_plots().rolling_sortino(
+ returns,
+ grayscale=grayscale,
+ figsize=(8, 3),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ period=win_half_year,
+ periods_per_year=win_year,
+ )
+ tpl = tpl.replace("{{rolling_sortino}}", _embed_figure(figfile, figfmt))
+
+ # Drawdown periods analysis
+ figfile = _get_utils()._file_stream()
+ if isinstance(returns, _pd.Series):
+ _get_plots().drawdowns_periods(
+ returns,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ title=returns.name,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{dd_periods}}", _embed_figure(figfile, figfmt))
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ embed = []
+ for col in returns.columns:
+ _get_plots().drawdowns_periods(
+ returns[col],
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ title=col,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ prepare_returns=False,
+ )
+ embed.append(figfile)
+ tpl = tpl.replace("{{dd_periods}}", _embed_figure(embed, figfmt))
+
+ # Underwater (drawdown) plot
+ figfile = _get_utils()._file_stream()
+ _get_plots().drawdown(
+ returns,
+ grayscale=grayscale,
+ figsize=(8, 3),
+ subtitle=False,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ )
+ tpl = tpl.replace("{{dd_plot}}", _embed_figure(figfile, figfmt))
+
+ # Monthly returns heatmap
+ figfile = _get_utils()._file_stream()
+ if isinstance(returns, _pd.Series):
+ _get_plots().monthly_heatmap(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ cbar=False,
+ returns_label=returns.name,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ active=active,
+ )
+ tpl = tpl.replace("{{monthly_heatmap}}", _embed_figure(figfile, figfmt))
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ embed = []
+ for col in returns.columns:
+ _get_plots().monthly_heatmap(
+ returns[col],
+ benchmark,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ cbar=False,
+ returns_label=col,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ active=active,
+ )
+ embed.append(figfile)
+ tpl = tpl.replace("{{monthly_heatmap}}", _embed_figure(embed, figfmt))
+
+ # Returns distribution analysis
+ figfile = _get_utils()._file_stream()
+
+ if isinstance(returns, _pd.Series):
+ _get_plots().distribution(
+ returns,
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ title=returns.name,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ prepare_returns=False,
+ )
+ tpl = tpl.replace("{{returns_dist}}", _embed_figure(figfile, figfmt))
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ embed = []
+ for col in returns.columns:
+ _get_plots().distribution(
+ returns[col],
+ grayscale=grayscale,
+ figsize=(8, 4),
+ subtitle=False,
+ title=col,
+ savefig={"fname": figfile, "format": figfmt},
+ show=False,
+ ylabel="",
+ compounded=compounded,
+ prepare_returns=False,
+ )
+ embed.append(figfile)
+ tpl = tpl.replace("{{returns_dist}}", _embed_figure(embed, figfmt))
+
+ # Clean up any remaining template placeholders
+ tpl = _regex.sub(r"\{\{(.*?)\}\}", "", tpl)
+ tpl = tpl.replace("white-space:pre;", "")
+
+ # Handle output - either download in browser or save to file
if output is None:
- # _open_html(tpl)
- _download_html(tpl, 'quantstats-tearsheet.html')
+ if _get_utils()._in_notebook():
+ _download_html(tpl, download_filename)
+ else:
+ # Save to temp file and open in browser
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".html", delete=False, encoding="utf-8"
+ ) as f:
+ f.write(tpl)
+ temp_path = f.name
+ webbrowser.open("file://" + temp_path)
return
- with open(output, 'w', encoding='utf-8') as f:
+ # Write HTML content to specified output file
+ with open(output, "w", encoding="utf-8") as f:
f.write(tpl)
-def full(returns, benchmark=None, rf=0., grayscale=False,
- figsize=(8, 5), display=True, compounded=True):
-
- dd = _stats.to_drawdown_series(returns)
- dd_info = _stats.drawdown_details(dd).sort_values(
- by='max drawdown', ascending=True)[:5]
+def full(
+ returns,
+ benchmark=None,
+ rf=0.0,
+ grayscale=False,
+ figsize=(8, 5),
+ display=True,
+ compounded=True,
+ periods_per_year=252,
+ match_dates=True,
+ **kwargs,
+):
+ """
+ Generate a comprehensive performance analysis report.
+
+ This function creates a full performance analysis including metrics,
+ worst drawdowns analysis, and complete visualization suite. It's designed
+ for detailed portfolio analysis and can handle both single strategies
+ and multiple strategy comparisons.
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Daily returns data for the strategy/portfolio
+ benchmark : pd.Series, str, or None, default None
+ Benchmark returns for comparison
+ rf : float, default 0.0
+ Risk-free rate for calculations (as decimal)
+ grayscale : bool, default False
+ Whether to generate charts in grayscale
+ figsize : tuple, default (8, 5)
+ Figure size for plots as (width, height)
+ display : bool, default True
+ Whether to display results in notebook/console
+ compounded : bool, default True
+ Whether to compound returns for calculations
+ periods_per_year : int, default 252
+ Number of trading periods per year
+ match_dates : bool, default True
+ Whether to align returns and benchmark start dates
+ **kwargs
+ Additional keyword arguments:
+ - strategy_title: Custom name for the strategy
+ - benchmark_title: Custom name for the benchmark
+ - active_returns: Whether to show active returns vs benchmark
+
+ Returns
+ -------
+ None
+ Displays comprehensive analysis including metrics, drawdowns, and plots
+
+ Examples
+ --------
+ >>> full(returns, benchmark='^GSPC', rf=0.02)
+ >>> full(returns, figsize=(10, 6), grayscale=True)
+ """
+ # prepare timeseries
+ if match_dates:
+ returns = returns.dropna()
+ # Clean and prepare returns data
+ returns = _get_utils()._prepare_returns(returns)
+
+ # Process benchmark if provided
+ if benchmark is not None:
+ benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf)
+ if match_dates is True:
+ returns, benchmark = _match_dates(returns, benchmark)
- if not dd_info.empty:
- dd_info.index = range(1, min(6, len(dd_info)+1))
- dd_info.columns = map(lambda x: str(x).title(), dd_info.columns)
+ # Extract title parameters from kwargs
+ benchmark_title = None
+ if benchmark is not None:
+ benchmark_title = kwargs.get("benchmark_title", "Benchmark")
+ strategy_title = kwargs.get("strategy_title", "Strategy")
+ active = kwargs.get("active_returns", False)
- if _utils._in_notebook():
- iDisplay(iHTML('
Performance Metrics
'))
- iDisplay(metrics(returns=returns, benchmark=benchmark,
- rf=rf, display=display, mode='full',
- compounded=compounded))
- iDisplay(iHTML('
5 Worst Drawdowns
'))
- if dd_info.empty:
- iDisplay(iHTML("
(no drawdowns)
"))
- else:
- iDisplay(dd_info)
+ # Handle multiple strategy columns
+ if isinstance(returns, _pd.DataFrame):
+ if len(returns.columns) > 1 and isinstance(strategy_title, str):
+ strategy_title = list(returns.columns)
- iDisplay(iHTML('
Strategy Visualization
'))
+ # Set names for display purposes
+ if benchmark is not None:
+ benchmark.name = benchmark_title
+ if isinstance(returns, _pd.Series):
+ returns.name = strategy_title
+ elif isinstance(returns, _pd.DataFrame):
+ returns.columns = strategy_title
+
+ # Calculate drawdown analysis for worst periods display
+ dd = _get_stats().to_drawdown_series(returns)
+
+ # Process drawdown details based on data type
+ if isinstance(dd, _pd.Series):
+ col = _get_stats().drawdown_details(dd).columns[4]
+ dd_info = _get_stats().drawdown_details(dd).sort_values(by=col, ascending=True)[:5]
+ if not dd_info.empty:
+ dd_info.index = range(1, min(6, len(dd_info) + 1))
+ dd_info.columns = map(lambda x: str(x).title(), dd_info.columns)
+ elif isinstance(dd, _pd.DataFrame):
+ # Handle multiple strategy columns
+ col = _get_stats().drawdown_details(dd).columns.get_level_values(1)[4]
+ dd_info_dict = {}
+ for ptf in dd.columns:
+ dd_info = _get_stats().drawdown_details(dd[ptf]).sort_values(
+ by=col, ascending=True
+ )[:5]
+ if not dd_info.empty:
+ dd_info.index = range(1, min(6, len(dd_info) + 1))
+ dd_info.columns = map(lambda x: str(x).title(), dd_info.columns)
+ dd_info_dict[ptf] = dd_info
+
+ # Display results based on environment (notebook vs console)
+ if _get_utils()._in_notebook():
+ # Display in Jupyter notebook with HTML formatting
+ iDisplay(iHTML("
Performance Metrics
"))
+ iDisplay(
+ metrics(
+ returns=returns,
+ benchmark=benchmark,
+ rf=rf,
+ display=display,
+ mode="full",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ )
+ )
+
+ # Display worst drawdowns analysis
+ if isinstance(dd, _pd.Series):
+ iDisplay(iHTML('
Worst 5 Drawdowns
'))
+ if dd_info.empty:
+ iDisplay(iHTML("
(no drawdowns)
"))
+ else:
+ iDisplay(dd_info)
+ elif isinstance(dd, _pd.DataFrame):
+ # Display drawdowns for each strategy
+ for ptf, dd_info in dd_info_dict.items():
+ iDisplay(
+ iHTML(
+ '
%s - Worst 5 Drawdowns
'
+ % ptf
+ )
+ )
+ if dd_info.empty:
+ iDisplay(iHTML("
(no drawdowns)
"))
+ else:
+ iDisplay(dd_info)
+
+ iDisplay(iHTML("
Strategy Visualization
"))
else:
- print('[Performance Metrics]\n')
- metrics(returns=returns, benchmark=benchmark,
- rf=rf, display=display, mode='full',
- compounded=compounded)
- print('\n\n')
- print('[5 Worst Drawdowns]\n')
- if dd_info.empty:
- print("(no drawdowns)")
- else:
- print(_tabulate(dd_info, headers="keys",
- tablefmt='simple', floatfmt=".2f"))
- print('\n\n')
- print('[Strategy Visualization]\nvia Matplotlib')
-
- plots(returns=returns, benchmark=benchmark,
- grayscale=grayscale, figsize=figsize, mode='full')
+ # Display in console/terminal environment
+ _print_parameters_table(
+ benchmark_title=benchmark_title,
+ periods_per_year=periods_per_year,
+ rf=rf,
+ compounded=compounded,
+ match_dates=match_dates,
+ )
+ print("[Performance Metrics]\n")
+ metrics(
+ returns=returns,
+ benchmark=benchmark,
+ rf=rf,
+ display=display,
+ mode="full",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ )
+ print("\n\n")
+ print("[Worst 5 Drawdowns]\n")
+
+ # Display drawdowns in tabular format
+ if isinstance(dd, _pd.Series):
+ if dd_info.empty:
+ print("(no drawdowns)")
+ else:
+ print(
+ _tabulate(
+ dd_info, headers="keys", tablefmt="simple", floatfmt=".2f"
+ )
+ )
+ elif isinstance(dd, _pd.DataFrame):
+ for ptf, dd_info in dd_info_dict.items():
+ if dd_info.empty:
+ print("(no drawdowns)")
+ else:
+ print(f"{ptf}\n")
+ print(
+ _tabulate(
+ dd_info, headers="keys", tablefmt="simple", floatfmt=".2f"
+ )
+ )
+
+ print("\n\n")
+ print("[Strategy Visualization]\nvia Matplotlib")
+
+ # Generate comprehensive plots
+ plots(
+ returns=returns,
+ benchmark=benchmark,
+ grayscale=grayscale,
+ figsize=figsize,
+ mode="full",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ active=active,
+ )
-def basic(returns, benchmark=None, rf=0., grayscale=False,
- figsize=(8, 5), display=True, compounded=True):
+def basic(
+ returns,
+ benchmark=None,
+ rf=0.0,
+ grayscale=False,
+ figsize=(8, 5),
+ display=True,
+ compounded=True,
+ periods_per_year=252,
+ match_dates=True,
+ **kwargs,
+):
+ """
+ Generate a basic performance analysis report.
+
+ This function creates a simplified performance analysis with essential
+ metrics and basic visualizations. It's designed for quick portfolio
+ analysis when detailed analysis is not needed.
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Daily returns data for the strategy/portfolio
+ benchmark : pd.Series, str, or None, default None
+ Benchmark returns for comparison
+ rf : float, default 0.0
+ Risk-free rate for calculations (as decimal)
+ grayscale : bool, default False
+ Whether to generate charts in grayscale
+ figsize : tuple, default (8, 5)
+ Figure size for plots as (width, height)
+ display : bool, default True
+ Whether to display results in notebook/console
+ compounded : bool, default True
+ Whether to compound returns for calculations
+ periods_per_year : int, default 252
+ Number of trading periods per year
+ match_dates : bool, default True
+ Whether to align returns and benchmark start dates
+ **kwargs
+ Additional keyword arguments:
+ - strategy_title: Custom name for the strategy
+ - benchmark_title: Custom name for the benchmark
+ - active_returns: Whether to show active returns vs benchmark
+
+ Returns
+ -------
+ None
+ Displays basic analysis including essential metrics and plots
+
+ Examples
+ --------
+ >>> basic(returns, benchmark='^GSPC')
+ >>> basic(returns, figsize=(10, 6), display=False)
+ """
+ # prepare timeseries
+ if match_dates:
+ returns = returns.dropna()
+ # Clean and prepare returns data
+ returns = _get_utils()._prepare_returns(returns)
+
+ # Process benchmark if provided
+ if benchmark is not None:
+ benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf)
+ if match_dates is True:
+ returns, benchmark = _match_dates(returns, benchmark)
- if _utils._in_notebook():
- iDisplay(iHTML('
Performance Metrics
'))
- metrics(returns=returns, benchmark=benchmark,
- rf=rf, display=display, mode='basic',
- compounded=compounded)
- iDisplay(iHTML('
Strategy Visualization
'))
+ # Extract title parameters from kwargs
+ benchmark_title = None
+ if benchmark is not None:
+ benchmark_title = kwargs.get("benchmark_title", "Benchmark")
+ strategy_title = kwargs.get("strategy_title", "Strategy")
+ active = kwargs.get("active_returns", False)
+
+ # Handle multiple strategy columns
+ if isinstance(returns, _pd.DataFrame):
+ if len(returns.columns) > 1 and isinstance(strategy_title, str):
+ strategy_title = list(returns.columns)
+
+ # Display results based on environment (notebook vs console)
+ if _get_utils()._in_notebook():
+ # Display in Jupyter notebook with HTML formatting
+ iDisplay(iHTML("
Performance Metrics
"))
+ metrics(
+ returns=returns,
+ benchmark=benchmark,
+ rf=rf,
+ display=display,
+ mode="basic",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ )
+ iDisplay(iHTML("
Strategy Visualization
"))
else:
- print('[Performance Metrics]\n')
- metrics(returns=returns, benchmark=benchmark,
- rf=rf, display=display, mode='basic',
- compounded=compounded)
-
- print('\n\n')
- print('[Strategy Visualization]\nvia Matplotlib')
-
- plots(returns=returns, benchmark=benchmark,
- grayscale=grayscale, figsize=figsize, mode='basic')
-
-
-def metrics(returns, benchmark=None, rf=0., display=True,
- mode='basic', sep=False, compounded=True, **kwargs):
+ # Display in console/terminal environment
+ _print_parameters_table(
+ benchmark_title=benchmark_title,
+ periods_per_year=periods_per_year,
+ rf=rf,
+ compounded=compounded,
+ match_dates=match_dates,
+ )
+ print("[Performance Metrics]\n")
+ metrics(
+ returns=returns,
+ benchmark=benchmark,
+ rf=rf,
+ display=display,
+ mode="basic",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ )
+
+ print("\n\n")
+ print("[Strategy Visualization]\nvia Matplotlib")
+
+ # Generate basic plots
+ plots(
+ returns=returns,
+ benchmark=benchmark,
+ grayscale=grayscale,
+ figsize=figsize,
+ mode="basic",
+ compounded=compounded,
+ periods_per_year=periods_per_year,
+ prepare_returns=False,
+ benchmark_title=benchmark_title,
+ strategy_title=strategy_title,
+ active=active,
+ )
- if isinstance(returns, _pd.DataFrame) and len(returns.columns) > 1:
- raise ValueError("`returns` must be a pandas Series, "
- "but a multi-column DataFrame was passed")
+def metrics(
+ returns,
+ benchmark=None,
+ rf=0.0,
+ display=True,
+ mode="basic",
+ sep=False,
+ compounded=True,
+ periods_per_year=252,
+ prepare_returns=True,
+ match_dates=True,
+ **kwargs,
+):
+ """
+ Calculate comprehensive performance metrics for portfolio analysis.
+
+ This function computes a wide range of performance metrics including
+ returns, risk measures, ratios, and statistical measures. It can handle
+ both single strategies and multiple strategy comparisons with optional
+ benchmark analysis.
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Daily returns data for the strategy/portfolio
+ benchmark : pd.Series, str, or None, default None
+ Benchmark returns for comparison
+ rf : float, default 0.0
+ Risk-free rate for calculations (as decimal)
+ display : bool, default True
+ Whether to display results in formatted table
+ mode : str, default "basic"
+ Analysis mode - "basic" for essential metrics, "full" for comprehensive
+ sep : bool, default False
+ Whether to include separator rows in output
+ compounded : bool, default True
+ Whether to compound returns for calculations
+ periods_per_year : int, default 252
+ Number of trading periods per year
+ prepare_returns : bool, default True
+ Whether to prepare/clean returns data
+ match_dates : bool, default True
+ Whether to align returns and benchmark start dates
+ **kwargs
+ Additional keyword arguments:
+ - strategy_title: Custom name for the strategy
+ - benchmark_title: Custom name for the benchmark
+ - as_pct: Whether to return percentages
+ - internal: Internal calculation flag
+
+ Returns
+ -------
+ pd.DataFrame or None
+ DataFrame with performance metrics if display=False, else None
+
+ Examples
+ --------
+ >>> metrics_df = metrics(returns, benchmark='^GSPC', display=False)
+ >>> metrics(returns, mode="full", rf=0.02)
+ """
+ # Clean returns data if date matching is enabled
+ if match_dates:
+ returns = returns.dropna()
+ # Remove timezone information from index for consistent processing
+ returns.index = returns.index.tz_localize(None)
+
+ # Get trading periods for annualization calculations
+ win_year, _ = _get_trading_periods(periods_per_year)
+
+ # Extract column names from kwargs or use defaults
+ benchmark_colname = kwargs.get("benchmark_title", "Benchmark")
+ strategy_colname = kwargs.get("strategy_title", "Strategy")
+
+ # Handle benchmark column naming
if benchmark is not None:
- if isinstance(returns, _pd.DataFrame) and len(returns.columns) > 1:
- raise ValueError("`benchmark` must be a pandas Series, "
- "but a multi-column DataFrame was passed")
-
- blank = ['']
- df = _pd.DataFrame({"returns": _utils._prepare_returns(returns, rf)})
+ if isinstance(benchmark, str):
+ benchmark_colname = f"Benchmark ({benchmark.upper()})"
+ elif isinstance(benchmark, _pd.DataFrame) and len(benchmark.columns) > 1:
+ raise ValueError(
+ "`benchmark` must be a pandas Series, "
+ "but a multi-column DataFrame was passed"
+ )
+
+ # Handle strategy column naming for multiple strategies
+ if isinstance(returns, _pd.DataFrame):
+ if len(returns.columns) > 1:
+ blank = [""] * len(returns.columns)
+ if isinstance(strategy_colname, str):
+ strategy_colname = list(returns.columns)
+ else:
+ blank = [""]
+
+ # if isinstance(returns, _pd.DataFrame):
+ # if len(returns.columns) > 1:
+ # raise ValueError("`returns` needs to be a Pandas Series or one column DataFrame. "
+ # "multi colums DataFrame was passed")
+ # returns = returns[returns.columns[0]]
+
+ # Prepare returns data if requested
+ if prepare_returns:
+ df = _get_utils()._prepare_returns(returns)
+
+ # Create main DataFrame for calculations
+ if isinstance(returns, _pd.Series):
+ df = _pd.DataFrame({"returns": returns})
+ elif isinstance(returns, _pd.DataFrame):
+ df = _pd.DataFrame(
+ {
+ "returns_" + str(i + 1): returns[strategy_col]
+ for i, strategy_col in enumerate(returns.columns)
+ }
+ )
+
+ # Process benchmark data if provided
if benchmark is not None:
- blank = ['', '']
- df["benchmark"] = _utils._prepare_benchmark(
- benchmark, returns.index, rf)
+ benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf)
+ if match_dates is True:
+ returns, benchmark = _match_dates(returns, benchmark)
+ # Truncate df to the aligned date range to exclude leading zeros
+ df = df.loc[returns.index]
+ df["benchmark"] = benchmark
+ # Update blank list for proper formatting
+ if isinstance(returns, _pd.Series):
+ blank = ["", ""]
+ df["returns"] = returns
+ elif isinstance(returns, _pd.DataFrame):
+ blank = [""] * len(returns.columns) + [""]
+ for i, strategy_col in enumerate(returns.columns):
+ df["returns_" + str(i + 1)] = returns[strategy_col]
+
+ # Calculate start and end dates for each series
+ if isinstance(returns, _pd.Series):
+ s_start = {"returns": df["returns"].index.strftime("%Y-%m-%d")[0]}
+ s_end = {"returns": df["returns"].index.strftime("%Y-%m-%d")[-1]}
+ s_rf = {"returns": rf}
+ elif isinstance(returns, _pd.DataFrame):
+ df_strategy_columns = [col for col in df.columns if col != "benchmark"]
+ s_start = {
+ strategy_col: df[strategy_col].dropna().index.strftime("%Y-%m-%d")[0]
+ for strategy_col in df_strategy_columns
+ }
+ s_end = {
+ strategy_col: df[strategy_col].dropna().index.strftime("%Y-%m-%d")[-1]
+ for strategy_col in df_strategy_columns
+ }
+ s_rf = {strategy_col: rf for strategy_col in df_strategy_columns}
- df = df.dropna()
+ # Add benchmark dates if present
+ if "benchmark" in df:
+ s_start["benchmark"] = df["benchmark"].index.strftime("%Y-%m-%d")[0]
+ s_end["benchmark"] = df["benchmark"].index.strftime("%Y-%m-%d")[-1]
+ s_rf["benchmark"] = rf
+ # Fill missing values with zeros for calculations
+ df = df.fillna(0)
+
+ # Determine percentage multiplier for display
# pct multiplier
pct = 100 if display or "internal" in kwargs else 1
+ if kwargs.get("as_pct", False):
+ pct = 100
- # return df
- dd = _calc_dd(df, display=(display or "internal" in kwargs))
-
+ # Initialize metrics DataFrame with basic information
metrics = _pd.DataFrame()
+ metrics["Start Period"] = _pd.Series(s_start)
+ metrics["End Period"] = _pd.Series(s_end)
+ metrics["Risk-Free Rate %"] = _pd.Series(s_rf) * 100
+ metrics["Time in Market %"] = _get_stats().exposure(df, prepare_returns=False) * pct
- s_start = {'returns': df['returns'].index.strftime('%Y-%m-%d')[0]}
- s_end = {'returns': df['returns'].index.strftime('%Y-%m-%d')[-1]}
- s_rf = {'returns': rf}
-
- if "benchmark" in df:
- s_start['benchmark'] = df['benchmark'].index.strftime('%Y-%m-%d')[0]
- s_end['benchmark'] = df['benchmark'].index.strftime('%Y-%m-%d')[-1]
- s_rf['benchmark'] = rf
-
- metrics['Start Period'] = _pd.Series(s_start)
- metrics['End Period'] = _pd.Series(s_end)
- metrics['Risk-Free Rate %'] = _pd.Series(s_rf)
- metrics['Time in Market %'] = _stats.exposure(df) * pct
-
- metrics['~'] = blank
+ # Add separator row
+ metrics["~"] = blank
+ # Calculate return metrics based on compounding preference
if compounded:
- metrics['Cumulative Return %'] = (
- _stats.comp(df) * pct).map('{:,.2f}'.format)
+ metrics["Cumulative Return %"] = (_get_stats().comp(df) * pct).map("{:,.2f}".format)
else:
- metrics['Total Return %'] = (df.sum() * pct).map('{:,.2f}'.format)
+ metrics["Total Return %"] = (df.sum() * pct).map("{:,.2f}".format)
- metrics['CAGR%%'] = _stats.cagr(df, rf, compounded) * pct
- metrics['Sharpe'] = _stats.sharpe(df, rf)
- metrics['Sortino'] = _stats.sortino(df, rf)
- metrics['Max Drawdown %'] = blank
- metrics['Longest DD Days'] = blank
+ # Calculate annualized return (CAGR)
+ metrics["CAGR﹪%"] = _get_stats().cagr(df, rf, compounded, win_year) * pct
- if mode.lower() == 'full':
- ret_vol = _stats.volatility(df['returns']) * pct
- if "benchmark" in df:
- bench_vol = _stats.volatility(df['benchmark']) * pct
- metrics['Volatility (ann.) %'] = [ret_vol, bench_vol]
- metrics['R^2'] = _stats.r_squared(df['returns'], df['benchmark'])
- else:
- metrics['Volatility (ann.) %'] = [ret_vol]
+ # Add separator row
+ metrics["~~~~~~~~~~~~~~"] = blank
- metrics['Calmar'] = _stats.calmar(df)
- metrics['Skew'] = _stats.skew(df)
- metrics['Kurtosis'] = _stats.kurtosis(df)
+ # Calculate risk-adjusted return ratios
+ metrics["Sharpe"] = _get_stats().sharpe(df, rf, win_year, True)
+ metrics["Prob. Sharpe Ratio %"] = (
+ _get_stats().probabilistic_sharpe_ratio(df, rf, win_year, False) * pct
+ )
- metrics['~~~~~~~~~~'] = blank
+ # Add advanced Sharpe metrics for full mode
+ if mode.lower() == "full":
+ metrics["Smart Sharpe"] = _get_stats().smart_sharpe(df, rf, win_year, True)
+ # metrics['Prob. Smart Sharpe Ratio %'] = _get_stats().probabilistic_sharpe_ratio(df, rf, win_year, False, True) * pct
+
+ # Calculate Sortino ratio (downside deviation-based)
+ metrics["Sortino"] = _get_stats().sortino(df, rf, win_year, True)
+ if mode.lower() == "full":
+ # metrics['Prob. Sortino Ratio %'] = _get_stats().probabilistic_sortino_ratio(df, rf, win_year, False) * pct
+ metrics["Smart Sortino"] = _get_stats().smart_sortino(df, rf, win_year, True)
+ # metrics['Prob. Smart Sortino Ratio %'] = _get_stats().probabilistic_sortino_ratio(
+ # df, rf, win_year, False, True) * pct
+
+ # Calculate adjusted Sortino ratio
+ metrics["Sortino/√2"] = metrics["Sortino"] / _sqrt(2)
+ if mode.lower() == "full":
+ # metrics['Prob. Sortino/√2 Ratio %'] = _get_stats().probabilistic_adjusted_sortino_ratio(
+ # df, rf, win_year, False) * pct
+ metrics["Smart Sortino/√2"] = metrics["Smart Sortino"] / _sqrt(2)
+ # metrics['Prob. Smart Sortino/√2 Ratio %'] = _get_stats().probabilistic_adjusted_sortino_ratio(
+ # df, rf, win_year, False, True) * pct
+
+ # Calculate Omega ratio (probability-weighted ratio)
+ if isinstance(returns, _pd.Series):
+ if "benchmark" in df:
+ metrics["Omega"] = [
+ _get_stats().omega(df["returns"], rf, 0.0, win_year),
+ _get_stats().omega(df["benchmark"], rf, 0.0, win_year),
+ ]
+ else:
+ metrics["Omega"] = _get_stats().omega(df["returns"], rf, 0.0, win_year)
+ elif isinstance(returns, _pd.DataFrame):
+ omega_values = [
+ _get_stats().omega(df[strategy_col], rf, 0.0, win_year)
+ for strategy_col in df_strategy_columns
+ ]
+ if "benchmark" in df:
+ omega_values.append(_get_stats().omega(df["benchmark"], rf, 0.0, win_year))
+ metrics["Omega"] = omega_values
+
+ # Add separator and prepare for drawdown metrics
+ metrics["~~~~~~~~"] = blank
+ metrics["Max Drawdown %"] = blank
+ metrics["Max DD Date"] = blank
+ metrics["Max DD Period Start"] = blank
+ metrics["Max DD Period End"] = blank
+ metrics["Longest DD Days"] = blank
+
+ # Add detailed volatility and risk metrics for full mode
+ if mode.lower() == "full":
+ # Calculate annualized volatility
+ if isinstance(returns, _pd.Series):
+ ret_vol = (
+ _get_stats().volatility(df["returns"], win_year, True, prepare_returns=False)
+ * pct
+ )
+ elif isinstance(returns, _pd.DataFrame):
+ ret_vol = [
+ _get_stats().volatility(
+ df[strategy_col], win_year, True, prepare_returns=False
+ )
+ * pct
+ for strategy_col in df_strategy_columns
+ ]
+
+ # Add benchmark volatility if present
+ if "benchmark" in df:
+ bench_vol = (
+ _get_stats().volatility(
+ df["benchmark"], win_year, True, prepare_returns=False
+ )
+ * pct
+ )
+
+ vol_ = [ret_vol, bench_vol]
+ if isinstance(ret_vol, list):
+ metrics["Volatility (ann.) %"] = list(_pd.core.common.flatten(vol_))
+ else:
+ metrics["Volatility (ann.) %"] = vol_
+
+ # Calculate benchmark-relative metrics
+ if isinstance(returns, _pd.Series):
+ metrics["R^2"] = _get_stats().r_squared(
+ df["returns"], df["benchmark"], prepare_returns=False
+ )
+ metrics["Information Ratio"] = _get_stats().information_ratio(
+ df["returns"], df["benchmark"], prepare_returns=False
+ )
+ elif isinstance(returns, _pd.DataFrame):
+ metrics["R^2"] = (
+ [
+ _get_stats().r_squared(
+ df[strategy_col], df["benchmark"], prepare_returns=False
+ ).round(2)
+ for strategy_col in df_strategy_columns
+ ]
+ ) + ["-"]
+ metrics["Information Ratio"] = (
+ [
+ _get_stats().information_ratio(
+ df[strategy_col], df["benchmark"], prepare_returns=False
+ ).round(2)
+ for strategy_col in df_strategy_columns
+ ]
+ ) + ["-"]
+ else:
+ # No benchmark case
+ if isinstance(returns, _pd.Series):
+ metrics["Volatility (ann.) %"] = [ret_vol]
+ elif isinstance(returns, _pd.DataFrame):
+ metrics["Volatility (ann.) %"] = ret_vol
+
+ # Additional risk and return metrics
+ metrics["Calmar"] = _get_stats().calmar(
+ df, prepare_returns=False, periods=win_year, compounded=compounded
+ )
+ metrics["Skew"] = _get_stats().skew(df, prepare_returns=False)
+ metrics["Kurtosis"] = _get_stats().kurtosis(df, prepare_returns=False)
+
+ # Additional ratios
+ metrics["Ulcer Performance Index"] = _get_stats().ulcer_performance_index(df, rf)
+ metrics["Risk-Adjusted Return %"] = _get_stats().rar(df, rf) * pct
+ metrics["Risk-Return Ratio"] = _get_stats().risk_return_ratio(df, prepare_returns=False)
+
+ # Add separator
+ metrics["~~~~~~~~~~"] = blank
+
+ # Average return metrics
+ metrics["Avg. Return %"] = _get_stats().avg_return(df, prepare_returns=False) * pct
+ metrics["Avg. Win %"] = _get_stats().avg_win(df, prepare_returns=False) * pct
+ metrics["Avg. Loss %"] = _get_stats().avg_loss(df, prepare_returns=False) * pct
+ metrics["Win/Loss Ratio"] = _get_stats().win_loss_ratio(df, prepare_returns=False)
+ metrics["Profit Ratio"] = _get_stats().profit_ratio(df, prepare_returns=False)
+
+ # Add separator
+ metrics["~~~~~~~~~~~"] = blank
+
+ # Expected returns at different frequencies
+ metrics["Expected Daily %%"] = (
+ _get_stats().expected_return(df, compounded=compounded, prepare_returns=False)
+ * pct
+ )
+ metrics["Expected Monthly %%"] = (
+ _get_stats().expected_return(
+ df, compounded=compounded, aggregate="ME", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Expected Yearly %%"] = (
+ _get_stats().expected_return(
+ df, compounded=compounded, aggregate="YE", prepare_returns=False
+ )
+ * pct
+ )
+
+ # Risk management metrics
+ metrics["Kelly Criterion %"] = (
+ _get_stats().kelly_criterion(df, prepare_returns=False) * pct
+ )
+ metrics["Risk of Ruin %"] = _get_stats().risk_of_ruin(df, prepare_returns=False)
+
+ # Value at Risk metrics
+ metrics["Daily Value-at-Risk %"] = -abs(
+ _get_stats().var(df, prepare_returns=False) * pct
+ )
+ metrics["Expected Shortfall (cVaR) %"] = -abs(
+ _get_stats().cvar(df, prepare_returns=False) * pct
+ )
+
+ # Add separator
+ metrics["~~~~~~"] = blank
+
+ # Consecutive wins/losses analysis (full mode only)
+ if mode.lower() == "full":
+ metrics["Max Consecutive Wins *int"] = _get_stats().consecutive_wins(df)
+ metrics["Max Consecutive Losses *int"] = _get_stats().consecutive_losses(df)
+
+ # Pain-based metrics (Gain/Pain ratio)
+ metrics["Gain/Pain Ratio"] = _get_stats().gain_to_pain_ratio(df, rf)
+ metrics["Gain/Pain (1M)"] = _get_stats().gain_to_pain_ratio(df, rf, "ME")
+ # if mode.lower() == 'full':
+ # metrics['GPR (3M)'] = _get_stats().gain_to_pain_ratio(df, rf, "QE")
+ # metrics['GPR (6M)'] = _get_stats().gain_to_pain_ratio(df, rf, "2Q")
+ # metrics['GPR (1Y)'] = _get_stats().gain_to_pain_ratio(df, rf, "YE")
+
+ # Add separator
+ metrics["~~~~~~~"] = blank
+
+ # Trading-based performance metrics
+ metrics["Payoff Ratio"] = _get_stats().payoff_ratio(df, prepare_returns=False)
+ metrics["Profit Factor"] = _get_stats().profit_factor(df, prepare_returns=False)
+ metrics["Common Sense Ratio"] = _get_stats().common_sense_ratio(df, prepare_returns=False)
+ metrics["CPC Index"] = _get_stats().cpc_index(df, prepare_returns=False)
+ metrics["Tail Ratio"] = _get_stats().tail_ratio(df, prepare_returns=False)
+ metrics["Outlier Win Ratio"] = _get_stats().outlier_win_ratio(df, prepare_returns=False)
+ metrics["Outlier Loss Ratio"] = _get_stats().outlier_loss_ratio(df, prepare_returns=False)
+
+ # # returns
+ metrics["~~"] = blank
+
+ # Time-based return analysis
+ today = df.index[-1] # _dt.today()
+ m3 = today - relativedelta(months=3)
+ m6 = today - relativedelta(months=6)
+ y1 = today - relativedelta(years=1)
- metrics['Expected Daily %%'] = _stats.expected_return(df) * pct
- metrics['Expected Monthly %%'] = _stats.expected_return(
- df, aggregate='M') * pct
- metrics['Expected Yearly %%'] = _stats.expected_return(
- df, aggregate='A') * pct
- metrics['Kelly Criterion %'] = _stats.kelly_criterion(df) * pct
- metrics['Risk of Ruin %'] = _stats.risk_of_ruin(df)
+ # Calculate period returns based on compounding preference
+ if compounded:
+ metrics["MTD %"] = (
+ _get_stats().comp(df[df.index >= _dt(today.year, today.month, 1)]) * pct
+ )
+ metrics["3M %"] = _get_stats().comp(df[df.index >= m3]) * pct
+ metrics["6M %"] = _get_stats().comp(df[df.index >= m6]) * pct
+ metrics["YTD %"] = _get_stats().comp(df[df.index >= _dt(today.year, 1, 1)]) * pct
+ metrics["1Y %"] = _get_stats().comp(df[df.index >= y1]) * pct
+ else:
+ metrics["MTD %"] = (
+ _np.sum(df[df.index >= _dt(today.year, today.month, 1)], axis=0) * pct
+ )
+ metrics["3M %"] = _np.sum(df[df.index >= m3], axis=0) * pct
+ metrics["6M %"] = _np.sum(df[df.index >= m6], axis=0) * pct
+ metrics["YTD %"] = _np.sum(df[df.index >= _dt(today.year, 1, 1)], axis=0) * pct
+ metrics["1Y %"] = _np.sum(df[df.index >= y1], axis=0) * pct
+
+ # Multi-year annualized returns
+ d = today - relativedelta(months=35)
+ metrics["3Y (ann.) %"] = (
+ _get_stats().cagr(df[df.index >= d], 0.0, compounded, win_year) * pct
+ )
- metrics['Daily Value-at-Risk %'] = -abs(_stats.var(df) * pct)
- metrics['Expected Shortfall (cVaR) %'] = -abs(_stats.cvar(df) * pct)
+ d = today - relativedelta(months=59)
+ metrics["5Y (ann.) %"] = (
+ _get_stats().cagr(df[df.index >= d], 0.0, compounded, win_year) * pct
+ )
- metrics['~~~~~~'] = blank
+ d = today - relativedelta(years=10)
+ metrics["10Y (ann.) %"] = (
+ _get_stats().cagr(df[df.index >= d], 0.0, compounded, win_year) * pct
+ )
- metrics['Payoff Ratio'] = _stats.payoff_ratio(df)
- metrics['Profit Factor'] = _stats.profit_factor(df)
- metrics['Common Sense Ratio'] = _stats.common_sense_ratio(df)
- metrics['CPC Index'] = _stats.cpc_index(df)
- metrics['Tail Ratio'] = _stats.tail_ratio(df)
- metrics['Outlier Win Ratio'] = _stats.outlier_win_ratio(df)
- metrics['Outlier Loss Ratio'] = _stats.outlier_loss_ratio(df)
+ metrics["All-time (ann.) %"] = _get_stats().cagr(df, 0.0, compounded, win_year) * pct
- # returns
- metrics['~~'] = blank
- comp_func = _stats.comp if compounded else _np.sum
+ # Best/worst period analysis (full mode only)
+ # best/worst
+ if mode.lower() == "full":
+ metrics["~~~"] = blank
+ metrics["Best Day %"] = (
+ _get_stats().best(df, compounded=compounded, prepare_returns=False) * pct
+ )
+ metrics["Worst Day %"] = _get_stats().worst(df, prepare_returns=False) * pct
+ metrics["Best Month %"] = (
+ _get_stats().best(
+ df, compounded=compounded, aggregate="ME", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Worst Month %"] = (
+ _get_stats().worst(df, aggregate="ME", prepare_returns=False) * pct
+ )
+ metrics["Best Year %"] = (
+ _get_stats().best(
+ df, compounded=compounded, aggregate="YE", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Worst Year %"] = (
+ _get_stats().worst(
+ df, compounded=compounded, aggregate="YE", prepare_returns=False
+ )
+ * pct
+ )
+
+ # Calculate and integrate drawdown metrics
+ # return drawdown (dd) df
+ dd = _calc_dd(
+ df,
+ display=(display or "internal" in kwargs),
+ as_pct=kwargs.get("as_pct", False),
+ )
- today = df.index[-1] # _dt.today()
- metrics['MTD %'] = comp_func(
- df[df.index >= _dt(today.year, today.month, 1)]) * pct
-
- d = today - _td(3*365/12)
- metrics['3M %'] = comp_func(
- df[df.index >= _dt(d.year, d.month, d.day)]) * pct
-
- d = today - _td(6*365/12)
- metrics['6M %'] = comp_func(
- df[df.index >= _dt(d.year, d.month, d.day)]) * pct
-
- metrics['YTD %'] = comp_func(df[df.index >= _dt(today.year, 1, 1)]) * pct
-
- d = today - _td(12*365/12)
- metrics['1Y %'] = comp_func(
- df[df.index >= _dt(d.year, d.month, d.day)]) * pct
- metrics['3Y (ann.) %'] = _stats.cagr(
- df[df.index >= _dt(today.year-3, today.month, today.day)
- ], 0., compounded) * pct
- metrics['5Y (ann.) %'] = _stats.cagr(
- df[df.index >= _dt(today.year-5, today.month, today.day)
- ], 0., compounded) * pct
- metrics['10Y (ann.) %'] = _stats.cagr(
- df[df.index >= _dt(today.year-10, today.month, today.day)
- ], 0., compounded) * pct
- metrics['All-time (ann.) %'] = _stats.cagr(df, 0., compounded) * pct
+ # Add drawdown metrics to main metrics DataFrame
+ # drawdown (dd) detail
+ metrics["~~~~"] = blank
+ # Properly integrate drawdown data into metrics
+ for metric_name in dd.index:
+ metrics[metric_name] = dd.loc[metric_name].values
- # best/worst
- if mode.lower() == 'full':
- metrics['~~~'] = blank
- metrics['Best Day %'] = _stats.best(df) * pct
- metrics['Worst Day %'] = _stats.worst(df) * pct
- metrics['Best Month %'] = _stats.best(df, aggregate='M') * pct
- metrics['Worst Month %'] = _stats.worst(df, aggregate='M') * pct
- metrics['Best Year %'] = _stats.best(df, aggregate='A') * pct
- metrics['Worst Year %'] = _stats.worst(df, aggregate='A') * pct
-
- # dd
- metrics['~~~~'] = blank
- for ix, row in dd.iterrows():
- metrics[ix] = row
- metrics['Recovery Factor'] = _stats.recovery_factor(df)
- metrics['Ulcer Index'] = _stats.ulcer_index(df, rf)
+ # Additional drawdown-based metrics
+ metrics["Recovery Factor"] = _get_stats().recovery_factor(df)
+ metrics["Ulcer Index"] = _get_stats().ulcer_index(df)
+ metrics["Serenity Index"] = _get_stats().serenity_index(df, rf)
+ # Win rate analysis (full mode only)
# win rate
- if mode.lower() == 'full':
- metrics['~~~~~'] = blank
- metrics['Avg. Up Month %'] = _stats.avg_win(df, aggregate='M') * pct
- metrics['Avg. Down Month %'] = _stats.avg_loss(df, aggregate='M') * pct
- metrics['Win Days %%'] = _stats.win_rate(df) * pct
- metrics['Win Month %%'] = _stats.win_rate(df, aggregate='M') * pct
- metrics['Win Quarter %%'] = _stats.win_rate(df, aggregate='Q') * pct
- metrics['Win Year %%'] = _stats.win_rate(df, aggregate='A') * pct
-
+ if mode.lower() == "full":
+ metrics["~~~~~"] = blank
+ metrics["Avg. Up Month %"] = (
+ _get_stats().avg_win(
+ df, compounded=compounded, aggregate="ME", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Avg. Down Month %"] = (
+ _get_stats().avg_loss(
+ df, compounded=compounded, aggregate="ME", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Win Days %%"] = _get_stats().win_rate(df, prepare_returns=False) * pct
+ metrics["Win Month %%"] = (
+ _get_stats().win_rate(
+ df, compounded=compounded, aggregate="ME", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Win Quarter %%"] = (
+ _get_stats().win_rate(
+ df, compounded=compounded, aggregate="QE", prepare_returns=False
+ )
+ * pct
+ )
+ metrics["Win Year %%"] = (
+ _get_stats().win_rate(
+ df, compounded=compounded, aggregate="YE", prepare_returns=False
+ )
+ * pct
+ )
+
+ # Greek letters and correlation analysis (if benchmark exists)
if "benchmark" in df:
- metrics['~~~~~~~'] = blank
- greeks = _stats.greeks(df['returns'], df['benchmark'])
- metrics['Beta'] = [str(round(greeks['beta'], 2)), '-']
- metrics['Alpha'] = [str(round(greeks['alpha'], 2)), '-']
-
+ metrics["~~~~~~~~~~~~"] = blank
+ if isinstance(returns, _pd.Series):
+ # Calculate Greek letters (Beta, Alpha) for single strategy
+ greeks = _get_stats().greeks(
+ df["returns"], df["benchmark"], win_year, prepare_returns=False
+ )
+ metrics["Beta"] = [str(round(greeks["beta"], 2)), "-"]
+ metrics["Alpha"] = [str(round(greeks["alpha"], 2)), "-"]
+ metrics["Correlation"] = [
+ str(round(df["benchmark"].corr(df["returns"]) * pct, 2)) + "%",
+ "-",
+ ]
+ metrics["Treynor Ratio"] = [
+ str(
+ round(
+ _get_stats().treynor_ratio(
+ df["returns"], df["benchmark"], win_year, rf
+ )
+ * pct,
+ 2,
+ )
+ )
+ + "%",
+ "-",
+ ]
+ elif isinstance(returns, _pd.DataFrame):
+ # Calculate Greek letters for multiple strategies
+ greeks = [
+ _get_stats().greeks(
+ df[strategy_col],
+ df["benchmark"],
+ win_year,
+ prepare_returns=False,
+ )
+ for strategy_col in df_strategy_columns
+ ]
+ metrics["Beta"] = [str(round(g["beta"], 2)) for g in greeks] + ["-"]
+ metrics["Alpha"] = [str(round(g["alpha"], 2)) for g in greeks] + ["-"]
+ metrics["Correlation"] = (
+ [
+ str(round(df["benchmark"].corr(df[strategy_col]) * pct, 2))
+ + "%"
+ for strategy_col in df_strategy_columns
+ ]
+ ) + ["-"]
+ metrics["Treynor Ratio"] = (
+ [
+ str(
+ round(
+ _get_stats().treynor_ratio(
+ df[strategy_col], df["benchmark"], win_year, rf
+ )
+ * pct,
+ 2,
+ )
+ )
+ + "%"
+ for strategy_col in df_strategy_columns
+ ]
+ ) + ["-"]
+
+ # Format metrics for display
# prepare for display
for col in metrics.columns:
try:
+ # Try to convert to float and round
metrics[col] = metrics[col].astype(float).round(2)
if display or "internal" in kwargs:
metrics[col] = metrics[col].astype(str)
- except Exception:
+ except (ValueError, TypeError, AttributeError):
pass
+ # Handle integer columns (marked with *int)
+ if (display or "internal" in kwargs) and "*int" in col:
+ metrics[col] = metrics[col].str.replace(".0", "", regex=False)
+ metrics.rename({col: col.replace("*int", "")}, axis=1, inplace=True)
+ # Add percentage signs to percentage columns
if (display or "internal" in kwargs) and "%" in col:
- metrics[col] = metrics[col] + '%'
+ metrics[col] = metrics[col] + "%"
+
+ # Format drawdown days as integers
try:
- metrics['Longest DD Days'] = _pd.to_numeric(
- metrics['Longest DD Days']).astype('int')
- metrics['Avg. Drawdown Days'] = _pd.to_numeric(
- metrics['Avg. Drawdown Days']).astype('int')
+ metrics["Longest DD Days"] = _pd.to_numeric(metrics["Longest DD Days"]).astype(
+ "int"
+ )
+ metrics["Avg. Drawdown Days"] = _pd.to_numeric(
+ metrics["Avg. Drawdown Days"]
+ ).astype("int")
if display or "internal" in kwargs:
- metrics['Longest DD Days'] = metrics['Longest DD Days'].astype(str)
- metrics['Avg. Drawdown Days'] = metrics['Avg. Drawdown Days'
- ].astype(str)
+ metrics["Longest DD Days"] = metrics["Longest DD Days"].astype(str)
+ metrics["Avg. Drawdown Days"] = metrics["Avg. Drawdown Days"].astype(str)
except Exception:
- metrics['Longest DD Days'] = '-'
- metrics['Avg. Drawdown Days'] = '-'
+ metrics["Longest DD Days"] = "-"
+ metrics["Avg. Drawdown Days"] = "-"
if display or "internal" in kwargs:
- metrics['Longest DD Days'] = '-'
- metrics['Avg. Drawdown Days'] = '-'
+ metrics["Longest DD Days"] = "-"
+ metrics["Avg. Drawdown Days"] = "-"
- metrics.columns = [
- col if '~' not in col else '' for col in metrics.columns]
- metrics.columns = [
- col[:-1] if '%' in col else col for col in metrics.columns]
+ # Clean up column names (remove separators and percentage signs)
+ metrics.columns = [col if "~" not in col else "" for col in metrics.columns]
+ metrics.columns = [col[:-1] if "%" in col else col for col in metrics.columns]
metrics = metrics.T
+ # Set appropriate column names
if "benchmark" in df:
- metrics.columns = ['Strategy', 'Benchmark']
+ column_names = [strategy_colname, benchmark_colname]
+ if isinstance(strategy_colname, list):
+ metrics.columns = list(_pd.core.common.flatten(column_names))
+ else:
+ metrics.columns = column_names
else:
- metrics.columns = ['Strategy']
+ if isinstance(strategy_colname, list):
+ metrics.columns = strategy_colname
+ else:
+ metrics.columns = [strategy_colname]
+
+ # Final data cleaning
+ # cleanups
+ metrics.replace([-0, "-0"], 0, inplace=True)
+ metrics.replace(
+ [
+ _np.nan,
+ -_np.nan,
+ _np.inf,
+ -_np.inf,
+ "-nan%",
+ "nan%",
+ "-nan",
+ "nan",
+ "-inf%",
+ "inf%",
+ "-inf",
+ "inf",
+ ],
+ "-",
+ inplace=True,
+ )
+
+ # Reorder columns to put benchmark first if present
+ # move benchmark to be the first column always if present
+ if "benchmark" in df:
+ metrics = metrics[
+ [benchmark_colname]
+ + [col for col in metrics.columns if col != benchmark_colname]
+ ]
+ # Handle display vs return
if display:
- print(_tabulate(metrics, headers="keys", tablefmt='simple'))
+ # Build and display parameters table (feature #472)
+ params_data = {
+ "Parameter": ["Risk-Free Rate", "Periods/Year", "Compounded", "Match Dates"],
+ "Value": [
+ f"{rf:.1%}" if rf != 0 else "0.0%",
+ str(periods_per_year),
+ "Yes" if compounded else "No",
+ "Yes" if match_dates else "No",
+ ],
+ }
+ if benchmark is not None:
+ params_data["Parameter"].insert(0, "Benchmark")
+ params_data["Value"].insert(0, benchmark_colname)
+ params_df = _pd.DataFrame(params_data)
+ print("\n" + _tabulate(params_df, headers="keys", tablefmt="simple", showindex=False))
+ print("\n")
+ print(_tabulate(metrics, headers="keys", tablefmt="simple"))
return None
+ # Remove separator rows if not requested
if not sep:
- metrics = metrics[metrics.index != '']
- return metrics
+ metrics = metrics[metrics.index != ""]
+ # Final formatting for programmatic use
+ # remove spaces from column names
+ metrics = metrics.T
+ metrics.columns = [
+ c.replace(" %", "").replace(" *int", "").strip() for c in metrics.columns
+ ]
+ metrics = metrics.T
-def plots(returns, benchmark=None, grayscale=False,
- figsize=(8, 5), mode='basic', compounded=True):
+ return metrics
- if mode.lower() != 'full':
- _plots.snapshot(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]),
- show=True, mode=("comp" if compounded else "sum"))
- _plots.monthly_heatmap(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False,
- compounded=compounded)
+def plots(
+ returns,
+ benchmark=None,
+ grayscale=False,
+ figsize=(8, 5),
+ mode="basic",
+ compounded=True,
+ periods_per_year=252,
+ prepare_returns=True,
+ match_dates=True,
+ **kwargs,
+):
+ """
+ Generate comprehensive visualization plots for portfolio performance.
+
+ This function creates a complete set of performance visualization plots
+ including returns, drawdowns, distributions, and rolling metrics. It can
+ generate either basic plots or a full comprehensive suite.
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Daily returns data for the strategy/portfolio
+ benchmark : pd.Series, str, or None, default None
+ Benchmark returns for comparison
+ grayscale : bool, default False
+ Whether to generate charts in grayscale
+ figsize : tuple, default (8, 5)
+ Figure size for plots as (width, height)
+ mode : str, default "basic"
+ Plot mode - "basic" for essential plots, "full" for comprehensive suite
+ compounded : bool, default True
+ Whether to compound returns for calculations
+ periods_per_year : int, default 252
+ Number of trading periods per year
+ prepare_returns : bool, default True
+ Whether to prepare/clean returns data
+ match_dates : bool, default True
+ Whether to align returns and benchmark start dates
+ **kwargs
+ Additional keyword arguments:
+ - strategy_title: Custom name for the strategy
+ - benchmark_title: Custom name for the benchmark
+ - active: Whether to show active returns vs benchmark
+
+ Returns
+ -------
+ None
+ Displays various performance plots
+
+ Examples
+ --------
+ >>> plots(returns, benchmark='^GSPC', mode="full")
+ >>> plots(returns, grayscale=True, figsize=(10, 6))
+ """
+ # Extract title parameters from kwargs
+ benchmark_colname = kwargs.get("benchmark_title", "Benchmark")
+ strategy_colname = kwargs.get("strategy_title", "Strategy")
+ active = kwargs.get("active", False)
+
+ # Handle multiple strategy columns
+ if isinstance(returns, _pd.DataFrame):
+ if len(returns.columns) > 1:
+ if isinstance(strategy_colname, str):
+ strategy_colname = list(returns.columns)
+
+ # Get trading periods for rolling window calculations
+ win_year, win_half_year = _get_trading_periods(periods_per_year)
+
+ # Clean returns data if date matching is enabled
+ if match_dates is True:
+ returns = returns.dropna()
+
+ # Prepare returns data if requested
+ if prepare_returns:
+ returns = _get_utils()._prepare_returns(returns)
+
+ # Set names for display in plots
+ if isinstance(returns, _pd.Series):
+ returns.name = strategy_colname
+ elif isinstance(returns, _pd.DataFrame):
+ returns.columns = strategy_colname
+
+ # Generate basic plots (snapshot and heatmap)
+ if mode.lower() != "full":
+ # Performance snapshot plot
+ _get_plots().snapshot(
+ returns,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0]),
+ show=True,
+ mode=("comp" if compounded else "sum"),
+ benchmark_title=benchmark_colname,
+ strategy_title=strategy_colname,
+ )
+
+ # Monthly returns heatmap
+ if isinstance(returns, _pd.Series):
+ _get_plots().monthly_heatmap(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ compounded=compounded,
+ active=active,
+ )
+ elif isinstance(returns, _pd.DataFrame):
+ # Generate heatmap for each strategy column
+ for col in returns.columns:
+ _get_plots().monthly_heatmap(
+ returns[col].dropna(),
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ returns_label=col,
+ compounded=compounded,
+ active=active,
+ )
return
- _plots.returns(returns, benchmark, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.6),
- show=True, ylabel=False)
-
- _plots.log_returns(returns, benchmark, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
-
+ # prepare timeseries
if benchmark is not None:
- _plots.returns(returns, benchmark, match_volatility=True,
- grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
-
- _plots.yearly_returns(returns, benchmark,
- grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
-
- _plots.histogram(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
+ benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index)
+ benchmark.name = benchmark_colname
+ if match_dates is True:
+ returns, benchmark = _match_dates(returns, benchmark)
+
+ # Generate comprehensive plot suite
+ # Cumulative returns plot
+ _get_plots().returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.6),
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ compound=compounded,
+ )
- _plots.daily_returns(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.3),
- show=True, ylabel=False)
+ # Log returns plot for better visualization
+ _get_plots().log_returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ compound=compounded,
+ )
+ # Volatility-matched returns (if benchmark exists)
if benchmark is not None:
- _plots.rolling_beta(returns, benchmark, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.3),
- show=True, ylabel=False)
-
- _plots.rolling_volatility(
- returns, benchmark, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.3), show=True, ylabel=False)
-
- _plots.rolling_sharpe(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.3),
- show=True, ylabel=False)
-
- _plots.rolling_sortino(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.3),
- show=True, ylabel=False)
+ _get_plots().returns(
+ returns,
+ benchmark,
+ match_volatility=True,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ compound=compounded,
+ )
+
+ # Yearly returns comparison
+ _get_plots().yearly_returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ compounded=compounded,
+ )
- _plots.drawdowns_periods(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
+ # Returns distribution histogram
+ _get_plots().histogram(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ compounded=compounded,
+ )
- _plots.drawdown(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.4),
- show=True, ylabel=False)
+ # Calculate figure size for smaller plots
+ small_fig_size = (figsize[0], figsize[0] * 0.35)
+ if isinstance(returns, _pd.DataFrame) and len(returns.columns) > 1:
+ small_fig_size = (
+ figsize[0],
+ figsize[0] * (0.33 * (len(returns.columns) * 0.66)),
+ )
+
+ # Daily returns scatter plot
+ _get_plots().daily_returns(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=small_fig_size,
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ active=active,
+ )
- _plots.monthly_heatmap(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
+ # Rolling beta analysis (if benchmark exists)
+ if benchmark is not None:
+ _get_plots().rolling_beta(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ window1=win_half_year,
+ window2=win_year,
+ figsize=small_fig_size,
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ )
+
+ # Rolling volatility analysis
+ _get_plots().rolling_volatility(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=small_fig_size,
+ show=True,
+ ylabel="",
+ period=win_half_year,
+ )
- _plots.distribution(returns, grayscale=grayscale,
- figsize=(figsize[0], figsize[0]*.5),
- show=True, ylabel=False)
+ # Rolling Sharpe ratio analysis
+ _get_plots().rolling_sharpe(
+ returns,
+ grayscale=grayscale,
+ figsize=small_fig_size,
+ show=True,
+ ylabel="",
+ period=win_half_year,
+ )
+ # Rolling Sortino ratio analysis
+ _get_plots().rolling_sortino(
+ returns,
+ grayscale=grayscale,
+ figsize=small_fig_size,
+ show=True,
+ ylabel="",
+ period=win_half_year,
+ )
-def _calc_dd(df, display=True):
- dd = _stats.to_drawdown_series(df)
- dd_info = _stats.drawdown_details(dd)
+ # Drawdown periods analysis
+ if isinstance(returns, _pd.Series):
+ _get_plots().drawdowns_periods(
+ returns,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ prepare_returns=False,
+ compounded=compounded,
+ )
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ for col in returns.columns:
+ _get_plots().drawdowns_periods(
+ returns[col],
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ title=col,
+ prepare_returns=False,
+ compounded=compounded,
+ )
+
+ # Underwater (drawdown) plot
+ _get_plots().drawdown(
+ returns,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.4),
+ show=True,
+ ylabel="",
+ compound=compounded,
+ )
+ # Monthly returns heatmap
+ if isinstance(returns, _pd.Series):
+ _get_plots().monthly_heatmap(
+ returns,
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ returns_label=returns.name,
+ show=True,
+ ylabel="",
+ compounded=compounded,
+ active=active,
+ )
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ for col in returns.columns:
+ _get_plots().monthly_heatmap(
+ returns[col],
+ benchmark,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ ylabel="",
+ returns_label=col,
+ compounded=compounded,
+ active=active,
+ )
+
+ # Returns distribution analysis
+ if isinstance(returns, _pd.Series):
+ _get_plots().distribution(
+ returns,
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ title=returns.name,
+ ylabel="",
+ prepare_returns=False,
+ compounded=compounded,
+ )
+ elif isinstance(returns, _pd.DataFrame):
+ # Handle multiple strategy columns
+ for col in returns.columns:
+ _get_plots().distribution(
+ returns[col],
+ grayscale=grayscale,
+ figsize=(figsize[0], figsize[0] * 0.5),
+ show=True,
+ title=col,
+ ylabel="",
+ prepare_returns=False,
+ compounded=compounded,
+ )
+
+
+def _calc_dd(df, display=True, as_pct=False):
+ """
+ Calculate drawdown statistics for performance analysis.
+
+ This helper function computes comprehensive drawdown statistics including
+ maximum drawdown, drawdown dates, recovery periods, and average drawdown
+ metrics. It handles both single strategy and multiple strategy analysis.
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ DataFrame containing returns data with columns for strategies
+ and optionally benchmark
+ display : bool, default True
+ Whether the output is for display purposes (affects formatting)
+ as_pct : bool, default False
+ Whether to return percentages instead of decimals
+
+ Returns
+ -------
+ pd.DataFrame
+ DataFrame with drawdown statistics including:
+ - Max Drawdown %: Maximum drawdown percentage
+ - Max DD Date: Date of maximum drawdown
+ - Max DD Period Start: Start date of worst drawdown period
+ - Max DD Period End: End date of worst drawdown period
+ - Longest DD Days: Duration of longest drawdown in days
+ - Avg. Drawdown %: Average drawdown percentage
+ - Avg. Drawdown Days: Average drawdown duration in days
+
+ Examples
+ --------
+ >>> dd_stats = _calc_dd(returns_df, display=False)
+ >>> dd_stats = _calc_dd(returns_df, as_pct=True)
+ """
+ # Convert returns to drawdown series
+ dd = _get_stats().to_drawdown_series(df)
+ dd_info = _get_stats().drawdown_details(dd)
+
+ # Return empty DataFrame if no drawdowns found
if dd_info.empty:
return _pd.DataFrame()
+ # Handle different column structures based on data type
if "returns" in dd_info:
- ret_dd = dd_info['returns']
+ ret_dd = dd_info["returns"]
+ # to match multiple columns like returns_1, returns_2, ...
+ elif (
+ any(dd_info.columns.get_level_values(0).str.contains("returns"))
+ and dd_info.columns.get_level_values(0).nunique() > 1
+ ):
+ ret_dd = dd_info.loc[
+ :, dd_info.columns.get_level_values(0).str.contains("returns")
+ ]
else:
ret_dd = dd_info
- # pct multiplier
- pct = 1 if display else 100
-
- dd_stats = {
- 'returns': {
- 'Max Drawdown %': ret_dd.sort_values(
- by='max drawdown', ascending=True
- )['max drawdown'].values[0] / pct,
- 'Longest DD Days': str(round(ret_dd.sort_values(
- by='days', ascending=False)['days'].values[0])),
- 'Avg. Drawdown %': ret_dd['max drawdown'].mean() / pct,
- 'Avg. Drawdown Days': str(round(ret_dd['days'].mean()))
+ # Calculate drawdown statistics based on data structure
+ if (
+ any(ret_dd.columns.get_level_values(0).str.contains("returns"))
+ and ret_dd.columns.get_level_values(0).nunique() > 1
+ ):
+ # Multiple strategy columns case
+ dd_stats = {
+ col: {
+ "Max Drawdown %": ret_dd[col]
+ .sort_values(by="max drawdown", ascending=True)["max drawdown"]
+ .values[0]
+ / 100,
+ "Max DD Date": ret_dd[col]
+ .sort_values(by="max drawdown", ascending=True)["valley"]
+ .values[0],
+ "Max DD Period Start": ret_dd[col]
+ .sort_values(by="max drawdown", ascending=True)["start"]
+ .values[0],
+ "Max DD Period End": ret_dd[col]
+ .sort_values(by="max drawdown", ascending=True)["end"]
+ .values[0],
+ "Longest DD Days": str(
+ _np.round(
+ ret_dd[col]
+ .sort_values(by="days", ascending=False)["days"]
+ .values[0]
+ )
+ ),
+ "Avg. Drawdown %": ret_dd[col]["max drawdown"].mean() / 100,
+ "Avg. Drawdown Days": str(_np.round(ret_dd[col]["days"].mean())),
+ }
+ for col in ret_dd.columns.get_level_values(0)
}
- }
+ else:
+ # Single strategy case
+ max_dd = ret_dd.sort_values(by="max drawdown", ascending=True)
+ dd_stats = {
+ "returns": {
+ "Max Drawdown %": max_dd["max drawdown"].values[0] / 100,
+ "Max DD Date": max_dd["valley"].values[0],
+ "Max DD Period Start": max_dd["start"].values[0],
+ "Max DD Period End": max_dd["end"].values[0],
+ "Longest DD Days": str(
+ _np.round(
+ ret_dd.sort_values(by="days", ascending=False)["days"].values[0]
+ )
+ ),
+ "Avg. Drawdown %": ret_dd["max drawdown"].mean() / 100,
+ "Avg. Drawdown Days": str(_np.round(ret_dd["days"].mean())),
+ }
+ }
+
+ # Add benchmark drawdown statistics if present
if "benchmark" in df and (dd_info.columns, _pd.MultiIndex):
- bench_dd = dd_info['benchmark'].sort_values(by='max drawdown')
- dd_stats['benchmark'] = {
- 'Max Drawdown %': bench_dd.sort_values(
- by='max drawdown', ascending=True
- )['max drawdown'].values[0] / pct,
- 'Longest DD Days': str(round(bench_dd.sort_values(
- by='days', ascending=False)['days'].values[0])),
- 'Avg. Drawdown %': bench_dd['max drawdown'].mean() / pct,
- 'Avg. Drawdown Days': str(round(bench_dd['days'].mean()))
+ bench_dd = dd_info["benchmark"].sort_values(by="max drawdown")
+ dd_stats["benchmark"] = {
+ "Max Drawdown %": bench_dd.sort_values(by="max drawdown", ascending=True)[
+ "max drawdown"
+ ].values[0]
+ / 100,
+ "Max DD Date": bench_dd.sort_values(
+ by="max drawdown", ascending=True
+ )["valley"].values[0],
+ "Max DD Period Start": bench_dd.sort_values(
+ by="max drawdown", ascending=True
+ )["start"].values[0],
+ "Max DD Period End": bench_dd.sort_values(
+ by="max drawdown", ascending=True
+ )["end"].values[0],
+ "Longest DD Days": str(
+ _np.round(
+ bench_dd.sort_values(by="days", ascending=False)["days"].values[0]
+ )
+ ),
+ "Avg. Drawdown %": bench_dd["max drawdown"].mean() / 100,
+ "Avg. Drawdown Days": str(_np.round(bench_dd["days"].mean())),
}
+ # Apply percentage multiplier based on display settings
+ # pct multiplier
+ pct = 100 if display or as_pct else 1
+
+ # Convert to DataFrame and apply percentage formatting
dd_stats = _pd.DataFrame(dd_stats).T
- dd_stats['Max Drawdown %'] = dd_stats['Max Drawdown %'].astype(float)
- dd_stats['Avg. Drawdown %'] = dd_stats['Avg. Drawdown %'].astype(float)
+ dd_stats["Max Drawdown %"] = dd_stats["Max Drawdown %"].astype(float) * pct
+ dd_stats["Avg. Drawdown %"] = dd_stats["Avg. Drawdown %"].astype(float) * pct
+
return dd_stats.T
def _html_table(obj, showindex="default"):
- obj = _tabulate(obj, headers="keys", tablefmt='html',
- floatfmt=".2f", showindex=showindex)
- obj = obj.replace(' style="text-align: right;"', '')
- obj = obj.replace(' style="text-align: left;"', '')
- obj = obj.replace(' style="text-align: center;"', '')
- obj = _regex.sub('
+', ' | ', obj)
- obj = _regex.sub(' + | ', '', obj)
- obj = _regex.sub('
+', ' | ', obj)
- obj = _regex.sub(' + | ', '', obj)
+ """
+ Convert DataFrame to HTML table format for report generation.
+
+ This helper function converts pandas DataFrames to clean HTML table format
+ suitable for embedding in HTML reports. It removes default tabulate styling
+ and cleans up spacing for better presentation.
+
+ Parameters
+ ----------
+ obj : pd.DataFrame
+ DataFrame to convert to HTML table
+ showindex : str or bool, default "default"
+ Whether to show the DataFrame index in the HTML table.
+ "default" uses tabulate's default behavior
+
+ Returns
+ -------
+ str
+ HTML string containing the formatted table
+
+ Examples
+ --------
+ >>> html_str = _html_table(metrics_df)
+ >>> html_str = _html_table(metrics_df, showindex=False)
+ """
+ # Convert DataFrame to HTML table using tabulate
+ obj = _tabulate(
+ obj, headers="keys", tablefmt="html", floatfmt=".2f", showindex=showindex
+ )
+
+ # Remove default tabulate styling attributes
+ obj = obj.replace(' style="text-align: right;"', "")
+ obj = obj.replace(' style="text-align: left;"', "")
+ obj = obj.replace(' style="text-align: center;"', "")
+
+ # Clean up spacing in table cells
+ obj = _regex.sub("
+", " | ", obj)
+ obj = _regex.sub(" + | ", "", obj)
+ obj = _regex.sub("
+", " | ", obj)
+ obj = _regex.sub(" + | ", "", obj)
+
return obj
def _download_html(html, filename="quantstats-tearsheet.html"):
- jscode = _regex.sub(' +', ' ', """""".replace('\n', ''))
- jscode = jscode.replace('{{html}}', _regex.sub(
- ' +', ' ', html.replace('\n', '')))
- if _utils._in_notebook():
- iDisplay(iHTML(jscode.replace('{{filename}}', filename)))
+ a.click();""".replace(
+ "\n", ""
+ ),
+ )
+
+ # Insert HTML content and clean up formatting
+ jscode = jscode.replace("{{html}}", _regex.sub(" +", " ", html.replace("\n", "")))
+
+ # Execute JavaScript in notebook if in notebook environment
+ if _get_utils()._in_notebook():
+ iDisplay(iHTML(jscode.replace("{{filename}}", filename)))
def _open_html(html):
- jscode = _regex.sub(' +', ' ', """""".replace('\n', ''))
- jscode = jscode.replace('{{html}}', _regex.sub(
- ' +', ' ', html.replace('\n', '')))
- if _utils._in_notebook():
+ """.replace(
+ "\n", ""
+ ),
+ )
+
+ # Insert HTML content and clean up formatting
+ jscode = jscode.replace("{{html}}", _regex.sub(" +", " ", html.replace("\n", "")))
+
+ # Execute JavaScript in notebook if in notebook environment
+ if _get_utils()._in_notebook():
iDisplay(iHTML(jscode))
+
+
+def _embed_figure(figfiles, figfmt):
+ """
+ Embed matplotlib figures in HTML format for reports.
+
+ This helper function converts matplotlib figure objects to embedded
+ HTML format suitable for inclusion in HTML reports. It handles both
+ SVG and base64-encoded image formats.
+
+ Parameters
+ ----------
+ figfiles : io.StringIO or list of io.StringIO
+ File-like objects containing figure data. Can be single figure
+ or list of figures for multiple plots
+ figfmt : str
+ Format for the figures ('svg', 'png', 'jpg', etc.)
+
+ Returns
+ -------
+ str
+ HTML string with embedded figure(s) ready for inclusion in report
+
+ Examples
+ --------
+ >>> embed_str = _embed_figure(figfile, 'svg')
+ >>> embed_str = _embed_figure([fig1, fig2], 'png')
+ """
+ # Handle multiple figures
+ if isinstance(figfiles, list):
+ embed_string = "\n"
+ for figfile in figfiles:
+ figbytes = figfile.getvalue()
+ if figfmt == "svg":
+ # SVG can be embedded directly as text
+ return figbytes.decode()
+ # For other formats, encode as base64 data URI
+ data_uri = _b64encode(figbytes).decode()
+ embed_string.join(
+ '

'.format(figfmt, data_uri)
+ )
+ else:
+ # Handle single figure
+ figbytes = figfiles.getvalue()
+ if figfmt == "svg":
+ # SVG can be embedded directly as text
+ return figbytes.decode()
+ # For other formats, encode as base64 data URI
+ data_uri = _b64encode(figbytes).decode()
+ embed_string = '

'.format(figfmt, data_uri)
+
+ return embed_string
diff --git a/quantstats/stats.py b/quantstats/stats.py
index 2c348760..5a0c6e36 100644
--- a/quantstats/stats.py
+++ b/quantstats/stats.py
@@ -1,10 +1,9 @@
#!/usr/bin/env python
-# -*- coding: UTF-8 -*-
#
# QuantStats: Portfolio analytics for quants
# https://github.com/ranaroussi/quantstats
#
-# Copyright 2019 Ran Aroussi
+# Copyright 2019-2025 Ran Aroussi
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,699 +17,3295 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+"""
+Portfolio Statistics Module
+
+This module provides comprehensive statistical analysis functions for portfolio
+performance evaluation, risk assessment, and benchmarking. It includes functions
+for calculating various return metrics, risk ratios, drawdown analysis, and
+comparison with benchmarks.
+
+The module is designed to work with pandas Series and DataFrames containing
+return data, price data, or performance metrics.
+"""
+
+from warnings import warn
+from typing import Literal
import pandas as _pd
import numpy as _np
-from math import ceil as _ceil
-from scipy.stats import (
- norm as _norm, linregress as _linregress
-)
+from numpy.typing import NDArray
+from math import ceil as _ceil, sqrt as _sqrt
+from scipy.stats import norm as _norm, linregress as _linregress
from . import utils as _utils
+from ._compat import safe_concat
+from .utils import validate_input
+# Type aliases for common types (Python 3.10+ syntax)
+Returns = _pd.Series | _pd.DataFrame
+"""Type alias for returns data: can be a pandas Series or DataFrame."""
# ======== STATS ========
-def pct_rank(prices, window=60):
- """ rank prices by window """
+
+def pct_rank(prices: _pd.Series, window: int = 60) -> _pd.Series:
+ """
+ Calculate the percentile rank of prices over a rolling window.
+
+ This function computes the percentile rank (0-100) of each price point
+ within a rolling window, useful for identifying relative position of
+ current prices compared to recent history.
+
+ Args:
+ prices (pd.Series): Series of price data
+ window (int): Rolling window size for rank calculation (default: 60)
+
+ Returns:
+ pd.Series: Percentile ranks (0-100 scale)
+
+ Example:
+ >>> prices = pd.Series([100, 105, 110, 95, 120])
+ >>> ranks = pct_rank(prices, window=3)
+ >>> print(ranks)
+ """
+ # Create rolling window shifts and transpose for ranking
rank = _utils.multi_shift(prices, window).T.rank(pct=True).T
- return rank.iloc[:, 0] * 100.
+ # Extract first column and convert to percentage scale
+ return rank.iloc[:, 0] * 100.0
-def compsum(returns):
- """ Calculates rolling compounded returns """
- return returns.add(1).cumprod() - 1
+def compsum(returns: Returns) -> Returns:
+ """
+ Calculate rolling compounded returns (cumulative product).
+ This function computes the cumulative compounded returns by adding 1
+ to each return, taking the cumulative product, and subtracting 1.
-def comp(returns):
- """ Calculates total compounded returns """
- return returns.add(1).prod() - 1
+ Args:
+ returns: Series or DataFrame of returns
+ Returns:
+ Cumulative compounded returns (same type as input)
-def expected_return(returns, aggregate=None, compounded=True):
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03])
+ >>> cumulative = compsum(returns)
+ >>> print(cumulative)
"""
- returns the expected return for a given period
- by calculating the geometric holding period return
+ # Add 1 to convert returns to growth factors, then cumulative product
+ return returns.add(1).cumprod(axis=0) - 1
+
+
+def comp(returns: Returns) -> _pd.Series | float:
"""
- returns = _utils._prepare_returns(returns)
+ Calculate total compounded returns (final cumulative return).
+
+ This function computes the total compounded return over the entire period
+ by converting returns to growth factors and taking their product.
+
+ Args:
+ returns (pd.Series): Series of returns
+
+ Returns:
+ float: Total compounded return
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03])
+ >>> total_return = comp(returns)
+ >>> print(total_return)
+ """
+ # Convert returns to growth factors, take product, subtract 1
+ return returns.add(1).prod(axis=0) - 1
+
+
+def distribution(
+ returns: Returns,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> dict:
+ """
+ Analyze return distributions across different time periods.
+
+ This function calculates return distributions (including outliers) for
+ daily, weekly, monthly, quarterly, and yearly periods. It identifies
+ outliers using the IQR method (1.5 * IQR beyond Q1/Q3).
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ dict: Dictionary containing distribution data for each period
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, -0.01],
+ ... index=pd.date_range('2023-01-01', periods=3))
+ >>> dist = distribution(returns)
+ >>> print(dist['Daily']['values'])
+ """
+ def get_outliers(data):
+ """
+ Identify outliers using the IQR method.
+
+ Uses 1.5 * IQR rule: values beyond Q1 - 1.5*IQR or Q3 + 1.5*IQR
+ are considered outliers.
+ """
+ # https://datascience.stackexchange.com/a/57199
+ Q1 = data.quantile(0.25) # First quartile
+ Q3 = data.quantile(0.75) # Third quartile
+ IQR = Q3 - Q1 # Interquartile range
+
+ # Create filter for non-outlier values
+ filtered = (data >= Q1 - 1.5 * IQR) & (data <= Q3 + 1.5 * IQR)
+
+ return {
+ "values": data.loc[filtered].tolist(),
+ "outliers": data.loc[~filtered].tolist(),
+ }
+
+ # Handle DataFrame input by selecting appropriate column
+ if isinstance(returns, _pd.DataFrame):
+ warn(
+ "Pandas DataFrame was passed (Series expected). "
+ "Only first column will be used."
+ )
+ returns = returns.copy()
+ returns.columns = map(str.lower, returns.columns)
+ if len(returns.columns) > 1 and "close" in returns.columns:
+ returns = returns["close"]
+ else:
+ returns = returns[returns.columns[0]]
+
+ # Choose aggregation function based on compounded parameter
+ apply_fnc = comp if compounded else _np.sum
+ daily = returns.dropna()
+
+ # Prepare returns if requested
+ if prepare_returns:
+ daily = _utils._prepare_returns(daily)
+
+ # Calculate distributions for different time periods
+ return {
+ "Daily": get_outliers(daily),
+ "Weekly": get_outliers(daily.resample("W-MON").apply(apply_fnc)),
+ "Monthly": get_outliers(daily.resample("ME").apply(apply_fnc)),
+ "Quarterly": get_outliers(daily.resample("QE").apply(apply_fnc)),
+ "Yearly": get_outliers(daily.resample("YE").apply(apply_fnc)),
+ }
+
+
+def expected_return(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float:
+ """
+ Calculate the expected return (geometric mean) for a given period.
+
+ This function computes the geometric holding period return, which represents
+ the expected return per period based on historical data. It's calculated
+ as the nth root of the product of (1 + returns) minus 1.
+
+ Args:
+ returns (pd.Series): Return series
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Expected return per period
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03])
+ >>> expected = expected_return(returns)
+ >>> print(f"Expected return: {expected:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns if period specified
returns = _utils.aggregate_returns(returns, aggregate, compounded)
- return _np.product(1 + returns) ** (1 / len(returns)) - 1
+ # Calculate geometric mean: (product of (1 + returns))^(1/n) - 1
+ return _np.prod(1 + returns, axis=0) ** (1 / len(returns)) - 1
+
+
+def geometric_mean(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+) -> float:
+ """
+ Calculate geometric mean of returns.
+
+ This is a shorthand function for expected_return() with the same parameters.
+
+ Args:
+ returns (pd.Series): Return series
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+
+ Returns:
+ float: Geometric mean of returns
+ """
+ return expected_return(returns, aggregate, compounded)
+
+
+def ghpr(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+) -> float:
+ """
+ Calculate Geometric Holding Period Return.
+
+ This is a shorthand function for expected_return() with the same parameters.
+ GHPR represents the average rate of return per period.
+
+ Args:
+ returns (pd.Series): Return series
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+
+ Returns:
+ float: Geometric holding period return
+ """
+ return expected_return(returns, aggregate, compounded)
+
+
+def outliers(returns: Returns, quantile: float = 0.95) -> Returns:
+ """
+ Identify and return outlier returns above a specified quantile.
+
+ This function filters returns to show only those above the specified
+ quantile threshold, helping identify extreme positive performance periods.
-def geometric_mean(retruns, aggregate=None, compounded=True):
- """ shorthand for expected_return() """
- return expected_return(retruns, aggregate, compounded)
+ Args:
+ returns (pd.Series): Return series to analyze
+ quantile (float): Quantile threshold (default: 0.95 for 95th percentile)
+
+ Returns:
+ pd.Series: Returns above the quantile threshold
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, 0.05, -0.01, 0.10])
+ >>> outlier_returns = outliers(returns, quantile=0.90)
+ >>> print(outlier_returns)
+ """
+ # Filter returns above the specified quantile and remove NaN values
+ return returns[returns > returns.quantile(quantile)].dropna(how="all")
-def ghpr(retruns, aggregate=None, compounded=True):
- """ shorthand for expected_return() """
- return expected_return(retruns, aggregate, compounded)
+def remove_outliers(returns: Returns, quantile: float = 0.95) -> Returns:
+ """
+ Remove outlier returns above a specified quantile.
+ This function filters out extreme returns above the quantile threshold,
+ useful for robust statistical analysis by removing extreme values.
-def outliers(returns, quantile=.95):
- """returns series of outliers """
- return returns[returns > returns.quantile(quantile)].dropna(how='all')
+ Args:
+ returns (pd.Series): Return series to filter
+ quantile (float): Quantile threshold (default: 0.95 for 95th percentile)
+ Returns:
+ pd.Series: Returns below the quantile threshold
-def remove_outliers(returns, quantile=.95):
- """ returns series of returns without the outliers """
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, 0.05, -0.01, 0.10])
+ >>> filtered = remove_outliers(returns, quantile=0.90)
+ >>> print(filtered)
+ """
+ # Keep only returns below the specified quantile threshold
return returns[returns < returns.quantile(quantile)]
-def best(returns, aggregate=None, compounded=True):
- """ returns the best day/month/week/quarter/year's return """
- returns = _utils._prepare_returns(returns)
+def best(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float:
+ """
+ Find the best (highest) return for a given period.
+
+ This function identifies the maximum return over the specified aggregation
+ period, helping identify the best performing period in the dataset.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Best (maximum) return for the period
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03])
+ >>> best_return = best(returns)
+ >>> print(f"Best return: {best_return:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns and find maximum
return _utils.aggregate_returns(returns, aggregate, compounded).max()
-def worst(returns, aggregate=None, compounded=True):
- """ returns the worst day/month/week/quarter/year's return """
- returns = _utils._prepare_returns(returns)
+def worst(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float:
+ """
+ Find the worst (lowest) return for a given period.
+
+ This function identifies the minimum return over the specified aggregation
+ period, helping identify the worst performing period in the dataset.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Worst (minimum) return for the period
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03])
+ >>> worst_return = worst(returns)
+ >>> print(f"Worst return: {worst_return:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns and find minimum
return _utils.aggregate_returns(returns, aggregate, compounded).min()
-def consecutive_wins(returns, aggregate=None, compounded=True):
- """ returns the maximum consecutive wins by day/month/week/quarter/year """
- returns = _utils._prepare_returns(returns)
+def consecutive_wins(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> int:
+ """
+ Calculate the maximum number of consecutive winning periods.
+
+ This function identifies the longest streak of positive returns, which
+ helps assess the consistency of positive performance.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ int: Maximum number of consecutive winning periods
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.02, 0.03, -0.01, 0.02])
+ >>> max_wins = consecutive_wins(returns)
+ >>> print(f"Max consecutive wins: {max_wins}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns and convert to boolean (positive = True)
returns = _utils.aggregate_returns(returns, aggregate, compounded) > 0
- return _utils.count_consecutive(returns).max()
+ # Count consecutive True values and return maximum
+ return _utils._count_consecutive(returns).max()
-def consecutive_losses(returns, aggregate=None, compounded=True):
+
+def consecutive_losses(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> int:
"""
- returns the maximum consecutive losses by
- day/month/week/quarter/year
+ Calculate the maximum number of consecutive losing periods.
+
+ This function identifies the longest streak of negative returns, which
+ helps assess the potential for extended drawdown periods.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ int: Maximum number of consecutive losing periods
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, -0.01, -0.01, 0.02])
+ >>> max_losses = consecutive_losses(returns)
+ >>> print(f"Max consecutive losses: {max_losses}")
"""
- returns = _utils._prepare_returns(returns)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns and convert to boolean (negative = True)
returns = _utils.aggregate_returns(returns, aggregate, compounded) < 0
- return _utils.count_consecutive(returns).max()
+ # Count consecutive True values and return maximum
+ return _utils._count_consecutive(returns).max()
-def exposure(returns):
- """ returns the market exposure time (returns != 0) """
- returns = _utils._prepare_returns(returns)
+
+def exposure(
+ returns: Returns,
+ prepare_returns: bool = True,
+) -> float | _pd.Series:
+ """
+ Calculate market exposure time as percentage of periods with non-zero returns.
+
+ This function measures how often the strategy was actually invested
+ (had non-zero returns) versus being in cash or having zero positions.
+
+ Args:
+ returns (pd.Series or pd.DataFrame): Return series or DataFrame
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float or pd.Series: Exposure percentage (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.00, 0.02, 0.00, 0.03])
+ >>> exp = exposure(returns)
+ >>> print(f"Market exposure: {exp:.2%}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
def _exposure(ret):
+ """
+ Calculate exposure for a single return series.
+
+ Counts non-NaN, non-zero returns and divides by total periods.
+ Rounds up to nearest percent to avoid zero exposure from rounding.
+ """
+ # Count non-NaN and non-zero returns
ex = len(ret[(~_np.isnan(ret)) & (ret != 0)]) / len(ret)
+ # Round up to nearest percent
return _ceil(ex * 100) / 100
+ # Handle DataFrame input by calculating exposure for each column
if isinstance(returns, _pd.DataFrame):
_df = {}
for col in returns.columns:
_df[col] = _exposure(returns[col])
return _pd.Series(_df)
+
return _exposure(returns)
-def win_rate(returns, aggregate=None, compounded=True):
- """ calculates the win ratio for a period """
+def win_rate(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float | _pd.Series:
+ """
+ Calculate the win rate (percentage of profitable periods).
+
+ This function computes the ratio of positive returns to total non-zero
+ returns, providing a measure of how often the strategy generates profits.
+
+ Args:
+ returns (pd.Series or pd.DataFrame): Return series or DataFrame
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float or pd.Series: Win rate as decimal (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> wr = win_rate(returns)
+ >>> print(f"Win rate: {wr:.2%}")
+ """
def _win_rate(series):
- try:
- return len(series[series > 0]) / len(series[series != 0])
- except Exception:
- return 0.
+ """
+ Calculate win rate for a single return series.
- returns = _utils._prepare_returns(returns)
+ Handles edge cases like no non-zero returns and provides
+ error handling for calculation issues.
+ """
+ try:
+ # Filter out zero returns (periods with no trading)
+ non_zero_returns = series[series != 0]
+ if len(non_zero_returns) == 0:
+ warn("No non-zero returns found for win rate calculation, returning 0.0")
+ return 0.0
+
+ # Calculate ratio of positive returns to non-zero returns
+ return len(series[series > 0]) / len(non_zero_returns)
+ except (ValueError, TypeError) as e:
+ warn(f"Error calculating win rate: {e}, returning 0.0")
+ return 0.0
+
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns if period specified
if aggregate:
returns = _utils.aggregate_returns(returns, aggregate, compounded)
+ # Handle DataFrame input by calculating win rate for each column
if isinstance(returns, _pd.DataFrame):
_df = {}
for col in returns.columns:
_df[col] = _win_rate(returns[col])
-
return _pd.Series(_df)
return _win_rate(returns)
-def avg_return(returns, aggregate=None, compounded=True):
+def avg_return(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float:
"""
- calculates the average return/trade return for a period
- returns = _utils._prepare_returns(returns)
+ Calculate the average return per period (excluding zero returns).
+
+ This function computes the mean of non-zero returns, providing insight
+ into the typical magnitude of returns when the strategy is active.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Average return per period
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.00, 0.02, -0.01, 0.03])
+ >>> avg_ret = avg_return(returns)
+ >>> print(f"Average return: {avg_ret:.4f}")
"""
- returns = _utils._prepare_returns(returns)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns if period specified
if aggregate:
returns = _utils.aggregate_returns(returns, aggregate, compounded)
+
+ # Calculate mean of non-zero returns
return returns[returns != 0].dropna().mean()
-def avg_win(returns, aggregate=None, compounded=True):
+def avg_win(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float:
"""
- calculates the average winning
- return/trade return for a period
+ Calculate the average winning return (mean of positive returns).
+
+ This function computes the mean of positive returns only, showing
+ the typical magnitude of profitable periods.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Average winning return
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> avg_win_ret = avg_win(returns)
+ >>> print(f"Average win: {avg_win_ret:.4f}")
"""
- returns = _utils._prepare_returns(returns)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns if period specified
if aggregate:
returns = _utils.aggregate_returns(returns, aggregate, compounded)
+
+ # Calculate mean of positive returns only
return returns[returns > 0].dropna().mean()
-def avg_loss(returns, aggregate=None, compounded=True):
+def avg_loss(
+ returns: Returns,
+ aggregate: str | None = None,
+ compounded: bool = True,
+ prepare_returns: bool = True,
+) -> float:
"""
- calculates the average low if
- return/trade return for a period
+ Calculate the average losing return (mean of negative returns).
+
+ This function computes the mean of negative returns only, showing
+ the typical magnitude of losing periods.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Average losing return (negative value)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> avg_loss_ret = avg_loss(returns)
+ >>> print(f"Average loss: {avg_loss_ret:.4f}")
"""
- returns = _utils._prepare_returns(returns)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Aggregate returns if period specified
if aggregate:
returns = _utils.aggregate_returns(returns, aggregate, compounded)
+
+ # Calculate mean of negative returns only
return returns[returns < 0].dropna().mean()
-def volatility(returns, periods=252, annualize=True):
- """ calculates the volatility of returns for a period """
- std = _utils._prepare_returns(returns).std()
+def volatility(
+ returns: Returns,
+ periods: int = 252,
+ annualize: bool = True,
+ prepare_returns: bool = True,
+) -> float | _pd.Series:
+ """
+ Calculate volatility (standard deviation) of returns.
+
+ This function computes the volatility of returns, which measures the
+ degree of variation in returns over time. Higher volatility indicates
+ more uncertainty and risk.
+
+ Args:
+ returns: Return series or DataFrame to analyze
+ periods: Number of periods per year for annualization (default: 252)
+ annualize: Whether to annualize the volatility (default: True)
+ prepare_returns: Whether to prepare returns first (default: True)
+
+ Returns:
+ Volatility (annualized if annualize=True)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> vol = volatility(returns)
+ >>> print(f"Annualized volatility: {vol:.4f}")
+ """
+ validate_input(returns)
+
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate standard deviation of returns
+ std = returns.std()
+
+ # Annualize by multiplying by square root of periods per year
if annualize:
return std * _np.sqrt(periods)
return std
-def implied_volatility(returns, periods=252, annualize=True):
- """ calculates the implied volatility of returns for a period """
+def rolling_volatility(
+ returns: Returns,
+ rolling_period: int = 126,
+ periods_per_year: int = 252,
+ prepare_returns: bool = True,
+) -> _pd.Series:
+ """
+ Calculate rolling volatility over a specified window.
+
+ This function computes volatility using a rolling window, providing
+ a time-varying measure of risk that adapts to changing market conditions.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rolling_period (int): Rolling window size (default: 126, ~6 months)
+ periods_per_year (int): Periods per year for annualization (default: 252)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ pd.Series: Rolling volatility series (annualized)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> rolling_vol = rolling_volatility(returns, rolling_period=3)
+ >>> print(rolling_vol)
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns, rolling_period)
+
+ # Calculate rolling standard deviation and annualize
+ return returns.rolling(rolling_period).std() * _np.sqrt(periods_per_year)
+
+
+def implied_volatility(
+ returns: Returns,
+ periods: int = 252,
+ annualize: bool = True,
+) -> float | _pd.Series:
+ """
+ Calculate implied volatility using log returns.
+
+ This function computes volatility using log returns instead of simple
+ returns, which is mathematically more appropriate for continuous compounding.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ periods (int): Number of periods for rolling calculation (default: 252)
+ annualize (bool): Whether to annualize the volatility (default: True)
+
+ Returns:
+ float or pd.Series: Implied volatility
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> impl_vol = implied_volatility(returns)
+ >>> print(f"Implied volatility: {impl_vol:.4f}")
+ """
+ # Convert to log returns for continuous compounding
logret = _utils.log_returns(returns)
+
if annualize:
+ # Calculate rolling volatility and annualize
return logret.rolling(periods).std() * _np.sqrt(periods)
- return logret.std()
+ # Return simple standard deviation
+ return logret.std()
-# ======= METRICS =======
-def sharpe(returns, rf=0., periods=252, annualize=True):
+def autocorr_penalty(
+ returns: Returns,
+ prepare_returns: bool = False,
+) -> float:
"""
- calculates the sharpe ratio of access returns
+ Calculate autocorrelation penalty for risk-adjusted metrics.
- If rf is non-zero, you must specify periods.
- In this case, rf is assumed to be expressed in yearly (annualized) terms
+ This function computes a penalty factor that accounts for autocorrelation
+ in returns, which can inflate risk-adjusted ratios. Used to adjust
+ Sharpe and Sortino ratios for more realistic risk assessment.
Args:
- * returns (Series, DataFrame): Input return series
- * rf (float): Risk-free rate expressed as a yearly (annualized) return
- * periods (int): Frequency of returns (252 for daily, 12 for monthly)
- * annualize: return annualize sharpe?
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: False)
+
+ Returns:
+ float: Autocorrelation penalty factor (>= 1)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> penalty = autocorr_penalty(returns)
+ >>> print(f"Autocorrelation penalty: {penalty:.4f}")
"""
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
- if rf != 0 and periods is None:
- raise Exception('Must provide periods if rf != 0')
+ # Handle DataFrame input by selecting first column
+ if isinstance(returns, _pd.DataFrame):
+ returns = returns[returns.columns[0]]
- returns = _utils._prepare_returns(returns, rf, periods)
- res = returns.mean() / returns.std()
+ # returns.to_csv('/Users/ran/Desktop/test.csv')
+ num = len(returns)
- if annualize:
- return res * _np.sqrt(1 if periods is None else periods)
+ # Calculate autocorrelation coefficient between consecutive returns
+ coef = _np.abs(_np.corrcoef(returns[:-1], returns[1:])[0, 1])
- return res
+ # Vectorized calculation instead of list comprehension
+ x = _np.arange(1, num)
+ # Calculate weighted correlation effects over time
+ corr = ((num - x) / num) * (coef**x)
+ # Return penalty factor (square root of 1 + 2 * sum of correlations)
+ return _np.sqrt(1 + 2 * _np.sum(corr))
-def sortino(returns, rf=0, periods=252, annualize=True):
+
+# ======= METRICS =======
+
+
+def sharpe(
+ returns: Returns,
+ rf: float = 0.0,
+ periods: int = 252,
+ annualize: bool = True,
+ smart: bool = False,
+) -> float | _pd.Series:
"""
- calculates the sortino ratio of access returns
+ Calculate the Sharpe ratio of excess returns.
- If rf is non-zero, you must specify periods.
- In this case, rf is assumed to be expressed in yearly (annualized) terms
+ The Sharpe ratio measures risk-adjusted returns by dividing excess returns
+ (returns - risk-free rate) by the standard deviation of returns.
+ Higher values indicate better risk-adjusted performance.
- Calculation is based on this paper by Red Rock Capital
- http://www.redrockcapital.com/Sortino__A__Sharper__Ratio_Red_Rock_Capital.pdf
+ Args:
+ returns: Return series or DataFrame to analyze
+ rf: Risk-free rate (annualized if periods specified, default: 0.0)
+ periods: Periods per year for annualization (default: 252)
+ annualize: Whether to annualize the ratio (default: True)
+ smart: Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ Sharpe ratio (float for Series input, Series for DataFrame input)
+
+ Raises:
+ ValueError: If rf is non-zero but periods is None
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> sharpe_ratio = sharpe(returns, rf=0.02)
+ >>> print(f"Sharpe ratio: {sharpe_ratio:.4f}")
"""
+ validate_input(returns)
+ # Validate parameters for risk-free rate handling
if rf != 0 and periods is None:
- raise Exception('Must provide periods if rf != 0')
+ raise ValueError("periods parameter is required when risk-free rate (rf) is non-zero. "
+ "This is needed to properly annualize the risk-free rate.")
+ # Prepare returns (subtract risk-free rate if applicable)
returns = _utils._prepare_returns(returns, rf, periods)
- downside = (returns[returns < 0] ** 2).sum() / len(returns)
- res = returns.mean() / _np.sqrt(downside)
+ # Calculate standard deviation as denominator
+ divisor = returns.std(ddof=1)
+
+ # Apply autocorrelation penalty if smart mode enabled
+ if smart:
+ # penalize sharpe with auto correlation
+ divisor = divisor * autocorr_penalty(returns)
+ # Calculate base Sharpe ratio
+ res = returns.mean() / divisor
+
+ # Annualize if requested
if annualize:
return res * _np.sqrt(1 if periods is None else periods)
return res
-def cagr(returns, rf=0., compounded=True):
+def smart_sharpe(
+ returns: Returns,
+ rf: float = 0.0,
+ periods: int = 252,
+ annualize: bool = True,
+) -> float | _pd.Series:
"""
- calculates the communicative annualized growth return
- (CAGR%) of access returns
+ Calculate the Smart Sharpe ratio (Sharpe with autocorrelation penalty).
+
+ This is a wrapper for the sharpe() function with smart=True, which
+ applies an autocorrelation penalty to provide more realistic risk-adjusted
+ returns for strategies with autocorrelated returns.
- If rf is non-zero, you must specify periods.
- In this case, rf is assumed to be expressed in yearly (annualized) terms
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the ratio (default: True)
+
+ Returns:
+ float: Smart Sharpe ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> smart_sharpe_ratio = smart_sharpe(returns)
+ >>> print(f"Smart Sharpe ratio: {smart_sharpe_ratio:.4f}")
"""
+ return sharpe(returns, rf, periods, annualize, True)
- total = _utils._prepare_returns(returns, rf)
- if compounded:
- total = comp(total)
- else:
- total = _np.sum(total)
- years = (returns.index[-1] - returns.index[0]).days / 365.
+def rolling_sharpe(
+ returns: Returns,
+ rf: float = 0.0,
+ rolling_period: int = 126,
+ annualize: bool = True,
+ periods_per_year: int = 252,
+ prepare_returns: bool = True,
+) -> _pd.Series:
+ """
+ Calculate rolling Sharpe ratio over a specified window.
- res = abs(total + 1.0) ** (1.0 / years) - 1
+ This function computes the Sharpe ratio using a rolling window, providing
+ a time-varying measure of risk-adjusted performance that adapts to
+ changing market conditions.
- if isinstance(returns, _pd.DataFrame):
- res = _pd.Series(res)
- res.index = returns.columns
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ rolling_period (int): Rolling window size (default: 126, ~6 months)
+ annualize (bool): Whether to annualize the ratio (default: True)
+ periods_per_year (int): Periods per year for annualization (default: 252)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ pd.Series: Rolling Sharpe ratio series
+
+ Raises:
+ Exception: If rf != 0 and rolling_period is None
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> rolling_sharpe_ratio = rolling_sharpe(returns, rolling_period=3)
+ >>> print(rolling_sharpe_ratio)
+ """
+ # Validate parameters for risk-free rate handling
+ if rf != 0 and rolling_period is None:
+ raise Exception("Must provide periods if rf != 0")
- return res
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns, rf, rolling_period)
+ # Calculate rolling mean and standard deviation
+ res = returns.rolling(rolling_period).mean() / returns.rolling(rolling_period).std()
-def rar(returns, rf=0.):
- """
- calculates the risk-adjusted return of access returns
- (CAGR / exposure. takes time into account.)
+ # Annualize if requested
+ if annualize:
+ res = res * _np.sqrt(1 if periods_per_year is None else periods_per_year)
- If rf is non-zero, you must specify periods.
- In this case, rf is assumed to be expressed in yearly (annualized) terms
- """
- returns = _utils._prepare_returns(returns, rf)
- return cagr(returns) / exposure(returns)
+ return res
-def skew(returns):
- """
- calculates returns' skewness
- (the degree of asymmetry of a distribution around its mean)
+def sortino(
+ returns: Returns,
+ rf: float = 0,
+ periods: int = 252,
+ annualize: bool = True,
+ smart: bool = False,
+) -> float | _pd.Series:
"""
- return _utils._prepare_returns(returns).skew()
+ Calculate the Sortino ratio of excess returns.
+ The Sortino ratio is similar to the Sharpe ratio but uses downside deviation
+ instead of total volatility, focusing only on harmful volatility.
+ This provides a more accurate measure of risk-adjusted returns.
-def kurtosis(returns):
- """
- calculates returns' kurtosis
- (the degree to which a distribution peak compared to a normal distribution)
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the ratio (default: True)
+ smart (bool): Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ float: Sortino ratio
+
+ Raises:
+ ValueError: If rf is non-zero but periods is None
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> sortino_ratio = sortino(returns, rf=0.02)
+ >>> print(f"Sortino ratio: {sortino_ratio:.4f}")
+
+ Note:
+ Calculation is based on this paper by Red Rock Capital:
+ http://www.redrockcapital.com/Sortino__A__Sharper__Ratio_Red_Rock_Capital.pdf
"""
- return _utils._prepare_returns(returns).kurtosis()
+ validate_input(returns)
+ # Validate parameters for risk-free rate handling
+ if rf != 0 and periods is None:
+ raise ValueError("periods parameter is required when risk-free rate (rf) is non-zero. "
+ "This is needed to properly annualize the risk-free rate.")
-def calmar(returns):
- """ calculates the calmar ratio (CAGR% / MaxDD%) """
- returns = _utils._prepare_returns(returns)
- cagr_ratio = cagr(returns)
- max_dd = max_drawdown(returns)
- return cagr_ratio / abs(max_dd)
-
+ # Prepare returns (subtract risk-free rate if applicable)
+ returns = _utils._prepare_returns(returns, rf, periods)
-def ulcer_index(returns, rf=0):
- """ calculates the ulcer index score (downside risk measurment) """
- returns = _utils._prepare_returns(returns, rf)
- dd = 1. - returns/returns.cummax()
- return _np.sqrt(_np.divide((dd**2).sum(), returns.shape[0] - 1))
+ # Calculate downside deviation (only negative returns)
+ downside = _np.sqrt((returns[returns < 0] ** 2).sum() / len(returns))
+ # Apply autocorrelation penalty if smart mode enabled
+ if smart:
+ # penalize sortino with auto correlation
+ downside = downside * autocorr_penalty(returns)
-def ulcer_performance_index(returns, rf=0):
- """
- calculates the ulcer index score
- (downside risk measurment)
- """
- returns = _utils._prepare_returns(returns, rf)
- dd = 1. - returns/returns.cummax()
- ulcer = _np.sqrt(_np.divide((dd**2).sum(), returns.shape[0] - 1))
- return returns.mean() / ulcer
+ # Calculate base Sortino ratio
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(downside, _pd.Series):
+ res = returns.mean() / downside.replace(0, _np.nan)
+ else:
+ if downside == 0:
+ res = _np.nan
+ else:
+ res = returns.mean() / downside
+ # Annualize if requested
+ if annualize:
+ return res * _np.sqrt(1 if periods is None else periods)
-def upi(returns, rf=0):
- """ shorthand for ulcer_performance_index() """
- return ulcer_performance_index(returns, rf)
+ return res
-def risk_of_ruin(returns):
- """
- calculates the risk of ruin
- (the likelihood of losing all one's investment capital)
+def smart_sortino(
+ returns: Returns,
+ rf: float = 0,
+ periods: int = 252,
+ annualize: bool = True,
+) -> float | _pd.Series:
"""
- returns = _utils._prepare_returns(returns)
- wins = win_rate(returns)
- return ((1 - wins) / (1 + wins)) ** len(returns)
+ Calculate the Smart Sortino ratio (Sortino with autocorrelation penalty).
+ This is a wrapper for the sortino() function with smart=True, which
+ applies an autocorrelation penalty to provide more realistic risk-adjusted
+ returns for strategies with autocorrelated returns.
-def ror(returns):
- """ shorthand for risk_of_ruin() """
- return risk_of_ruin(returns)
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the ratio (default: True)
+
+ Returns:
+ float: Smart Sortino ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> smart_sortino_ratio = smart_sortino(returns)
+ >>> print(f"Smart Sortino ratio: {smart_sortino_ratio:.4f}")
+ """
+ return sortino(returns, rf, periods, annualize, True)
-def value_at_risk(returns, sigma=1, confidence=0.95):
- """
- calculats the daily value-at-risk
- (variance-covariance calculation with confidence n)
+def rolling_sortino(
+ returns: Returns,
+ rf: float = 0,
+ rolling_period: int = 126,
+ annualize: bool = True,
+ periods_per_year: int = 252,
+ **kwargs,
+) -> _pd.Series:
"""
- returns = _utils._prepare_returns(returns)
- mu = returns.mean()
- sigma *= returns.std()
-
- if confidence > 1:
- confidence = confidence/100
+ Calculate rolling Sortino ratio over a specified window.
- return _norm.ppf(1-confidence, mu, sigma)
+ This function computes the Sortino ratio using a rolling window, providing
+ a time-varying measure of downside risk-adjusted performance.
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ rolling_period (int): Rolling window size (default: 126, ~6 months)
+ annualize (bool): Whether to annualize the ratio (default: True)
+ periods_per_year (int): Periods per year for annualization (default: 252)
+ **kwargs: Additional keyword arguments (e.g., prepare_returns)
+
+ Returns:
+ pd.Series: Rolling Sortino ratio series
+
+ Raises:
+ Exception: If rf != 0 and rolling_period is None
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> rolling_sortino_ratio = rolling_sortino(returns, rolling_period=3)
+ >>> print(rolling_sortino_ratio)
+ """
+ # Validate parameters for risk-free rate handling
+ if rf != 0 and rolling_period is None:
+ raise Exception("Must provide periods if rf != 0")
-def var(returns, sigma=1, confidence=0.95):
- """ shorthand for value_at_risk() """
- return value_at_risk(returns, sigma, confidence)
+ if kwargs.get("prepare_returns", True):
+ returns = _utils._prepare_returns(returns, rf, rolling_period)
+ # Optimized downside calculation using vectorized operations
+ def calc_downside(x):
+ """
+ Calculate downside variance more efficiently.
-def conditional_value_at_risk(returns, sigma=1, confidence=0.95):
- """
- calculats the conditional daily value-at-risk (aka expected shortfall)
- quantifies the amount of tail risk an investment
- """
- returns = _utils._prepare_returns(returns)
- var = value_at_risk(returns, sigma, confidence)
- c_var = returns[returns < var].mean()[0]
- return c_var if ~_np.isnan(c_var) else var
+ This function computes the sum of squared negative returns,
+ which is used to calculate downside deviation.
+ """
+ negative_returns = x[x < 0]
+ return (negative_returns**2).sum() if len(negative_returns) > 0 else 0
+ # Calculate rolling downside deviation
+ downside = (
+ returns.rolling(rolling_period).apply(calc_downside, raw=True) / rolling_period
+ )
-def cvar(returns, sigma=1, confidence=0.95):
- """ shorthand for conditional_value_at_risk() """
- return conditional_value_at_risk(returns, sigma, confidence)
+ # Calculate rolling Sortino ratio
+ res = returns.rolling(rolling_period).mean() / _np.sqrt(downside)
+ # Annualize if requested
+ if annualize:
+ res = res * _np.sqrt(1 if periods_per_year is None else periods_per_year)
-def expected_shortfall(returns, sigma=1, confidence=0.95):
- """ shorthand for conditional_value_at_risk() """
- return conditional_value_at_risk(returns, sigma, confidence)
+ return res
-def tail_ratio(returns, cutoff=0.95):
+def adjusted_sortino(
+ returns: Returns,
+ rf: float = 0,
+ periods: int = 252,
+ annualize: bool = True,
+ smart: bool = False,
+) -> float | _pd.Series:
"""
- measures the ratio between the right
- (95%) and left tail (5%).
+ Calculate Jack Schwager's adjusted Sortino ratio.
+
+ This version of the Sortino ratio is adjusted by dividing by sqrt(2)
+ to allow for direct comparisons with the Sharpe ratio. This adjustment
+ accounts for the difference in calculation methods.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the ratio (default: True)
+ smart (bool): Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ float: Adjusted Sortino ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> adj_sortino = adjusted_sortino(returns)
+ >>> print(f"Adjusted Sortino ratio: {adj_sortino:.4f}")
+
+ Note:
+ See here for more info: https://archive.is/wip/2rwFW
"""
- returns = _utils._prepare_returns(returns)
- return abs(returns.quantile(cutoff) / returns.quantile(1-cutoff))
+ # Calculate standard Sortino ratio
+ data = sortino(returns, rf, periods=periods, annualize=annualize, smart=smart)
+ # Apply Schwager's adjustment factor
+ return data / _sqrt(2)
-def payoff_ratio(returns):
- """ measures the payoff ratio (average win/average loss) """
- returns = _utils._prepare_returns(returns)
- return avg_win(returns) / abs(avg_loss(returns))
+def probabilistic_ratio(
+ series: Returns,
+ rf: float = 0.0,
+ base: str = "sharpe",
+ periods: int = 252,
+ annualize: bool = False,
+ smart: bool = False,
+) -> float:
+ """
+ Calculate the probabilistic ratio for a given base metric.
-def win_loss_ratio(returns):
- """ shorthand for payoff_ratio() """
- return payoff_ratio(returns)
+ This function computes the probabilistic version of risk-adjusted ratios,
+ which accounts for the statistical uncertainty in the ratio estimation.
+ It considers skewness and kurtosis to provide more robust estimates.
+ Args:
+ series (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ base (str): Base metric ('sharpe', 'sortino', 'adjusted_sortino')
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the result (default: False)
+ smart (bool): Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ float: Probabilistic ratio (0-1 scale representing probability)
+
+ Raises:
+ ValueError: If invalid base metric is provided
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> prob_ratio = probabilistic_ratio(returns, base="sharpe")
+ >>> print(f"Probabilistic Sharpe ratio: {prob_ratio:.4f}")
+ """
+ # Calculate the base ratio depending on the selected metric
+ if base.lower() == "sharpe":
+ base = sharpe(series, periods=periods, annualize=False, smart=smart)
+ elif base.lower() == "sortino":
+ base = sortino(series, periods=periods, annualize=False, smart=smart)
+ elif base.lower() == "adjusted_sortino":
+ base = adjusted_sortino(series, periods=periods, annualize=False, smart=smart)
+ else:
+ raise ValueError(
+ f"Invalid metric '{base}'. Must be one of: 'sharpe', 'sortino', or 'adjusted_sortino'"
+ )
-def profit_ratio(returns):
- """ measures the profit ratio (win ratio / loss ratio) """
- returns = _utils._prepare_returns(returns)
- wins = returns[returns >= 0]
- loss = returns[returns < 0]
+ # Calculate higher moments for adjustment
+ skew_no = skew(series, prepare_returns=False)
+ kurtosis_no = kurtosis(series, prepare_returns=False)
- win_ratio = abs(wins.mean() / wins.count())
- loss_ratio = abs(loss.mean() / loss.count())
- try:
- return win_ratio / loss_ratio
- except Exception:
- return 0.
+ n = len(series)
+ # Calculate standard error of the ratio incorporating higher moments
+ # Formula accounts for skewness and kurtosis effects on ratio distribution
+ sigma_sr = _np.sqrt(
+ (1 + (0.5 * base**2) - (skew_no * base) + (((kurtosis_no - 3) / 4) * base**2))
+ / (n - 1)
+ )
-def profit_factor(returns):
- """ measures the profit ratio (wins/loss) """
- returns = _utils._prepare_returns(returns)
- return abs(returns[returns >= 0].sum() / returns[returns < 0].sum())
+ # Calculate standardized ratio and convert to probability
+ ratio = (base - rf) / sigma_sr
+ psr = _norm.cdf(ratio)
+ # Annualize if requested
+ if annualize:
+ return psr * (252**0.5)
-def gain_to_pain_ratio(returns):
- """ shorthand for profit_factor() """
- return profit_factor(returns)
+ return psr
-def cpc_index(returns):
+def probabilistic_sharpe_ratio(
+ series: Returns,
+ rf: float = 0.0,
+ periods: int = 252,
+ annualize: bool = False,
+ smart: bool = False,
+) -> float:
"""
- measures the cpc ratio
- (profit factor * win % * win loss ratio)
+ Calculate the Probabilistic Sharpe Ratio (PSR).
+
+ This function computes the PSR, which represents the probability that
+ the observed Sharpe ratio is statistically greater than a benchmark.
+ It accounts for higher moments to provide more robust estimates.
+
+ Args:
+ series (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the result (default: False)
+ smart (bool): Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ float: Probabilistic Sharpe ratio (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> psr = probabilistic_sharpe_ratio(returns)
+ >>> print(f"Probabilistic Sharpe ratio: {psr:.4f}")
+ """
+ return probabilistic_ratio(
+ series, rf, base="sharpe", periods=periods, annualize=annualize, smart=smart
+ )
+
+
+def probabilistic_sortino_ratio(
+ series, rf=0.0, periods=252, annualize=False, smart=False
+):
+ """
+ Calculate the Probabilistic Sortino Ratio.
+
+ This function computes the probabilistic version of the Sortino ratio,
+ which accounts for statistical uncertainty in the ratio estimation.
+
+ Args:
+ series (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the result (default: False)
+ smart (bool): Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ float: Probabilistic Sortino ratio (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> psr = probabilistic_sortino_ratio(returns)
+ >>> print(f"Probabilistic Sortino ratio: {psr:.4f}")
+ """
+ return probabilistic_ratio(
+ series, rf, base="sortino", periods=periods, annualize=annualize, smart=smart
+ )
+
+
+def probabilistic_adjusted_sortino_ratio(
+ series, rf=0.0, periods=252, annualize=False, smart=False
+):
+ """
+ Calculate the Probabilistic Adjusted Sortino Ratio.
+
+ This function computes the probabilistic version of the adjusted Sortino
+ ratio, accounting for statistical uncertainty in the ratio estimation.
+
+ Args:
+ series (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+ annualize (bool): Whether to annualize the result (default: False)
+ smart (bool): Whether to apply autocorrelation penalty (default: False)
+
+ Returns:
+ float: Probabilistic adjusted Sortino ratio (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> psr = probabilistic_adjusted_sortino_ratio(returns)
+ >>> print(f"Probabilistic adjusted Sortino ratio: {psr:.4f}")
+ """
+ return probabilistic_ratio(
+ series,
+ rf,
+ base="adjusted_sortino",
+ periods=periods,
+ annualize=annualize,
+ smart=smart,
+ )
+
+
+def treynor_ratio(returns, benchmark, periods=252.0, rf=0.0):
+ """
+ Calculate the Treynor ratio.
+
+ The Treynor ratio measures risk-adjusted returns relative to systematic risk
+ (beta) rather than total risk (volatility). It's calculated as excess return
+ divided by beta, useful for comparing portfolios with different market exposure.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for beta calculation
+ periods (float): Periods per year for annualization (default: 252.0)
+ rf (float): Risk-free rate (annualized, default: 0.0)
+
+ Returns:
+ float: Treynor ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015])
+ >>> treynor = treynor_ratio(returns, benchmark)
+ >>> print(f"Treynor ratio: {treynor:.4f}")
+ """
+ # Handle DataFrame input by selecting first column
+ if isinstance(returns, _pd.DataFrame):
+ returns = returns[returns.columns[0]]
+
+ # Calculate beta from the Greeks (alpha, beta analysis)
+ beta = greeks(returns, benchmark, periods=periods).to_dict().get("beta", 0)
+
+ # Prevent division by zero
+ if beta == 0:
+ warn("Beta is zero, cannot calculate Treynor ratio, returning 0")
+ return 0
+
+ # Calculate excess return over risk-free rate divided by beta
+ return (comp(returns) - rf) / beta
+
+
+def omega(
+ returns: Returns,
+ rf: float = 0.0,
+ required_return: float = 0.0,
+ periods: int = 252,
+) -> float:
+ """
+ Calculate the Omega ratio of a strategy.
+
+ The Omega ratio measures the probability-weighted ratio of gains to losses
+ above and below a threshold return. It provides a comprehensive view of
+ the return distribution's characteristics.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ required_return (float): Required return threshold (default: 0.0)
+ periods (int): Periods per year for annualization (default: 252)
+
+ Returns:
+ float: Omega ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> omega_ratio = omega(returns, required_return=0.01)
+ >>> print(f"Omega ratio: {omega_ratio:.4f}")
+
+ Note:
+ See https://en.wikipedia.org/wiki/Omega_ratio for more details.
+ """
+ validate_input(returns)
+
+ # Validate minimum data requirements
+ if len(returns) < 2:
+ warn("Insufficient data for omega ratio calculation (need at least 2 returns), returning NaN")
+ return _np.nan
+
+ # Validate required return parameter
+ if required_return <= -1:
+ warn(f"Invalid required_return ({required_return}) for omega ratio, must be > -1, returning NaN")
+ return _np.nan
+
+ # Prepare returns (subtract risk-free rate if applicable)
+ returns = _utils._prepare_returns(returns, rf, periods)
+
+ # Convert annualized required return to per-period if needed
+ if periods == 1:
+ return_threshold = required_return
+ else:
+ return_threshold = (1 + required_return) ** (1.0 / periods) - 1
+
+ # Calculate deviations from threshold
+ returns_less_thresh = returns - return_threshold
+
+ # Sum of positive deviations (gains above threshold)
+ numer = returns_less_thresh[returns_less_thresh > 0.0].sum()
+
+ # Sum of negative deviations (losses below threshold)
+ denom = -1.0 * returns_less_thresh[returns_less_thresh < 0.0].sum()
+
+ # Handle both Series and scalar cases
+ if isinstance(denom, _pd.Series):
+ result = numer / denom
+ # Return NaN where denominator is zero
+ result = result.where(denom > 0.0, _np.nan)
+ return result
+ else:
+ if denom > 0.0:
+ return numer / denom
+ return _np.nan
+
+
+def gain_to_pain_ratio(returns, rf=0, resolution="D"):
+ """
+ Calculate Jack Schwager's Gain-to-Pain Ratio (GPR).
+
+ This ratio measures the total gains divided by the total losses,
+ providing a simple measure of how much profit is generated per
+ unit of loss. Higher values indicate better performance.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (default: 0)
+ resolution (str): Resampling frequency ('D', 'W', 'M', etc.)
+
+ Returns:
+ float: Gain-to-Pain ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> gpr = gain_to_pain_ratio(returns)
+ >>> print(f"Gain-to-Pain ratio: {gpr:.4f}")
+
+ Note:
+ See here for more info: https://archive.is/wip/2rwFW
+ """
+ # Prepare returns and resample to specified frequency
+ returns = _utils._prepare_returns(returns, rf).resample(resolution).sum()
+
+ # Calculate absolute sum of negative returns (pain)
+ downside = abs(returns[returns < 0].sum())
+
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(downside, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ return returns.sum() / downside.replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if downside == 0:
+ return _np.nan
+ return returns.sum() / downside
+
+
+def cagr(
+ returns: Returns,
+ rf: float = 0.0,
+ compounded: bool = True,
+ periods: int = 252,
+) -> float | _pd.Series:
+ """
+ Calculate the Compound Annual Growth Rate (CAGR) of excess returns.
+
+ CAGR represents the geometric mean annual growth rate, providing a
+ smoothed annualized return that accounts for compounding effects.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+ compounded (bool): Whether to compound returns (default: True)
+ periods (int): Periods per year for annualization (default: 252)
+
+ Returns:
+ float or pd.Series: CAGR percentage
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02],
+ ... index=pd.date_range('2023-01-01', periods=5))
+ >>> cagr_value = cagr(returns)
+ >>> print(f"CAGR: {cagr_value:.4f}")
+ """
+ validate_input(returns)
+
+ # Prepare returns (subtract risk-free rate if applicable)
+ total = _utils._prepare_returns(returns, rf)
+
+ # Calculate total return
+ if compounded:
+ total = comp(total)
+ else:
+ total = _np.sum(total, axis=0)
+
+ # Calculate time period in years using trading periods
+ # This is consistent with how Sharpe, Sortino, and other metrics
+ # handle annualization in quantstats
+ years = len(returns) / periods
+
+ # Calculate CAGR using geometric mean formula
+ res = abs(total + 1.0) ** (1.0 / years) - 1
+
+ # Handle DataFrame input
+ if isinstance(returns, _pd.DataFrame):
+ res = _pd.Series(res)
+ res.index = returns.columns
+
+ return res
+
+
+def rar(returns, rf=0.0):
+ """
+ Calculate the Risk-Adjusted Return (RAR).
+
+ RAR is calculated as CAGR divided by exposure, taking into account
+ the time the strategy was actually invested. This provides a more
+ accurate measure of returns adjusted for actual market participation.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (annualized, default: 0.0)
+
+ Returns:
+ float: Risk-adjusted return
+
+ Example:
+ >>> returns = pd.Series([0.01, 0.00, 0.03, 0.00, 0.02])
+ >>> rar_value = rar(returns)
+ >>> print(f"Risk-adjusted return: {rar_value:.4f}")
+ """
+ # Prepare returns (subtract risk-free rate if applicable)
+ returns = _utils._prepare_returns(returns, rf)
+
+ # Calculate CAGR and divide by exposure time
+ return cagr(returns) / exposure(returns)
+
+
+def skew(returns, prepare_returns=True):
+ """
+ Calculate returns' skewness.
+
+ Skewness measures the degree of asymmetry of a distribution around its mean.
+ Positive skewness indicates a longer tail on the positive side,
+ while negative skewness indicates a longer tail on the negative side.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Skewness value
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> skewness = skew(returns)
+ >>> print(f"Skewness: {skewness:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate skewness using pandas built-in method
+ return returns.skew()
+
+
+def kurtosis(returns, prepare_returns=True):
+ """
+ Calculate returns' kurtosis.
+
+ Kurtosis measures the degree to which a distribution is peaked compared
+ to a normal distribution. Higher kurtosis indicates more extreme returns
+ (fat tails), while lower kurtosis indicates fewer extreme returns.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Kurtosis value (excess kurtosis, normal distribution = 0)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> kurt = kurtosis(returns)
+ >>> print(f"Kurtosis: {kurt:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate kurtosis using pandas built-in method (excess kurtosis)
+ return returns.kurtosis()
+
+
+def calmar(
+ returns: Returns,
+ prepare_returns: bool = True,
+ periods: int = 252,
+ compounded: bool = True,
+) -> float:
+ """
+ Calculate the Calmar ratio (CAGR / Maximum Drawdown).
+
+ The Calmar ratio measures risk-adjusted returns by dividing the CAGR
+ by the absolute value of the maximum drawdown. It provides insight
+ into returns relative to the worst-case scenario.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+ periods (int): Periods per year for annualization (default: 252)
+ compounded (bool): Whether to use compounded (geometric) returns
+ for the CAGR calculation (default: True). Set to False for
+ intraday or non-compounded return streams.
+
+ Returns:
+ float: Calmar ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> calmar_ratio = calmar(returns)
+ >>> print(f"Calmar ratio: {calmar_ratio:.4f}")
+ """
+ validate_input(returns)
+
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate CAGR and maximum drawdown
+ cagr_ratio = cagr(returns, compounded=compounded, periods=periods)
+ max_dd = max_drawdown(returns)
+
+ # Return ratio of CAGR to absolute maximum drawdown
+ return cagr_ratio / abs(max_dd)
+
+
+def ulcer_index(returns):
+ """
+ Calculate the Ulcer Index (downside risk measurement).
+
+ The Ulcer Index measures the depth and duration of drawdowns,
+ providing a comprehensive measure of downside risk. It's calculated
+ as the square root of the mean of squared drawdowns.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+
+ Returns:
+ float: Ulcer Index value
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> ulcer = ulcer_index(returns)
+ >>> print(f"Ulcer Index: {ulcer:.4f}")
+ """
+ # Convert returns to drawdown series
+ dd = to_drawdown_series(returns)
+
+ # Calculate root mean square of drawdowns
+ return _np.sqrt(_np.divide((dd**2).sum(), returns.shape[0] - 1))
+
+
+def ulcer_performance_index(returns, rf=0):
+ """
+ Calculate the Ulcer Performance Index (UPI).
+
+ The UPI measures risk-adjusted returns using the Ulcer Index as the
+ risk measure instead of standard deviation. It provides a better
+ measure for strategies with significant drawdowns.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (default: 0)
+
+ Returns:
+ float: Ulcer Performance Index
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> upi_value = ulcer_performance_index(returns)
+ >>> print(f"Ulcer Performance Index: {upi_value:.4f}")
+ """
+ # Calculate excess return divided by Ulcer Index
+ ulcer = ulcer_index(returns)
+
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(ulcer, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ return (comp(returns) - rf) / ulcer.replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if ulcer == 0:
+ return _np.nan
+ return (comp(returns) - rf) / ulcer
+
+
+def upi(returns, rf=0):
+ """
+ Calculate the Ulcer Performance Index (UPI).
+
+ This is a shorthand function for ulcer_performance_index() with
+ the same parameters and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (default: 0)
+
+ Returns:
+ float: Ulcer Performance Index
+ """
+ return ulcer_performance_index(returns, rf)
+
+
+def serenity_index(returns, rf=0):
+ """
+ Calculate the Serenity Index.
+
+ The Serenity Index is a comprehensive risk-adjusted return measure
+ that combines the Ulcer Index with downside risk considerations.
+ It provides a more holistic view of strategy performance.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (default: 0)
+
+ Returns:
+ float: Serenity Index
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> serenity = serenity_index(returns)
+ >>> print(f"Serenity Index: {serenity:.4f}")
+
+ Note:
+ Based on KeyQuant whitepaper:
+ https://www.keyquant.com/Download/GetFile?Filename=%5CPublications%5CKeyQuant_WhitePaper_APT_Part1.pdf
+ """
+ # Convert returns to drawdown series
+ dd = to_drawdown_series(returns)
+
+ # Calculate pitfall measure using conditional value at risk of drawdowns
+ std_returns = returns.std()
+
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(std_returns, _pd.Series):
+ # DataFrame input - element-wise operations
+ pitfall = -cvar(dd) / std_returns.replace(0, _np.nan)
+ denominator = ulcer_index(returns) * pitfall
+ return (returns.sum() - rf) / denominator.replace(0, _np.nan)
+ else:
+ # Series input - scalar operations
+ if std_returns == 0:
+ return _np.nan
+
+ cvar_val = cvar(dd)
+ ulcer_val = ulcer_index(returns)
+
+ # Handle cases where these might return Series/array
+ if hasattr(cvar_val, '__len__') and len(cvar_val) == 1:
+ cvar_val = float(cvar_val.iloc[0] if hasattr(cvar_val, 'iloc') else cvar_val[0])
+ if hasattr(ulcer_val, '__len__') and len(ulcer_val) == 1:
+ ulcer_val = float(ulcer_val.iloc[0] if hasattr(ulcer_val, 'iloc') else ulcer_val[0])
+
+ pitfall = -cvar_val / std_returns
+ denominator = ulcer_val * pitfall
+
+ if denominator == 0:
+ return _np.nan
+ return (returns.sum() - rf) / denominator
+
+
+def risk_of_ruin(returns, prepare_returns=True):
+ """
+ Calculate the risk of ruin (probability of losing all capital).
+
+ This function estimates the likelihood of losing all investment capital
+ based on the win rate and the number of trades/periods. It's useful
+ for position sizing and risk management.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Risk of ruin probability (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> ror_value = risk_of_ruin(returns)
+ >>> print(f"Risk of ruin: {ror_value:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate win rate
+ wins = win_rate(returns)
+
+ # Calculate risk of ruin using gambler's ruin formula
+ return ((1 - wins) / (1 + wins)) ** len(returns)
+
+
+def ror(returns):
+ """
+ Calculate the risk of ruin (probability of losing all capital).
+
+ This is a shorthand function for risk_of_ruin() with the same
+ parameters and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+
+ Returns:
+ float: Risk of ruin probability (0-1 scale)
+ """
+ return risk_of_ruin(returns)
+
+
+def value_at_risk(
+ returns: Returns,
+ sigma: float = 1,
+ confidence: float = 0.95,
+ prepare_returns: bool = True,
+) -> float | _pd.Series:
+ """
+ Calculate the daily Value at Risk (VaR).
+
+ VaR estimates the maximum expected loss over a given time horizon
+ at a specified confidence level, using the variance-covariance method.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ sigma (float): Volatility multiplier (default: 1)
+ confidence (float): Confidence level (0.95 = 95%, default: 0.95)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Value at Risk (negative value representing loss)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> var_value = value_at_risk(returns, confidence=0.95)
+ >>> print(f"95% VaR: {var_value:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate mean and adjust volatility
+ mu = returns.mean()
+ sigma *= returns.std()
+
+ # Convert percentage confidence to decimal if needed
+ if confidence > 1:
+ confidence = confidence / 100
+
+ # Calculate VaR using normal distribution inverse CDF
+ return _norm.ppf(1 - confidence, mu, sigma)
+
+
+def var(returns, sigma=1, confidence=0.95, prepare_returns=True):
+ """
+ Calculate the daily Value at Risk (VaR).
+
+ This is a shorthand function for value_at_risk() with the same
+ parameters and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ sigma (float): Volatility multiplier (default: 1)
+ confidence (float): Confidence level (0.95 = 95%, default: 0.95)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Value at Risk (negative value representing loss)
+ """
+ return value_at_risk(returns, sigma, confidence, prepare_returns)
+
+
+def conditional_value_at_risk(
+ returns: Returns,
+ sigma: float = 1,
+ confidence: float = 0.95,
+ prepare_returns: bool = True,
+) -> float | _pd.Series:
+ """
+ Calculate the Conditional Value at Risk (CVaR), also known as Expected Shortfall.
+
+ CVaR measures the expected loss given that a loss exceeds the VaR threshold.
+ It quantifies the amount of tail risk an investment faces, providing a more
+ comprehensive risk measure than VaR alone.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ sigma (float): Volatility multiplier (default: 1)
+ confidence (float): Confidence level (0.95 = 95%, default: 0.95)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Conditional Value at Risk (expected loss beyond VaR)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> cvar_value = conditional_value_at_risk(returns, confidence=0.95)
+ >>> print(f"95% CVaR: {cvar_value:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Handle both Series and DataFrame inputs
+ if isinstance(returns, _pd.DataFrame):
+ # For DataFrame, calculate CVaR for each column separately
+ result = {}
+ for col in returns.columns:
+ col_returns = returns[col]
+ # Calculate VaR for this specific column
+ col_var = value_at_risk(col_returns, sigma, confidence, prepare_returns=False)
+ below_var = col_returns[col_returns < col_var]
+ c_var_col = below_var.mean() if len(below_var) > 0 else _np.nan
+ result[col] = c_var_col if not _np.isnan(c_var_col) else col_var
+ return _pd.Series(result)
+ else:
+ # For Series, calculate VaR threshold
+ var = value_at_risk(returns, sigma, confidence)
+ c_var = returns[returns < var].values.mean()
+ # Return CVaR if valid, otherwise return VaR
+ return c_var if ~_np.isnan(c_var) else var
+
+
+def cvar(returns, sigma=1, confidence=0.95, prepare_returns=True):
+ """
+ Calculate the Conditional Value at Risk (CVaR).
+
+ This is a shorthand function for conditional_value_at_risk() with
+ the same parameters and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ sigma (float): Volatility multiplier (default: 1)
+ confidence (float): Confidence level (0.95 = 95%, default: 0.95)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Conditional Value at Risk
+ """
+ return conditional_value_at_risk(returns, sigma, confidence, prepare_returns)
+
+
+def expected_shortfall(returns, sigma=1, confidence=0.95):
+ """
+ Calculate the Expected Shortfall (ES), also known as CVaR.
+
+ This is a shorthand function for conditional_value_at_risk() with
+ the same parameters and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ sigma (float): Volatility multiplier (default: 1)
+ confidence (float): Confidence level (0.95 = 95%, default: 0.95)
+
+ Returns:
+ float: Expected Shortfall
+ """
+ return conditional_value_at_risk(returns, sigma, confidence)
+
+
+def tail_ratio(returns, cutoff=0.95, prepare_returns=True):
+ """
+ Calculate the tail ratio between right and left tails.
+
+ This function measures the ratio between the right (95%) and left (5%) tails
+ of the return distribution, providing insight into the asymmetry of extreme
+ returns. Higher values indicate more favorable tail characteristics.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ cutoff (float): Percentile cutoff for tail analysis (default: 0.95)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Tail ratio (right tail / left tail)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> tail_r = tail_ratio(returns)
+ >>> print(f"Tail ratio: {tail_r:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate ratio of right tail to left tail
+ upper_quantile = returns.quantile(cutoff)
+ lower_quantile = returns.quantile(1 - cutoff)
+
+ # Handle edge cases: NaN values or zero denominator
+ # Check if result is a Series (DataFrame input) or scalar (Series input)
+ if isinstance(upper_quantile, _pd.Series):
+ # Handle DataFrame input - apply element-wise
+ result = _pd.Series(index=upper_quantile.index, dtype=float)
+ for col in upper_quantile.index:
+ if _pd.isna(upper_quantile[col]) or _pd.isna(lower_quantile[col]) or lower_quantile[col] == 0:
+ result[col] = _np.nan
+ else:
+ result[col] = abs(upper_quantile[col] / lower_quantile[col])
+ return result
+ else:
+ # Handle Series input - scalar values
+ if _pd.isna(upper_quantile) or _pd.isna(lower_quantile) or lower_quantile == 0:
+ return _np.nan
+ return abs(upper_quantile / lower_quantile)
+
+
+def payoff_ratio(returns, prepare_returns=True):
+ """
+ Calculate the payoff ratio (average win / average loss).
+
+ This function measures the ratio of average winning returns to average
+ losing returns, providing insight into the reward-to-risk profile
+ of individual trades or periods.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Payoff ratio (average win / absolute average loss)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> payoff_r = payoff_ratio(returns)
+ >>> print(f"Payoff ratio: {payoff_r:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate ratio of average win to absolute average loss
+ avg_loss_val = avg_loss(returns)
+ avg_win_val = avg_win(returns)
+
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(avg_loss_val, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ # Use abs() before replace to handle negative values properly
+ return avg_win_val / abs(avg_loss_val).replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if avg_loss_val == 0:
+ return _np.nan
+ return avg_win_val / abs(avg_loss_val)
+
+
+def win_loss_ratio(returns, prepare_returns=True):
+ """
+ Calculate the win-loss ratio (average win / average loss).
+
+ This is a shorthand function for payoff_ratio() with the same
+ parameters and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Win-loss ratio
+ """
+ return payoff_ratio(returns, prepare_returns)
+
+
+def profit_ratio(returns, prepare_returns=True):
+ """
+ Calculate the profit ratio (win ratio / loss ratio).
+
+ This function measures the ratio of win frequency to loss frequency,
+ providing insight into the consistency of profitable periods.
+
+ Args:
+ returns (pd.Series or pd.DataFrame): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float or pd.Series: Profit ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> profit_r = profit_ratio(returns)
+ >>> print(f"Profit ratio: {profit_r:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ def _profit_ratio(ret):
+ # Separate wins and losses
+ wins = ret[ret >= 0]
+ loss = ret[ret < 0]
+
+ # Handle edge cases
+ win_count = len(wins)
+ loss_count = len(loss)
+
+ if win_count == 0:
+ return 0.0
+ if loss_count == 0:
+ return _np.nan
+
+ # Calculate win and loss ratios
+ win_ratio = abs(wins.mean() / win_count) if win_count > 0 else 0
+ loss_ratio = abs(loss.mean() / loss_count) if loss_count > 0 else 0
+
+ if loss_ratio == 0:
+ return _np.nan
+ return win_ratio / loss_ratio
+
+ # Handle DataFrame by applying to each column
+ if isinstance(returns, _pd.DataFrame):
+ return returns.apply(_profit_ratio)
+
+ return _profit_ratio(returns)
+
+
+def profit_factor(returns, prepare_returns=True):
"""
- returns = _utils._prepare_returns(returns)
- return profit_factor(returns) * win_rate(returns) * \
- win_loss_ratio(returns)
+ Calculate the profit factor (total wins / total losses).
+ This function measures the ratio of total winning returns to total
+ losing returns, providing insight into the overall profitability
+ of the strategy.
-def common_sense_ratio(returns):
- """ measures the common sense ratio (profit factor * tail ratio) """
- returns = _utils._prepare_returns(returns)
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Profit factor (total wins / total losses)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> pf = profit_factor(returns)
+ >>> print(f"Profit factor: {pf:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate total wins and losses
+ wins_sum = returns[returns >= 0].sum()
+ losses_sum = abs(returns[returns < 0].sum())
+
+ # Handle both Series and scalar cases
+ if isinstance(losses_sum, _pd.Series):
+ result = wins_sum / losses_sum
+ # Replace infinite values with 0
+ result = result.replace([_np.inf, -_np.inf], 0)
+ return result
+ else:
+ # Handle division by zero case
+ if losses_sum == 0:
+ return 0.0 if wins_sum == 0 else float('inf')
+ return wins_sum / losses_sum
+
+
+def cpc_index(returns, prepare_returns=True):
+ """
+ Calculate the CPC Index (Profit Factor * Win Rate * Win-Loss Ratio).
+
+ The CPC Index is a comprehensive performance measure that combines
+ profit factor, win rate, and win-loss ratio to provide a single
+ metric for strategy evaluation.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: CPC Index
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> cpc = cpc_index(returns)
+ >>> print(f"CPC Index: {cpc:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate composite metric
+ return profit_factor(returns) * win_rate(returns) * win_loss_ratio(returns)
+
+
+def common_sense_ratio(returns, prepare_returns=True):
+ """
+ Calculate the Common Sense Ratio (Profit Factor * Tail Ratio).
+
+ This ratio combines profit factor with tail ratio to provide a
+ measure that considers both profitability and tail risk characteristics.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Common Sense Ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> csr = common_sense_ratio(returns)
+ >>> print(f"Common Sense Ratio: {csr:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate composite metric
return profit_factor(returns) * tail_ratio(returns)
-def outlier_win_ratio(returns, quantile=.99):
+def outlier_win_ratio(returns, quantile=0.99, prepare_returns=True):
+ """
+ Calculate the outlier winners ratio.
+
+ This function computes the ratio of the 99th percentile of returns
+ to the mean positive return, showing how much outlier wins contribute
+ to overall performance.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ quantile (float): Quantile for outlier threshold (default: 0.99)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Outlier win ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> outlier_win_r = outlier_win_ratio(returns)
+ >>> print(f"Outlier win ratio: {outlier_win_r:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate ratio of high quantile to mean positive return
+ positive_mean = returns[returns >= 0].mean()
+ quantile_val = returns.quantile(quantile) # Series for DataFrame, scalar for Series
+
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(positive_mean, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ return quantile_val / positive_mean.replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if _pd.isna(positive_mean) or positive_mean == 0:
+ return _np.nan
+ return quantile_val / positive_mean
+
+
+def outlier_loss_ratio(returns, quantile=0.01, prepare_returns=True):
"""
- calculates the outlier winners ratio
- 99th percentile of returns / mean positive return
+ Calculate the outlier losers ratio.
+
+ This function computes the ratio of the 1st percentile of returns
+ to the mean negative return, showing how much outlier losses contribute
+ to overall risk.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ quantile (float): Quantile for outlier threshold (default: 0.01)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Outlier loss ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> outlier_loss_r = outlier_loss_ratio(returns)
+ >>> print(f"Outlier loss ratio: {outlier_loss_r:.4f}")
"""
- returns = _utils._prepare_returns(returns)
- return returns.quantile(quantile).mean() / returns[returns >= 0].mean()
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+ # Calculate ratio of low quantile to mean negative return
+ negative_mean = returns[returns < 0].mean()
+ quantile_val = returns.quantile(quantile) # Series for DataFrame, scalar for Series
-def outlier_loss_ratio(returns, quantile=.01):
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(negative_mean, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ return quantile_val / negative_mean.replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if _pd.isna(negative_mean) or negative_mean == 0:
+ return _np.nan
+ return quantile_val / negative_mean
+
+
+def recovery_factor(returns, rf=0.0, prepare_returns=True):
"""
- calculates the outlier losers ratio
- 1st percentile of returns / mean negative return
+ Calculate the recovery factor (total returns / maximum drawdown).
+
+ This function measures how fast the strategy recovers from drawdowns
+ by comparing total returns to the maximum drawdown experienced.
+ Higher values indicate better recovery characteristics.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ rf (float): Risk-free rate (default: 0.0)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Recovery factor
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> rf_value = recovery_factor(returns)
+ >>> print(f"Recovery factor: {rf_value:.4f}")
"""
- returns = _utils._prepare_returns(returns)
- return returns.quantile(quantile).mean() / returns[returns < 0].mean()
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+ # Calculate total excess returns
+ total_returns = returns.sum() - rf
-def recovery_factor(returns):
- """ measures how fast the strategy recovers from drawdowns """
- returns = _utils._prepare_returns(returns)
- total_returns = comp(returns)
+ # Calculate maximum drawdown
max_dd = max_drawdown(returns)
- return total_returns / abs(max_dd)
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(max_dd, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ return abs(total_returns) / abs(max_dd).replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if max_dd == 0:
+ return _np.nan
+ return abs(total_returns) / abs(max_dd)
+
+
+def risk_return_ratio(returns, prepare_returns=True):
+ """
+ Calculate the risk-return ratio (mean return / standard deviation).
+
+ This function calculates the Sharpe ratio without factoring in the
+ risk-free rate, providing a simple measure of return per unit of risk.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Risk-return ratio
-def risk_return_ratio(returns):
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> rrr = risk_return_ratio(returns)
+ >>> print(f"Risk-return ratio: {rrr:.4f}")
"""
- calculates the return / risk ratio
- (sharpe ratio without factoring in the risk-free rate)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Calculate mean return divided by standard deviation
+ std = returns.std()
+
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(std, _pd.Series):
+ # DataFrame input - element-wise division with zero protection
+ return returns.mean() / std.replace(0, _np.nan)
+ else:
+ # Series input - scalar division
+ if std == 0:
+ return _np.nan
+ return returns.mean() / std
+
+
+def _get_baseline_value(prices):
"""
- returns = _utils._prepare_returns(returns)
- return returns.mean() / returns.std()
+ Determine the appropriate baseline value for drawdown calculations.
+ This function analyzes the price series to determine the correct baseline
+ value that should represent "no drawdown" (i.e., the starting equity).
-def max_drawdown(prices):
- """ calculates the maximum drawdown """
- prices = _utils._prepare_prices(prices)
- return (prices / prices.expanding(min_periods=0).max()).min() - 1
+ Args:
+ prices (pd.Series): Price series
+
+ Returns:
+ float: Baseline value for drawdown calculations
+ """
+ if len(prices) == 0:
+ return 1.0
+
+ # Handle both Series and DataFrame cases
+ if isinstance(prices, _pd.DataFrame):
+ # If prices is a DataFrame, ensure it has at least one column
+ if prices.shape[1] == 0:
+ return 1.0 # Default baseline for empty DataFrame with no columns
+ # Get the first value of the first column
+ first_price = prices.iat[0, 0]
+ else:
+ # If prices is a Series, get the first value directly
+ first_price = prices.iloc[0]
+
+ # If the first price is much larger than 1, it's likely from to_prices conversion
+ # The to_prices function uses base * (1 + compsum), so we determine the appropriate baseline
+ if first_price > 1000:
+ # This suggests it came from to_prices with a large base (default 1e5)
+ # However, we should use a more reasonable baseline for drawdown calculations
+ # We'll use the same scale as the prices but represent the "no loss" baseline
+ return 1e5
+ elif first_price > 10:
+ # Smaller base value scale
+ return 100.0
+ else:
+ # Normal price scale, use 1.0 as baseline
+ return 1.0
+
+
+def max_drawdown(prices: Returns) -> float:
+ """
+ Calculate the maximum drawdown from peak to trough.
+
+ This function calculates the maximum observed loss from a peak to a
+ subsequent trough, expressed as a percentage. It handles the edge case
+ where the first return is negative by establishing a proper baseline.
+ Args:
+ prices (pd.Series): Price series or cumulative returns
+
+ Returns:
+ float: Maximum drawdown (negative value)
+
+ Example:
+ >>> prices = pd.Series([100, 110, 105, 120, 115])
+ >>> max_dd = max_drawdown(prices)
+ >>> print(f"Maximum drawdown: {max_dd:.4f}")
+ """
+ validate_input(prices)
-def to_drawdown_series(prices):
- """ convert price series to drawdown series """
+ # Prepare prices (convert from returns if needed)
prices = _utils._prepare_prices(prices)
- dd = prices / _np.maximum.accumulate(prices) - 1.
- return dd.replace([_np.inf, -_np.inf, -0], 0)
+ if len(prices) == 0:
+ return 0.0
-def drawdown_details(drawdown):
+ # Handle edge case: if first value represents a loss from baseline
+ # Add a phantom baseline value to ensure proper drawdown calculation
+ try:
+ time_delta = prices.index.freq or _pd.Timedelta(days=1)
+ except Exception:
+ time_delta = _pd.Timedelta(days=1)
+
+ phantom_date = prices.index[0] - time_delta
+
+ # Determine appropriate baseline value
+ baseline_value = _get_baseline_value(prices)
+
+ # Create extended series with phantom baseline
+ extended_prices = prices.copy()
+ extended_prices.loc[phantom_date] = baseline_value
+ extended_prices = extended_prices.sort_index()
+
+ # Calculate drawdown with phantom baseline
+ return (extended_prices / extended_prices.expanding(min_periods=0).max()).min() - 1
+
+
+def to_drawdown_series(returns):
"""
- calculates drawdown details, including start/end/valley dates,
- duration, max drawdown and max dd for 99% of the dd period
- for every drawdown period
+ Convert returns series to drawdown series.
+
+ This function converts a return series to a drawdown series showing
+ the decline from peak equity at each point in time. It handles the
+ edge case where the first return is negative by establishing a proper baseline.
+
+ Args:
+ returns (pd.Series): Return series to convert
+
+ Returns:
+ pd.Series: Drawdown series (negative values showing decline from peak)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> dd_series = to_drawdown_series(returns)
+ >>> print(dd_series)
"""
- def _drawdown_details(drawdown):
- # mark no drawdown
- no_dd = drawdown == 0
+ validate_input(returns)
- # extract dd start dates
- starts = ~no_dd & no_dd.shift(1)
- starts = list(starts[starts].index)
+ # Convert returns to prices
+ prices = _utils._prepare_prices(returns)
- # extract end dates
- ends = no_dd & (~no_dd).shift(1)
- ends = list(ends[ends].index)
+ if len(prices) == 0:
+ return _pd.Series([], dtype=float, index=returns.index)
- # no drawdown :)
- if not starts:
- return _pd.DataFrame(
- index=[], columns=('start', 'valley', 'end', 'days',
- 'max drawdown', '99% max drawdown'))
+ # Handle edge case: if first value represents a loss from baseline
+ # Add a phantom baseline value to ensure proper drawdown calculation
+ try:
+ time_delta = prices.index.freq or _pd.Timedelta(days=1)
+ except Exception:
+ time_delta = _pd.Timedelta(days=1)
- # drawdown series begins in a drawdown
- if ends and starts[0] > ends[0]:
- starts.insert(0, drawdown.index[0])
+ phantom_date = prices.index[0] - time_delta
- # series ends in a drawdown fill with last date
- if not ends or starts[-1] > ends[-1]:
- ends.append(drawdown.index[-1])
+ # Determine appropriate baseline value
+ baseline_value = _get_baseline_value(prices)
- # build dataframe from results
- data = []
- for i, _ in enumerate(starts):
- dd = drawdown[starts[i]:ends[i]]
- clean_dd = -remove_outliers(-dd, .99)
- data.append((starts[i], dd.idxmin(), ends[i],
- (ends[i] - starts[i]).days,
- dd.min() * 100, clean_dd.min() * 100))
-
- df = _pd.DataFrame(data=data,
- columns=('start', 'valley', 'end', 'days',
- 'max drawdown',
- '99% max drawdown'))
- df['days'] = df['days'].astype(int)
- df['max drawdown'] = df['max drawdown'].astype(float)
- df['99% max drawdown'] = df['99% max drawdown'].astype(float)
-
- df['start'] = df['start'].dt.strftime('%Y-%m-%d')
- df['end'] = df['end'].dt.strftime('%Y-%m-%d')
- df['valley'] = df['valley'].dt.strftime('%Y-%m-%d')
+ # Create extended series with phantom baseline
+ extended_prices = prices.copy()
+ extended_prices.loc[phantom_date] = baseline_value
+ extended_prices = extended_prices.sort_index()
- return df
+ # Calculate drawdown series with phantom baseline
+ dd = extended_prices / _np.maximum.accumulate(extended_prices) - 1.0
- if isinstance(drawdown, _pd.DataFrame):
- _dfs = {}
- for col in drawdown.columns:
- _dfs[col] = _drawdown_details(drawdown[col])
- return _pd.concat(_dfs, axis=1)
+ # Remove phantom point and return original time series
+ dd = dd.drop(phantom_date)
- return _drawdown_details(drawdown)
+ # Clean up infinite and zero values
+ return dd.replace([_np.inf, -_np.inf, -0], 0) # type: ignore[attr-defined]
-def kelly_criterion(returns):
+def kelly_criterion(returns, prepare_returns=True):
"""
- calculates the recommended maximum amount of capital that
+ Calculates the recommended maximum amount of capital that
should be allocated to the given strategy, based on the
Kelly Criterion (http://en.wikipedia.org/wiki/Kelly_criterion)
"""
- returns = _utils._prepare_returns(returns)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
win_loss_ratio = payoff_ratio(returns)
win_prob = win_rate(returns)
lose_prob = 1 - win_prob
- return ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio
+ # Handle both Series (DataFrame input) and scalar (Series input) cases
+ if isinstance(win_loss_ratio, _pd.Series):
+ # DataFrame input - element-wise operations with zero/nan protection
+ # Replace 0 and NaN values with NaN to avoid division issues
+ win_loss_ratio_safe = win_loss_ratio.replace(0, _np.nan)
+ return ((win_loss_ratio_safe * win_prob) - lose_prob) / win_loss_ratio_safe
+ else:
+ # Series input - scalar operations
+ if win_loss_ratio == 0 or _pd.isna(win_loss_ratio):
+ return _np.nan
+ return ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio
# ==== VS. BENCHMARK ====
-def r_squared(returns, benchmark):
- """ measures the straight line fit of the equity curve """
- # slope, intercept, r_val, p_val, std_err = _linregress(
+
+def r_squared(returns, benchmark, prepare_returns=True):
+ """
+ Calculate the R-squared (coefficient of determination) versus benchmark.
+
+ R-squared measures how well the returns fit a straight line relationship
+ with the benchmark. Values closer to 1 indicate higher correlation with
+ the benchmark, while values closer to 0 indicate more independent movement.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for comparison
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: R-squared value (0-1 scale)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015])
+ >>> r_sq = r_squared(returns, benchmark)
+ >>> print(f"R-squared: {r_sq:.4f}")
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Prepare benchmark to match returns index
+ benchmark = _utils._prepare_benchmark(benchmark, returns.index)
+
+ # Perform linear regression and extract correlation coefficient
_, _, r_val, _, _ = _linregress(
- _utils._prepare_returns(returns),
- _utils._prepare_benchmark(benchmark, returns.index))
+ returns, _utils._prepare_benchmark(benchmark, returns.index)
+ )
+
+ # Square the correlation coefficient to get R-squared
return r_val**2
def r2(returns, benchmark):
- """ shorthand for r_squared() """
+ """
+ Calculate the R-squared (coefficient of determination) versus benchmark.
+
+ This is a shorthand function for r_squared() with the same parameters
+ and functionality.
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for comparison
+
+ Returns:
+ float: R-squared value (0-1 scale)
+ """
return r_squared(returns, benchmark)
-def information_ratio(returns, benchmark):
+def information_ratio(returns, benchmark, prepare_returns=True):
"""
- calculates the information ratio
- (basically the risk return ratio of the net profits)
+ Calculate the Information Ratio.
+
+ The Information Ratio measures the risk-adjusted excess return of a
+ portfolio relative to a benchmark. It's calculated as the active return
+ (return - benchmark) divided by the tracking error (standard deviation
+ of active returns).
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for comparison
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ float: Information Ratio
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015])
+ >>> info_ratio = information_ratio(returns, benchmark)
+ >>> print(f"Information Ratio: {info_ratio:.4f}")
"""
- diff_rets = _utils._prepare_returns(returns) - \
- _utils._prepare_benchmark(benchmark, returns.index)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
- return diff_rets.mean() / diff_rets.std()
+ # Prepare benchmark to match returns index
+ benchmark = _utils._prepare_benchmark(benchmark, returns.index)
+ # Calculate active returns (returns - benchmark)
+ diff_rets = returns - _utils._prepare_benchmark(benchmark, returns.index)
-def greeks(returns, benchmark, periods=252.):
- """ calculates alpha and beta of the portfolio """
+ # Calculate tracking error (standard deviation of active returns)
+ std = diff_rets.std()
- # ----------------------------
- # data cleanup
- returns = _utils._prepare_returns(returns)
+ # Return Information Ratio (active return / tracking error)
+ if std != 0:
+ return diff_rets.mean() / diff_rets.std()
+ return 0
+
+
+def greeks(returns, benchmark, periods=252.0, prepare_returns=True):
+ """
+ Calculate portfolio Greeks (alpha and beta) relative to benchmark.
+
+ This function calculates the key portfolio metrics for benchmark comparison:
+ - Alpha: Excess return after adjusting for systematic risk (beta)
+ - Beta: Sensitivity to benchmark movements (systematic risk)
+
+ Args:
+ returns (pd.Series): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for comparison
+ periods (float): Periods per year for alpha annualization (default: 252.0)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ pd.Series: Series containing 'alpha' and 'beta' values
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015])
+ >>> portfolio_greeks = greeks(returns, benchmark)
+ >>> print(f"Alpha: {portfolio_greeks['alpha']:.4f}")
+ >>> print(f"Beta: {portfolio_greeks['beta']:.4f}")
+ """
+ # Data preparation
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
benchmark = _utils._prepare_benchmark(benchmark, returns.index)
# ----------------------------
- # find covariance
+ # Calculate covariance matrix between returns and benchmark
matrix = _np.cov(returns, benchmark)
- beta = matrix[0, 1] / matrix[1, 1]
- # calculates measures now
+ # Calculate beta (sensitivity to benchmark movements)
+ if matrix[1, 1] == 0:
+ beta = _np.nan
+ else:
+ beta = matrix[0, 1] / matrix[1, 1]
+
+ # Calculate alpha (excess return after adjusting for beta)
alpha = returns.mean() - beta * benchmark.mean()
+
+ # Annualize alpha
alpha = alpha * periods
- return _pd.Series({
- "beta": beta,
- "alpha": alpha,
- # "vol": _np.sqrt(matrix[0, 0]) * _np.sqrt(periods)
- }).fillna(0)
+ # Return results as Series
+ return _pd.Series(
+ {
+ "beta": beta,
+ "alpha": alpha,
+ # "vol": _np.sqrt(matrix[0, 0]) * _np.sqrt(periods)
+ }
+ ).fillna(0)
+
+
+def rolling_greeks(returns, benchmark, periods=252, prepare_returns=True):
+ """
+ Calculate rolling Greeks (alpha and beta) over time.
+ This function calculates time-varying alpha and beta using a rolling
+ window, showing how portfolio sensitivity to the benchmark changes
+ over time. Useful for analyzing strategy stability and regime changes.
-def rolling_greeks(returns, benchmark, periods=252):
- """ calculates rolling alpha and beta of the portfolio """
- df = _pd.DataFrame(data={
- "returns": _utils._prepare_returns(returns),
- "benchmark": _utils._prepare_benchmark(benchmark, returns.index)
- })
- corr = df.rolling(int(periods)).corr().unstack()['returns']['benchmark']
+ Args:
+ returns (pd.Series): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for comparison
+ periods (int): Rolling window size (default: 252, ~1 year)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ pd.DataFrame: DataFrame with 'alpha' and 'beta' columns over time
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015])
+ >>> rolling_greeks_df = rolling_greeks(returns, benchmark, periods=3)
+ >>> print(rolling_greeks_df)
+ """
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Create combined DataFrame for rolling calculations
+ df = _pd.DataFrame(
+ data={
+ "returns": returns,
+ "benchmark": _utils._prepare_benchmark(benchmark, returns.index),
+ }
+ )
+
+ # Fill NaN values with 0 for calculation stability
+ df = df.fillna(0)
+
+ # Calculate rolling correlation and standard deviations
+ corr = df.rolling(int(periods)).corr().unstack()["returns"]["benchmark"]
std = df.rolling(int(periods)).std()
- beta = corr * std['returns'] / std['benchmark']
- alpha = df['returns'].mean() - beta * df['benchmark'].mean()
+ # Calculate rolling beta (protect against division by zero)
+ beta = corr * std["returns"] / std["benchmark"].replace(0, _np.nan)
- # alpha = alpha * periods
- return _pd.DataFrame(index=returns.index, data={
- "beta": beta,
- "alpha": alpha
- }).fillna(0)
+ # Calculate rolling alpha (not annualized for rolling version)
+ alpha = df["returns"].mean() - beta * df["benchmark"].mean()
+ # Return DataFrame with rolling Greeks
+ return _pd.DataFrame(index=returns.index, data={"beta": beta, "alpha": alpha})
-def compare(returns, benchmark, aggregate=None, compounded=True,
- round_vals=None):
+
+def compare(
+ returns,
+ benchmark,
+ aggregate=None,
+ compounded=True,
+ round_vals=None,
+ prepare_returns=True,
+):
"""
- compare returns to benchmark on a
- day/week/month/quarter/year basis
+ Compare returns to benchmark across different time periods.
+
+ This function provides a comprehensive comparison of portfolio returns
+ versus benchmark performance across various aggregation periods
+ (daily, weekly, monthly, quarterly, yearly).
+
+ Args:
+ returns (pd.Series or pd.DataFrame): Return series to analyze
+ benchmark (pd.Series): Benchmark return series for comparison
+ aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y')
+ compounded (bool): Whether to compound returns (default: True)
+ round_vals (int): Number of decimal places to round (default: None)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ pd.DataFrame: Comparison DataFrame with columns:
+ - Benchmark: Benchmark returns for each period
+ - Returns: Portfolio returns for each period
+ - Multiplier: Portfolio return / Benchmark return
+ - Won: '+' if portfolio outperformed, '-' if underperformed
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015])
+ >>> comparison = compare(returns, benchmark)
+ >>> print(comparison)
"""
- returns = _utils._prepare_returns(returns)
+ # Normalize timezone for returns to ensure consistent comparisons
+ # Convert to UTC if timezone-aware, then make naive
+ # This must happen before prepare_returns to avoid issues
+ if hasattr(returns.index, 'tz') and returns.index.tz is not None:
+ returns = returns.tz_convert('UTC').tz_localize(None)
+
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Normalize benchmark timezone first if it's not a string
+ if benchmark is not None and not isinstance(benchmark, str):
+ if hasattr(benchmark.index if isinstance(benchmark, _pd.Series) else benchmark[benchmark.columns[0]].index, 'tz'):
+ if isinstance(benchmark, _pd.Series) and benchmark.index.tz is not None:
+ benchmark = benchmark.tz_convert('UTC').tz_localize(None)
+ elif isinstance(benchmark, _pd.DataFrame) and benchmark[benchmark.columns[0]].index.tz is not None:
+ for col in benchmark.columns:
+ benchmark[col] = benchmark[col].tz_convert('UTC').tz_localize(None)
+
+ # Store original benchmark for proper aggregation
+ # This preserves returns that may fall on non-trading days
+ if isinstance(benchmark, str):
+ benchmark_original = _utils.download_returns(benchmark)
+ elif isinstance(benchmark, _pd.DataFrame):
+ benchmark_original = benchmark[benchmark.columns[0]].copy()
+ else:
+ benchmark_original = benchmark.copy() if benchmark is not None else None
+
+ # Normalize timezone for benchmark_original as well (in case it was downloaded)
+ if benchmark_original is not None and hasattr(benchmark_original.index, 'tz') and benchmark_original.index.tz is not None:
+ benchmark_original = benchmark_original.tz_convert('UTC').tz_localize(None)
+
+ # Prepare benchmark to match returns index for other calculations
benchmark = _utils._prepare_benchmark(benchmark, returns.index)
- data = _pd.DataFrame(data={
- 'Benchmark': _utils.aggregate_returns(
- benchmark, aggregate, compounded) * 100,
- 'Returns': _utils.aggregate_returns(
- returns, aggregate, compounded) * 100
- })
+ # Handle Series input
+ if isinstance(returns, _pd.Series):
+ # Aggregate returns and use original benchmark for aggregation
+ # This ensures we don't lose benchmark returns on non-trading days
+ if benchmark_original is not None:
+ benchmark_agg = _utils.aggregate_returns(benchmark_original, aggregate, compounded) * 100
+ else:
+ benchmark_agg = _utils.aggregate_returns(benchmark, aggregate, compounded) * 100
+ returns_agg = _utils.aggregate_returns(returns, aggregate, compounded) * 100
+
+ # Create comparison DataFrame
+ data = _pd.DataFrame(
+ data={
+ "Benchmark": benchmark_agg,
+ "Returns": returns_agg,
+ }
+ )
+
+ # Calculate performance multiplier and win/loss indicator
+ # Protect against division by zero in benchmark
+ data["Multiplier"] = data["Returns"] / data["Benchmark"].replace(0, _np.nan)
+ data["Won"] = _np.where(data["Returns"] >= data["Benchmark"], "+", "-")
+
+ # Handle DataFrame input (multiple strategies)
+ elif isinstance(returns, _pd.DataFrame):
+ # Aggregate benchmark using original data to preserve non-trading day returns
+ if benchmark_original is not None:
+ bench = {
+ "Benchmark": _utils.aggregate_returns(benchmark_original, aggregate, compounded) * 100
+ }
+ else:
+ bench = {
+ "Benchmark": _utils.aggregate_returns(benchmark, aggregate, compounded) * 100
+ }
- data['Multiplier'] = data['Returns'] / data['Benchmark']
- data['Won'] = _np.where(data['Returns'] >= data['Benchmark'], '+', '-')
+ # Aggregate each strategy column
+ strategy = {
+ "Returns_" + str(i): _utils.aggregate_returns(returns[col], aggregate, compounded) * 100
+ for i, col in enumerate(returns.columns)
+ }
+ # Combine into single DataFrame
+ data = _pd.DataFrame(data={**bench, **strategy})
+
+ # Apply rounding if specified
if round_vals is not None:
return _np.round(data, round_vals)
return data
-def monthly_returns(returns, eoy=True, compounded=True):
- """ calculates monthly returns """
+def monthly_returns(returns, eoy=True, compounded=True, prepare_returns=True):
+ """
+ Calculate monthly returns in a pivot table format.
+
+ This function creates a matrix showing returns for each month across
+ different years, making it easy to identify seasonal patterns and
+ compare performance across time periods.
+
+ Args:
+ returns (pd.Series or pd.DataFrame): Return series to analyze
+ eoy (bool): Whether to include end-of-year totals (default: True)
+ compounded (bool): Whether to compound returns (default: True)
+ prepare_returns (bool): Whether to prepare returns first (default: True)
+
+ Returns:
+ pd.DataFrame: Monthly returns matrix with years as rows and months
+ as columns. If eoy=True, includes 'EOY' column with
+ annual returns.
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02],
+ ... index=pd.date_range('2023-01-01', periods=5, freq='M'))
+ >>> monthly_rets = monthly_returns(returns)
+ >>> print(monthly_rets)
+ """
+ # Handle DataFrame input by selecting appropriate column
if isinstance(returns, _pd.DataFrame):
+ warn(
+ "Pandas DataFrame was passed (Series expected). "
+ "Only first column will be used."
+ )
+ returns = returns.copy()
returns.columns = map(str.lower, returns.columns)
- if len(returns.columns) > 1 and 'close' in returns.columns:
- returns = returns['close']
+ if len(returns.columns) > 1 and "close" in returns.columns:
+ returns = returns["close"]
else:
returns = returns[returns.columns[0]]
- returns = _utils._prepare_returns(returns)
+ if prepare_returns:
+ returns = _utils._prepare_returns(returns)
+
+ # Store original returns for end-of-year calculations
original_returns = returns.copy()
+ # Group returns by month-year and aggregate
returns = _pd.DataFrame(
- _utils.group_returns(returns,
- returns.index.strftime('%Y-%m-01'),
- compounded))
+ _utils.group_returns(returns, returns.index.strftime("%Y-%m-01"), compounded)
+ )
- returns.columns = ['Returns']
+ # Set up DataFrame structure
+ returns.columns = ["Returns"]
returns.index = _pd.to_datetime(returns.index)
- # get returnsframe
- returns['Year'] = returns.index.strftime('%Y')
- returns['Month'] = returns.index.strftime('%b')
+ # Extract year and month for pivot table
+ returns["Year"] = returns.index.strftime("%Y")
+ returns["Month"] = returns.index.strftime("%b")
- # make pivot table
- returns = returns.pivot('Year', 'Month', 'Returns').fillna(0)
+ # Create pivot table with years as rows and months as columns
+ returns = returns.pivot(index="Year", columns="Month", values="Returns").fillna(0)
- # handle missing months
- for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
- 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']:
+ # Ensure all months are present in the DataFrame
+ for month in [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ]:
if month not in returns.columns:
returns.loc[:, month] = 0
- # order columns by month
- returns = returns[['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
- 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']]
+ # Order columns by calendar month
+ returns = returns[
+ [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ]
+ ]
+ # Add end-of-year totals if requested
if eoy:
- returns['eoy'] = _utils.group_returns(
- original_returns, original_returns.index.year).values
+ returns["eoy"] = _utils.group_returns(
+ original_returns, original_returns.index.year, compounded=compounded # type: ignore
+ ).values
- returns.columns = map(lambda x: str(x).upper(), returns.columns)
+ # Format column names to uppercase
+ returns.columns = map(lambda x: str(x).upper(), returns.columns) # type: ignore
returns.index.name = None
return returns
+
+
+def drawdown_details(drawdown):
+ """
+ Calculate detailed drawdown statistics for each drawdown period.
+
+ This function analyzes a drawdown series to provide comprehensive statistics
+ for each individual drawdown period, including start/end dates, duration,
+ maximum drawdown, and 99th percentile drawdown.
+
+ Args:
+ drawdown (pd.Series or pd.DataFrame): Drawdown series to analyze
+
+ Returns:
+ pd.DataFrame: Detailed drawdown statistics with columns:
+ - start: Start date of drawdown period
+ - valley: Date of maximum drawdown
+ - end: End date of drawdown period
+ - days: Duration in days
+ - max drawdown: Maximum drawdown percentage
+ - 99% max drawdown: 99th percentile drawdown (excludes outliers)
+
+ Example:
+ >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
+ >>> dd_series = to_drawdown_series(returns)
+ >>> dd_details = drawdown_details(dd_series)
+ >>> print(dd_details)
+ """
+
+ def _drawdown_details(drawdown):
+ """
+ Calculate drawdown details for a single drawdown series.
+
+ This internal function processes a single drawdown series to extract
+ detailed statistics about each drawdown period.
+ """
+ # Mark periods with no drawdown (drawdown = 0)
+ no_dd = drawdown == 0
+
+ # Extract drawdown start dates (first date of each drawdown period)
+ starts = ~no_dd & no_dd.shift(1)
+ starts = list(starts[starts.values].index)
+
+ # Extract drawdown end dates (last date of each drawdown period)
+ ends = no_dd & (~no_dd).shift(1)
+ ends = ends.shift(-1, fill_value=False)
+ ends = list(ends[ends.values].index)
+
+ # Return empty DataFrame if no drawdowns found
+ if not starts:
+ return _pd.DataFrame(
+ index=[],
+ columns=(
+ "start",
+ "valley",
+ "end",
+ "days",
+ "max drawdown",
+ "99% max drawdown",
+ ),
+ )
+
+ # Handle edge case: drawdown series begins in a drawdown
+ if ends and starts[0] > ends[0]:
+ starts.insert(0, drawdown.index[0])
+
+ # Handle edge case: series ends in a drawdown
+ if not ends or starts[-1] > ends[-1]:
+ ends.append(drawdown.index[-1])
+
+ # Build detailed statistics for each drawdown period
+ data = []
+ for i, _ in enumerate(starts):
+ # Extract drawdown for this period
+ dd = drawdown[starts[i]:ends[i]]
+
+ # Calculate 99% drawdown (excluding outliers)
+ clean_dd = -remove_outliers(-dd, 0.99)
+
+ # Compile statistics for this drawdown period
+ data.append(
+ (
+ starts[i], # Start date
+ dd.idxmin(), # Valley date (max drawdown)
+ ends[i], # End date
+ (ends[i] - starts[i]).days + 1, # Duration in days
+ dd.min() * 100, # Max drawdown %
+ clean_dd.min() * 100, # 99% max drawdown %
+ )
+ )
+
+ # Create DataFrame with results
+ df = _pd.DataFrame(
+ data=data,
+ columns=(
+ "start",
+ "valley",
+ "end",
+ "days",
+ "max drawdown",
+ "99% max drawdown",
+ ),
+ )
+
+ # Format data types
+ df["days"] = df["days"].astype(int)
+ df["max drawdown"] = df["max drawdown"].astype(float)
+ df["99% max drawdown"] = df["99% max drawdown"].astype(float)
+
+ # Format dates as strings
+ df["start"] = df["start"].dt.strftime("%Y-%m-%d")
+ df["end"] = df["end"].dt.strftime("%Y-%m-%d")
+ df["valley"] = df["valley"].dt.strftime("%Y-%m-%d")
+
+ return df
+
+ # Handle DataFrame input by processing each column separately
+ if isinstance(drawdown, _pd.DataFrame):
+ _dfs = {}
+ for col in drawdown.columns:
+ _dfs[col] = _drawdown_details(drawdown[col])
+ return safe_concat(_dfs, axis=1)
+
+ return _drawdown_details(drawdown)
+
+
+# ======== MONTE CARLO ========
+
+
+def montecarlo(returns, sims=1000, bust=None, goal=None, seed=None):
+ """
+ Run Monte Carlo simulation by shuffling returns.
+
+ This function creates multiple simulated return paths by randomly
+ shuffling the historical returns. This preserves the return distribution
+ while breaking any time-series dependencies, allowing probability-based
+ risk assessment.
+
+ Args:
+ returns (pd.Series or pd.DataFrame): Daily returns (not prices)
+ sims (int): Number of simulations to run (default: 1000)
+ bust (float, optional): Drawdown threshold for "bust" probability
+ (e.g., -0.1 for -10% drawdown)
+ goal (float, optional): Return threshold for "goal" probability
+ (e.g., 1.0 for +100% return)
+ seed (int, optional): Random seed for reproducibility
+
+ Returns:
+ MonteCarloResult: Object containing simulation results with:
+ - .data: DataFrame of all simulation paths
+ - .stats: Terminal value statistics (min, max, mean, median, std)
+ - .maxdd: Max drawdown statistics across simulations
+ - .bust_probability: Probability of exceeding bust threshold
+ - .goal_probability: Probability of reaching goal threshold
+ - .plot(): Visualize simulation paths
+
+ Example:
+ >>> import quantstats as qs
+ >>> returns = qs.utils.download_returns("SPY")
+ >>> mc = qs.stats.montecarlo(returns, sims=1000, bust=-0.2, goal=0.5)
+ >>> print(mc.stats)
+ >>> print(f"Bust probability: {mc.bust_probability:.1%}")
+ >>> print(f"Goal probability: {mc.goal_probability:.1%}")
+ >>> mc.plot()
+ """
+ from ._montecarlo import run_montecarlo
+
+ # Validate and prepare returns
+ validate_input(returns)
+ returns = _utils._prepare_returns(returns)
+
+ # Handle DataFrame by processing first column (or extend for multi-column)
+ if isinstance(returns, _pd.DataFrame):
+ if returns.shape[1] == 1:
+ returns = returns.iloc[:, 0]
+ else:
+ # For multi-column DataFrame, run on first column
+ # Future: could return dict of MonteCarloResults
+ returns = returns.iloc[:, 0]
+
+ return run_montecarlo(returns, sims=sims, bust=bust, goal=goal, seed=seed)
+
+
+def montecarlo_sharpe(returns, sims=1000, rf=0, periods=252, seed=None):
+ """
+ Distribution of Sharpe ratios across Monte Carlo simulations.
+
+ This function runs Monte Carlo simulations and calculates the Sharpe
+ ratio for each simulated path, providing a distribution of possible
+ Sharpe ratio outcomes.
+
+ Args:
+ returns (pd.Series): Daily returns
+ sims (int): Number of simulations (default: 1000)
+ rf (float): Risk-free rate (default: 0)
+ periods (int): Periods per year for annualization (default: 252)
+ seed (int, optional): Random seed for reproducibility
+
+ Returns:
+ dict: Statistics of Sharpe ratio distribution including
+ min, max, mean, median, std, percentile_5, percentile_95
+
+ Example:
+ >>> sharpe_dist = qs.stats.montecarlo_sharpe(returns, sims=1000)
+ >>> print(f"Expected Sharpe: {sharpe_dist['mean']:.2f}")
+ >>> print(f"Sharpe range: {sharpe_dist['percentile_5']:.2f} to "
+ ... f"{sharpe_dist['percentile_95']:.2f}")
+ """
+ from ._montecarlo import run_montecarlo
+
+ validate_input(returns)
+ returns = _utils._prepare_returns(returns)
+
+ if isinstance(returns, _pd.DataFrame):
+ returns = returns.iloc[:, 0]
+
+ mc = run_montecarlo(returns, sims=sims, seed=seed)
+
+ # Calculate Sharpe for each simulation path
+ sharpe_values = []
+ for col in mc.data.columns:
+ # Convert cumulative returns back to simple returns
+ cumret = mc.data[col] + 1
+ sim_returns = cumret.pct_change().dropna()
+ if len(sim_returns) > 0 and sim_returns.std() > 0:
+ excess = sim_returns.mean() - rf / periods
+ sharpe_val = excess / sim_returns.std() * _np.sqrt(periods)
+ sharpe_values.append(sharpe_val)
+
+ sharpe_series = _pd.Series(sharpe_values)
+ return {
+ "min": sharpe_series.min(),
+ "max": sharpe_series.max(),
+ "mean": sharpe_series.mean(),
+ "median": sharpe_series.median(),
+ "std": sharpe_series.std(),
+ "percentile_5": sharpe_series.quantile(0.05),
+ "percentile_95": sharpe_series.quantile(0.95),
+ }
+
+
+def montecarlo_drawdown(returns, sims=1000, seed=None):
+ """
+ Distribution of maximum drawdowns across Monte Carlo simulations.
+
+ This function runs Monte Carlo simulations and returns statistics
+ about the distribution of maximum drawdowns across all paths.
+
+ Args:
+ returns (pd.Series): Daily returns
+ sims (int): Number of simulations (default: 1000)
+ seed (int, optional): Random seed for reproducibility
+
+ Returns:
+ dict: Statistics of max drawdown distribution including
+ min, max, mean, median, std, percentile_5, percentile_95
+
+ Example:
+ >>> dd_dist = qs.stats.montecarlo_drawdown(returns, sims=1000)
+ >>> print(f"Expected max drawdown: {dd_dist['mean']:.1%}")
+ >>> print(f"Worst case (5th pct): {dd_dist['percentile_5']:.1%}")
+ """
+ mc = montecarlo(returns, sims=sims, seed=seed)
+ return mc.maxdd
+
+
+def montecarlo_cagr(returns, sims=1000, seed=None):
+ """
+ Distribution of CAGR across Monte Carlo simulations.
+
+ This function runs Monte Carlo simulations and calculates the
+ Compound Annual Growth Rate for each simulated path.
+
+ Args:
+ returns (pd.Series): Daily returns
+ sims (int): Number of simulations (default: 1000)
+ seed (int, optional): Random seed for reproducibility
+
+ Returns:
+ dict: Statistics of CAGR distribution including
+ min, max, mean, median, std, percentile_5, percentile_95
+
+ Example:
+ >>> cagr_dist = qs.stats.montecarlo_cagr(returns, sims=1000)
+ >>> print(f"Expected CAGR: {cagr_dist['mean']:.1%}")
+ """
+ from ._montecarlo import run_montecarlo
+
+ validate_input(returns)
+ returns = _utils._prepare_returns(returns)
+
+ if isinstance(returns, _pd.DataFrame):
+ returns = returns.iloc[:, 0]
+
+ mc = run_montecarlo(returns, sims=sims, seed=seed)
+
+ # Calculate CAGR for each simulation path
+ n_periods = len(mc.data)
+ years = n_periods / 252 # Assume daily data
+
+ cagr_values = []
+ for col in mc.data.columns:
+ terminal = mc.data[col].iloc[-1]
+ # CAGR = (1 + total_return)^(1/years) - 1
+ if terminal > -1: # Avoid invalid values
+ cagr_val = (1 + terminal) ** (1 / years) - 1
+ cagr_values.append(cagr_val)
+
+ cagr_series = _pd.Series(cagr_values)
+ return {
+ "min": cagr_series.min(),
+ "max": cagr_series.max(),
+ "mean": cagr_series.mean(),
+ "median": cagr_series.median(),
+ "std": cagr_series.std(),
+ "percentile_5": cagr_series.quantile(0.05),
+ "percentile_95": cagr_series.quantile(0.95),
+ }
diff --git a/quantstats/utils.py b/quantstats/utils.py
index e8f9bbfa..14c235da 100644
--- a/quantstats/utils.py
+++ b/quantstats/utils.py
@@ -1,10 +1,9 @@
#!/usr/bin/env python
-# -*- coding: UTF-8 -*-
#
# QuantStats: Portfolio analytics for quants
# https://github.com/ranaroussi/quantstats
#
-# Copyright 2019 Ran Aroussi
+# Copyright 2019-2025 Ran Aroussi
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -21,88 +20,383 @@
import datetime as _dt
import pandas as _pd
import numpy as _np
-import yfinance as _yf
-from . import stats as _stats
+from ._compat import safe_yfinance_download
+from ._compat import safe_concat, safe_resample
+import inspect
+import threading
+
+# Type alias for return data
+Returns = _pd.Series | _pd.DataFrame
+"""Type alias for returns data: can be a pandas Series or DataFrame."""
+
+
+# Custom exception classes for QuantStats
+class QuantStatsError(Exception):
+ """Base exception class for QuantStats"""
+
+ pass
+
+
+class DataValidationError(QuantStatsError):
+ """Raised when input data validation fails"""
+
+ pass
+
+
+class CalculationError(QuantStatsError):
+ """Raised when a calculation fails"""
+
+ pass
+
+
+class PlottingError(QuantStatsError):
+ """Raised when plotting operations fail"""
+
+ pass
+
+
+class BenchmarkError(QuantStatsError):
+ """Raised when benchmark data issues occur"""
+
+ pass
+
+
+def validate_input(data, allow_empty=False):
+ """
+ Validate input data for QuantStats functions
+
+ Parameters
+ ----------
+ data : pd.Series or pd.DataFrame
+ Input data to validate
+ allow_empty : bool, default False
+ Whether to allow empty datasets
+
+ Raises
+ ------
+ DataValidationError
+ If data validation fails
+ """
+ if data is None:
+ raise DataValidationError("Input data cannot be None")
+
+ if not isinstance(data, (_pd.Series, _pd.DataFrame)):
+ raise DataValidationError(
+ f"Input data must be pandas Series or DataFrame, got {type(data)}"
+ )
+
+ if not allow_empty and len(data) == 0:
+ raise DataValidationError("Input data cannot be empty")
+
+ if not allow_empty and data.dropna().empty:
+ raise DataValidationError("Input data contains only NaN values")
+
+ # Check for valid date index
+ if not isinstance(data.index, (_pd.DatetimeIndex, _pd.RangeIndex)):
+ try:
+ data.index = _pd.to_datetime(data.index)
+ except Exception:
+ raise DataValidationError("Input data must have a valid datetime index")
+
+ return True
+
+
+# Cache for _prepare_returns function with thread safety
+_PREPARE_RETURNS_CACHE = {}
+_CACHE_MAX_SIZE = 100
+_cache_lock = threading.Lock()
+
+
+def _generate_cache_key(data, rf, nperiods):
+ """
+ Generate a cache key for the _prepare_returns function
+
+ Parameters
+ ----------
+ data : pd.Series or pd.DataFrame
+ Input data to generate hash from
+ rf : float
+ Risk-free rate parameter
+ nperiods : int
+ Number of periods parameter
+
+ Returns
+ -------
+ str or None
+ Cache key string or None if hashing fails
+ """
+ try:
+ # Create a hash from the data
+ if isinstance(data, _pd.Series):
+ data_hash = _pd.util.hash_pandas_object(data).sum()
+ elif isinstance(data, _pd.DataFrame):
+ data_hash = _pd.util.hash_pandas_object(data).sum()
+ else:
+ data_hash = hash(str(data))
+
+ # Include parameters in the key
+ key = f"{data_hash}_{rf}_{nperiods}"
+ return key
+ except (ValueError, TypeError, AttributeError, MemoryError):
+ # If hashing fails, return None to skip caching
+ return None
+
+
+def _clear_cache_if_full():
+ """
+ Clear cache if it exceeds maximum size
+
+ Uses a simple FIFO strategy, keeping the most recent half of entries
+ when cache size limit is exceeded.
+ """
+ with _cache_lock:
+ if len(_PREPARE_RETURNS_CACHE) >= _CACHE_MAX_SIZE:
+ # Remove oldest entries (simple FIFO) - keep the most recent half
+ keys_to_remove = list(_PREPARE_RETURNS_CACHE.keys())[:-(_CACHE_MAX_SIZE // 2)]
+ for key in keys_to_remove:
+ del _PREPARE_RETURNS_CACHE[key]
def _mtd(df):
- return df[df.index >= _dt.datetime.now(
- ).strftime('%Y-%m-01')]
+ """
+ Filter dataframe to month-to-date data
+
+ Parameters
+ ----------
+ df : pd.DataFrame or pd.Series
+ Input data with datetime index
+
+ Returns
+ -------
+ pd.DataFrame or pd.Series
+ Filtered data from start of current month
+ """
+ # Get first day of current month as string
+ return df[df.index >= _dt.datetime.now().strftime("%Y-%m-01")]
def _qtd(df):
+ """
+ Filter dataframe to quarter-to-date data
+
+ Parameters
+ ----------
+ df : pd.DataFrame or pd.Series
+ Input data with datetime index
+
+ Returns
+ -------
+ pd.DataFrame or pd.Series
+ Filtered data from start of current quarter
+ """
date = _dt.datetime.now()
- for q in [1, 4, 7, 10]:
+ # Check which quarter we're in (Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec)
+ for q in [1, 4, 7, 10]: # First month of each quarter
if date.month <= q:
- return df[df.index >= _dt.datetime(
- date.year, q, 1).strftime('%Y-%m-01')]
- return df[df.index >= date.strftime('%Y-%m-01')]
+ return df[df.index >= _dt.datetime(date.year, q, 1).strftime("%Y-%m-01")]
+ # Default to current month if no quarter match
+ return df[df.index >= date.strftime("%Y-%m-01")]
def _ytd(df):
- return df[df.index >= _dt.datetime.now(
- ).strftime('%Y-01-01')]
+ """
+ Filter dataframe to year-to-date data
+
+ Parameters
+ ----------
+ df : pd.DataFrame or pd.Series
+ Input data with datetime index
+
+ Returns
+ -------
+ pd.DataFrame or pd.Series
+ Filtered data from start of current year
+ """
+ # Get first day of current year as string
+ return df[df.index >= _dt.datetime.now().strftime("%Y-01-01")]
def _pandas_date(df, dates):
+ """
+ Filter dataframe to specific dates
+
+ Parameters
+ ----------
+ df : pd.DataFrame or pd.Series
+ Input data with datetime index
+ dates : list or single date
+ Date(s) to filter by
+
+ Returns
+ -------
+ pd.DataFrame or pd.Series
+ Filtered data for specified dates
+ """
+ # Ensure dates is a list for consistent processing
if not isinstance(dates, list):
dates = [dates]
return df[df.index.isin(dates)]
def _pandas_current_month(df):
+ """
+ Filter dataframe to current month's data
+
+ Parameters
+ ----------
+ df : pd.DataFrame or pd.Series
+ Input data with datetime index
+
+ Returns
+ -------
+ pd.DataFrame or pd.Series
+ Filtered data for current month
+ """
n = _dt.datetime.now()
+ # Create date range from first day of current month to now
daterange = _pd.date_range(_dt.date(n.year, n.month, 1), n)
return df[df.index.isin(daterange)]
def multi_shift(df, shift=3):
- """ get last N rows relative to another row in pandas """
+ """Get last N rows relative to another row in pandas - optimized for memory usage"""
if isinstance(df, _pd.Series):
df = _pd.DataFrame(df)
- dfs = [df.shift(i) for i in _np.arange(shift)]
- for ix, dfi in enumerate(dfs[1:]):
- dfs[ix + 1].columns = [str(col) for col in dfi.columns + str(ix + 1)]
- return _pd.concat(dfs, 1, sort=True)
+ # More memory-efficient approach using dictionary comprehension
+ # and direct column assignment
+ result = df.copy()
+ # Create lagged versions of the data
+ for i in range(1, shift):
+ shifted = df.shift(i)
+ # Rename columns to avoid conflicts
+ shifted.columns = [f"{col}{i}" for col in shifted.columns]
+ result = safe_concat([result, shifted], axis=1, sort=True)
-def to_returns(prices, rf=0.):
- """ Calculates the simple arithmetic returns of a price series """
+ return result
+
+
+def to_returns(prices: Returns, rf: float = 0.0) -> Returns:
+ """
+ Calculate simple arithmetic returns from price series
+
+ Parameters
+ ----------
+ prices : pd.Series or pd.DataFrame
+ Price data
+ rf : float, default 0.0
+ Risk-free rate
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Simple arithmetic returns
+ """
return _prepare_returns(prices, rf)
-def to_prices(returns, base=1e5):
- """ Converts returns series to price data """
- returns = returns.copy().fillna(0).replace(
- [_np.inf, -_np.inf], float('NaN'))
+def to_prices(returns: Returns, base: float = 1e5) -> Returns:
+ """
+ Convert returns series to price data
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ base : float, default 1e5
+ Starting base value for price series
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Price data calculated from returns
+ """
+ from . import stats as _stats # deferred import to avoid circular dependency
+
+ # Clean returns data by filling NaN and replacing infinite values
+ returns = returns.copy().fillna(0).replace([_np.inf, -_np.inf], float("NaN"))
+ # Convert returns to prices using compounded sum
return base + base * _stats.compsum(returns)
-def log_returns(returns, rf=0., nperiods=None):
- """ shorthand for to_log_returns """
+def log_returns(returns: Returns, rf: float = 0.0, nperiods: int | None = None) -> Returns:
+ """
+ Shorthand for to_log_returns function
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ rf : float, default 0.0
+ Risk-free rate
+ nperiods : int, optional
+ Number of periods for risk-free rate conversion
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Log returns
+ """
return to_log_returns(returns, rf, nperiods)
-def to_log_returns(returns, rf=0., nperiods=None):
- """ Converts returns series to log returns """
+def to_log_returns(returns: Returns, rf: float = 0.0, nperiods: int | None = None) -> Returns:
+ """
+ Convert returns series to log returns
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ rf : float, default 0.0
+ Risk-free rate
+ nperiods : int, optional
+ Number of periods for risk-free rate conversion
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Log returns calculated as ln(1 + returns)
+ """
returns = _prepare_returns(returns, rf, nperiods)
try:
- return _np.log(returns+1).replace([_np.inf, -_np.inf], float('NaN'))
- except Exception:
- return 0.
+ # Calculate log returns: ln(1 + returns)
+ return _np.log(returns + 1).replace([_np.inf, -_np.inf], float("NaN")) # type: ignore
+ except (ValueError, TypeError, AttributeError, OverflowError) as e:
+ from warnings import warn
+ warn(f"Error converting to log returns: {type(e).__name__}: {e}, returning 0.0")
+ return 0.0
def exponential_stdev(returns, window=30, is_halflife=False):
- """ Returns series representing exponential volatility of returns """
+ """
+ Calculate exponential weighted standard deviation (volatility) of returns
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ window : int, default 30
+ Window size for exponential weighting
+ is_halflife : bool, default False
+ Whether window parameter represents halflife
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Exponential weighted standard deviation
+ """
returns = _prepare_returns(returns)
+ # Set halflife parameter based on is_halflife flag
halflife = window if is_halflife else None
- return returns.ewm(com=None, span=window,
- halflife=halflife, min_periods=window).std()
+ return returns.ewm(
+ com=None, span=window, halflife=halflife, min_periods=window
+ ).std()
-def rebase(prices, base=100.):
+def rebase(prices: Returns, base: float = 100.0) -> Returns:
"""
Rebase all series to a given intial base.
This makes comparing/plotting different series together easier.
@@ -110,57 +404,110 @@ def rebase(prices, base=100.):
* prices: Expects a price series/dataframe
* base (number): starting value for all series.
"""
+ # Normalize prices to start at the base value
return prices.dropna() / prices.dropna().iloc[0] * base
-def group_returns(returns, groupby, compounded=False):
- """ summarize returns
+def group_returns(returns: Returns, groupby, compounded: bool = False) -> Returns:
+ """
+ Summarize returns by grouping criteria
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ groupby : grouper object
+ Pandas groupby object or criteria
+ compounded : bool, default False
+ Whether to compound returns or use simple sum
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Grouped returns
+
+ Examples
+ --------
group_returns(df, df.index.year)
group_returns(df, [df.index.year, df.index.month])
"""
if compounded:
+ from . import stats as _stats # deferred import to avoid circular dependency
+
+ # Use compounded returns calculation
return returns.groupby(groupby).apply(_stats.comp)
+ # Use simple sum for non-compounded returns
return returns.groupby(groupby).sum()
-def aggregate_returns(returns, period=None, compounded=True):
- """ Aggregates returns based on date periods """
-
- if period is None or 'day' in period:
+def aggregate_returns(returns: Returns, period: str | None = None, compounded: bool = True) -> Returns:
+ """
+ Aggregate returns based on specified time periods
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ period : str, optional
+ Time period for aggregation ('month', 'quarter', 'year', etc.)
+ compounded : bool, default True
+ Whether to compound returns
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Aggregated returns for specified period
+ """
+ # Normalize timezone for consistency before aggregation
+ # Convert to UTC if timezone-aware, then make naive
+ if hasattr(returns.index, 'tz') and returns.index.tz is not None:
+ returns = returns.tz_convert('UTC').tz_localize(None)
+
+ # Return original data if no period specified or daily period
+ if period is None or "day" in period:
return returns
+
index = returns.index
- if 'month' in period:
+ # Group by month
+ if "month" in period:
return group_returns(returns, index.month, compounded=compounded)
- if 'quarter' in period:
+ # Group by quarter
+ if "quarter" in period:
return group_returns(returns, index.quarter, compounded=compounded)
- if period == "A" or any(x in period for x in ['year', 'eoy', 'yoy']):
+ # Group by year (multiple possible period strings)
+ if period == "YE" or any(x in period for x in ["year", "eoy", "yoy"]):
return group_returns(returns, index.year, compounded=compounded)
- if 'week' in period:
+ # Group by week
+ if "week" in period:
return group_returns(returns, index.week, compounded=compounded)
- if 'eow' in period or period == "W":
- return group_returns(returns, [index.year, index.week],
- compounded=compounded)
+ # End of week grouping
+ if "eow" in period or period == "W":
+ return group_returns(returns, [index.year, index.week], compounded=compounded)
- if 'eom' in period or period == "M":
- return group_returns(returns, [index.year, index.month],
- compounded=compounded)
+ # End of month grouping
+ if "eom" in period or period == "ME":
+ return group_returns(returns, [index.year, index.month], compounded=compounded)
- if 'eoq' in period or period == "Q":
- return group_returns(returns, [index.year, index.quarter],
- compounded=compounded)
+ # End of quarter grouping
+ if "eoq" in period or period == "QE":
+ return group_returns(
+ returns, [index.year, index.quarter], compounded=compounded
+ )
+ # Custom period grouping (non-string)
if not isinstance(period, str):
return group_returns(returns, period, compounded)
+ # Default: return original data
return returns
-def to_excess_returns(returns, rf, nperiods=None):
+def to_excess_returns(returns: Returns, rf: float, nperiods: int | None = None) -> Returns:
"""
Calculates excess returns by subtracting
risk-free returns from total returns
@@ -173,70 +520,188 @@ def to_excess_returns(returns, rf, nperiods=None):
Returns:
* excess_returns (Series, DataFrame): Returns - rf
"""
+ # Convert integer rf to float for consistency
+ if isinstance(rf, int):
+ rf = float(rf)
+
+ # Align rf with returns index if rf is a series/dataframe
if not isinstance(rf, float):
- rf = rf[rf.index.isin(returns.index)]
+ rf = rf[rf.index.isin(returns.index)] # type: ignore
+ # Deannualize rf if nperiods is provided
if nperiods is not None:
# deannualize
- rf = _np.power(1 + returns, 1. / nperiods) - 1.
+ rf = _np.power(1 + rf, 1.0 / nperiods) - 1.0
- return returns - rf
+ # Calculate excess returns
+ df = returns - rf
+ df = df.tz_localize(None)
+ return df
-def _prepare_prices(data, base=1.):
- """ Converts return data into prices + cleanup """
+def _prepare_prices(data, base=1.0):
+ """
+ Convert return data into prices and perform cleanup
+
+ Parameters
+ ----------
+ data : pd.Series or pd.DataFrame
+ Input data (returns or prices)
+ base : float, default 1.0
+ Base value for price conversion
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Cleaned price data
+ """
data = data.copy()
if isinstance(data, _pd.DataFrame):
for col in data.columns:
- if data[col].dropna().min() <= 0 or data[col].dropna().max() < 1:
+ # Cache dropna operation to avoid repeated computation
+ col_clean = data[col].dropna()
+ # Check if data looks like returns (negative values or values < 1)
+ if col_clean.min() <= 0 or col_clean.max() < 1:
data[col] = to_prices(data[col], base)
- # is it returns?
+ # Check if series looks like returns data
# elif data.min() < 0 and data.max() < 1:
elif data.min() < 0 or data.max() < 1:
data = to_prices(data, base)
+ # Clean data by filling NaN and replacing infinite values
if isinstance(data, (_pd.DataFrame, _pd.Series)):
- data = data.fillna(0).replace(
- [_np.inf, -_np.inf], float('NaN'))
+ data = data.fillna(0).replace([_np.inf, -_np.inf], float("NaN"))
+ # Normalize timezone information for consistency
+ # Convert to UTC if timezone-aware, then make naive
+ if hasattr(data.index, 'tz') and data.index.tz is not None:
+ data = data.tz_convert('UTC').tz_localize(None)
return data
-def _prepare_returns(data, rf=0., nperiods=None):
- """ Converts price data into returns + cleanup """
+def _prepare_returns(data, rf=0.0, nperiods=None):
+ """
+ Convert price data into returns and perform cleanup
+
+ Parameters
+ ----------
+ data : pd.Series or pd.DataFrame
+ Input data (prices or returns)
+ rf : float, default 0.0
+ Risk-free rate
+ nperiods : int, optional
+ Number of periods for risk-free rate conversion
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Cleaned returns data
+ """
+ # Try to get from cache first
+ cache_key = _generate_cache_key(data, rf, nperiods)
+ if cache_key:
+ with _cache_lock:
+ if cache_key in _PREPARE_RETURNS_CACHE:
+ return _PREPARE_RETURNS_CACHE[cache_key].copy()
+
data = data.copy()
+ # Get calling function name for conditional processing
+ function = inspect.stack()[1][3]
+ # Process DataFrame columns
if isinstance(data, _pd.DataFrame):
for col in data.columns:
- if data[col].dropna().min() >= 0 or data[col].dropna().max() > 1:
- data[col] = data[col].pct_change()
+ # Cache dropna operation to avoid repeated computation
+ col_clean = data[col].dropna()
+ # Check if data looks like prices (positive values > 1)
+ if col_clean.min() >= 0 and col_clean.max() > 1:
+ data[col] = data[col].pct_change(fill_method=None)
+ # Process Series data
elif data.min() >= 0 and data.max() > 1:
- data = data.pct_change()
+ data = data.pct_change(fill_method=None)
- # cleanup data
- data = data.replace([_np.inf, -_np.inf], float('NaN'))
+ # cleanup data - replace infinite values with NaN
+ data = data.replace([_np.inf, -_np.inf], float("NaN"))
+ # Fill NaN values with 0 and replace infinite values
if isinstance(data, (_pd.DataFrame, _pd.Series)):
- data = data.fillna(0).replace(
- [_np.inf, -_np.inf], float('NaN'))
+ data = data.fillna(0).replace([_np.inf, -_np.inf], float("NaN"))
+
+ # Functions that don't need excess returns calculation
+ unnecessary_function_calls = [
+ "_prepare_benchmark",
+ "cagr",
+ "gain_to_pain_ratio",
+ "rolling_volatility",
+ ]
+
+ # Calculate excess returns if rf > 0 and function needs it
+ if function not in unnecessary_function_calls:
+ if rf > 0:
+ result = to_excess_returns(data, rf, nperiods)
+ # Cache the result
+ if cache_key:
+ _clear_cache_if_full()
+ with _cache_lock:
+ _PREPARE_RETURNS_CACHE[cache_key] = result.copy()
+ return result
+
+ # Normalize timezone information for consistency
+ # Convert to UTC if timezone-aware, then make naive
+ if hasattr(data.index, 'tz') and data.index.tz is not None:
+ data = data.tz_convert('UTC').tz_localize(None)
+
+ # Cache the result
+ if cache_key:
+ _clear_cache_if_full()
+ with _cache_lock:
+ _PREPARE_RETURNS_CACHE[cache_key] = data.copy()
- if rf > 0:
- return to_excess_returns(data, rf, nperiods)
return data
-def download_returns(ticker, period="max"):
+def download_returns(ticker, period="max", proxy=None):
+ """
+ Download returns data for a given ticker using yfinance
+
+ Parameters
+ ----------
+ ticker : str
+ Stock ticker symbol
+ period : str or pd.DatetimeIndex, default "max"
+ Time period for data download
+ proxy : str, optional
+ Proxy server for download
+
+ Returns
+ -------
+ pd.Series
+ Daily returns data for the ticker
+ """
+ # Set up parameters for yfinance download
+ params = {
+ "tickers": ticker,
+ "auto_adjust": True,
+ "multi_level_index": False,
+ "progress": False,
+ }
+
+ # Handle different period types
if isinstance(period, _pd.DatetimeIndex):
- p = {"start": period[0]}
+ params["start"] = period[0]
else:
- p = {"period": period}
- return _yf.Ticker(ticker).history(**p)['Close'].pct_change()
+ params["period"] = period
+
+ # Download data and calculate returns
+ df = safe_yfinance_download(proxy=proxy, **params)["Close"].pct_change(fill_method=None) # type: ignore
+ df = df.fillna(0).tz_localize(None)
+ return df
-def _prepare_benchmark(benchmark=None, period="max", rf=0.):
+def _prepare_benchmark(benchmark=None, period="max", rf=0.0, prepare_returns=True):
"""
- fetch benchmark if ticker is provided, and pass through
+ Fetch benchmark if ticker is provided, and pass through
_prepare_returns()
period can be options or (expected) _pd.DatetimeIndex range
@@ -244,41 +709,99 @@ def _prepare_benchmark(benchmark=None, period="max", rf=0.):
if benchmark is None:
return None
+ # Download benchmark data if ticker string provided
if isinstance(benchmark, str):
benchmark = download_returns(benchmark)
+ # Extract first column if DataFrame provided
elif isinstance(benchmark, _pd.DataFrame):
benchmark = benchmark[benchmark.columns[0]].copy()
- if isinstance(period, _pd.DatetimeIndex):
+ # Align benchmark with strategy period if needed
+ if isinstance(period, _pd.DatetimeIndex) and set(period) != set(benchmark.index):
+
+ # Adjust Benchmark to Strategy frequency
+ benchmark_prices = to_prices(benchmark, base=1)
+ new_index = _pd.date_range(start=period[0], end=period[-1], freq="D")
+ benchmark = (
+ benchmark_prices.reindex(new_index, method="bfill")
+ .reindex(period)
+ .pct_change(fill_method=None)
+ .fillna(0)
+ )
benchmark = benchmark[benchmark.index.isin(period)]
- return _prepare_returns(benchmark.dropna(), rf=rf)
+ # Normalize timezone information for consistent comparisons
+ # Convert to UTC if timezone-aware, then make naive
+ if hasattr(benchmark.index, 'tz') and benchmark.index.tz is not None:
+ benchmark = benchmark.tz_convert('UTC').tz_localize(None)
+ # If already timezone-naive, no action needed
+
+ # Prepare returns or return raw data
+ if prepare_returns:
+ return _prepare_returns(benchmark.dropna(), rf=rf)
+ return benchmark.dropna()
def _round_to_closest(val, res, decimals=None):
- """ round to closest resolution """
+ """
+ Round value to closest resolution
+
+ Parameters
+ ----------
+ val : float
+ Value to round
+ res : float
+ Resolution to round to
+ decimals : int, optional
+ Number of decimal places
+
+ Returns
+ -------
+ float
+ Rounded value
+ """
+ # Auto-detect decimals from resolution if not provided
if decimals is None and "." in str(res):
- decimals = len(str(res).split('.')[1])
+ decimals = len(str(res).split(".")[1])
return round(round(val / res) * res, decimals)
def _file_stream():
- """ Returns a file stream """
+ """
+ Create and return a file stream object
+
+ Returns
+ -------
+ io.BytesIO
+ File stream object for handling bytes
+ """
return _io.BytesIO()
def _in_notebook(matplotlib_inline=False):
- """ Identify enviroment (notebook, terminal, etc) """
+ """
+ Identify current environment (notebook, terminal, etc.)
+
+ Parameters
+ ----------
+ matplotlib_inline : bool, default False
+ Whether to enable matplotlib inline mode
+
+ Returns
+ -------
+ bool
+ True if running in Jupyter notebook, False otherwise
+ """
try:
- from IPython import get_ipython
- shell = get_ipython().__class__.__name__
- if shell == 'ZMQInteractiveShell':
+ # Get IPython shell class name
+ shell = get_ipython().__class__.__name__ # type: ignore
+ if shell == "ZMQInteractiveShell":
# Jupyter notebook or qtconsole
if matplotlib_inline:
- shell.magic("matplotlib inline")
+ get_ipython().run_line_magic("matplotlib", "inline") # type: ignore
return True
- if shell == 'TerminalInteractiveShell':
+ if shell == "TerminalInteractiveShell":
# Terminal running IPython
return False
# Other type (?)
@@ -289,11 +812,25 @@ def _in_notebook(matplotlib_inline=False):
def _count_consecutive(data):
- """ Counts consecutive data (like cumsum() with reset on zeroes) """
+ """
+ Count consecutive occurrences in data (like cumsum() with reset on zeroes)
+
+ Parameters
+ ----------
+ data : pd.Series or pd.DataFrame
+ Input data to count consecutive occurrences
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Data with consecutive counts
+ """
+
def _count(data):
- return data * (data.groupby(
- (data != data.shift(1)).cumsum()).cumcount() + 1)
+ # Group by consecutive values and count occurrences
+ return data * (data.groupby((data != data.shift(1)).cumsum()).cumcount() + 1)
+ # Handle DataFrame by processing each column
if isinstance(data, _pd.DataFrame):
for col in data.columns:
data[col] = _count(data[col])
@@ -302,35 +839,134 @@ def _count(data):
def _score_str(val):
- """ Returns + sign for positive values (used in plots) """
+ """
+ Format value string with appropriate sign (used in plots)
+
+ Parameters
+ ----------
+ val : str or numeric
+ Value to format
+
+ Returns
+ -------
+ str
+ Formatted string with + or - sign
+ """
+ # Add + sign for positive values, - is already included for negative
return ("" if "-" in val else "+") + str(val)
-def make_portfolio(returns, start_balance=1e5,
- mode="comp", round_to=None):
- """ Calculates compounded value of portfolio """
+def make_index(
+ ticker_weights, rebalance="1ME", period="max", returns=None, match_dates=False
+):
+ """
+ Makes an index out of the given tickers and weights.
+ Optionally you can pass a dataframe with the returns.
+ If returns is not given it try to download them with yfinance
+
+ Args:
+ * ticker_weights (Dict): A python dict with tickers as keys
+ and weights as values
+ * rebalance: Pandas resample interval or None for never
+ * period: time period of the returns to be downloaded
+ * returns (Series, DataFrame): Optional. Returns If provided,
+ it will fist check if returns for the given ticker are in
+ this dataframe, if not it will try to download them with
+ yfinance
+ Returns:
+ * index_returns (Series, DataFrame): Returns for the index
+ """
+ # Declare a returns variable
+ index = None
+ portfolio = {}
+
+ # Iterate over weights and get returns for each ticker
+ for ticker in ticker_weights.keys():
+ if (returns is None) or (ticker not in returns.columns):
+ # Download the returns for this ticker, e.g. GOOG
+ ticker_returns = download_returns(ticker, period)
+ else:
+ ticker_returns = returns[ticker]
+
+ portfolio[ticker] = ticker_returns
+
+ # Create index members time-series
+ index = _pd.DataFrame(portfolio).dropna()
+
+ # Match dates to start from first non-zero date
+ if match_dates:
+ index = index[max(index.ne(0).idxmax()):]
+
+ # Handle case with no rebalancing
+ if rebalance is None:
+ # Apply weights directly to returns
+ for ticker, weight in ticker_weights.items():
+ index[ticker] = weight * index[ticker]
+ return index.sum(axis=1)
+
+ last_day = index.index[-1]
+
+ # Apply weights to each ticker's returns
+ # For a weighted portfolio, each day's portfolio return is sum of (weight * asset_return)
+ for ticker, weight in ticker_weights.items():
+ index[ticker] = weight * index[ticker]
+
+ # Calculate daily portfolio returns as sum of weighted asset returns
+ portfolio_returns = index.sum(axis=1)
+
+ # Remove rows where all values are NaN
+ portfolio_returns = portfolio_returns.dropna()
+ return portfolio_returns[portfolio_returns.index <= last_day]
+
+
+def make_portfolio(returns, start_balance=1e5, mode="comp", round_to=None):
+ """
+ Calculate compounded value of portfolio from returns
+
+ Parameters
+ ----------
+ returns : pd.Series or pd.DataFrame
+ Returns data
+ start_balance : float, default 1e5
+ Starting portfolio balance
+ mode : str, default "comp"
+ Calculation mode ("comp", "cumsum", "sum", or other)
+ round_to : int, optional
+ Number of decimal places to round to
+
+ Returns
+ -------
+ pd.Series or pd.DataFrame
+ Portfolio values over time
+ """
returns = _prepare_returns(returns)
+ # Calculate portfolio values based on mode
if mode.lower() in ["cumsum", "sum"]:
+ # Simple cumulative sum approach
p1 = start_balance + start_balance * returns.cumsum()
elif mode.lower() in ["compsum", "comp"]:
+ # Compounded returns approach
p1 = to_prices(returns, start_balance)
else:
- # fixed amount every day
- comp_rev = (start_balance + start_balance *
- returns.shift(1)).fillna(start_balance) * returns
+ # Fixed amount every day approach
+ comp_rev = (start_balance + start_balance * returns.shift(1)).fillna(
+ start_balance
+ ) * returns
p1 = start_balance + comp_rev.cumsum()
- # add day before with starting balance
- p0 = _pd.Series(data=start_balance,
- index=p1.index + _pd.Timedelta(days=-1))[:1]
+ # Add day before with starting balance
+ p0 = _pd.Series(data=start_balance, index=p1.index + _pd.Timedelta(days=-1))[:1]
- portfolio = _pd.concat([p0, p1])
+ # Combine starting balance with portfolio values
+ portfolio = safe_concat([p0, p1])
+ # Handle DataFrame case
if isinstance(returns, _pd.DataFrame):
- portfolio.loc[:1, :] = start_balance
+ portfolio.iloc[:1, :] = start_balance
portfolio.drop(columns=[0], inplace=True)
+ # Round if requested
if round_to:
portfolio = _np.round(portfolio, round_to)
@@ -338,11 +974,27 @@ def make_portfolio(returns, start_balance=1e5,
def _flatten_dataframe(df, set_index=None):
- """ Dirty method for flattening multi-index dataframe """
+ """
+ Flatten multi-index dataframe using CSV conversion method
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ Multi-index dataframe to flatten
+ set_index : str, optional
+ Column to use as index after flattening
+
+ Returns
+ -------
+ pd.DataFrame
+ Flattened dataframe
+ """
+ # Use string buffer to convert to CSV and back to flatten structure
s_buf = _io.StringIO()
df.to_csv(s_buf)
s_buf.seek(0)
+ # Read back from CSV to get flattened structure
df = _pd.read_csv(s_buf)
if set_index is not None:
df.set_index(set_index, inplace=True)
diff --git a/quantstats/version.py b/quantstats/version.py
new file mode 100644
index 00000000..968cfb8a
--- /dev/null
+++ b/quantstats/version.py
@@ -0,0 +1 @@
+version = "0.0.81"
diff --git a/requirements.txt b/requirements.txt
index 25ab8f59..a5e60f4c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,8 +1,10 @@
-pandas>=0.24.0
-numpy>=1.15.0
-seaborn>=0.9.0
-matplotlib>=3.0.0
-scipy>=1.2.0
-tabulate>=0.8.0
-yfinance>=0.1.44
-
+# Core dependencies - see pyproject.toml for full list
+# This file is kept for backwards compatibility with pip install -r
+pandas>=2.0.0
+numpy>=1.24.0
+scipy>=1.11.0
+matplotlib>=3.7.0
+seaborn>=0.13.0
+tabulate>=0.9.0
+yfinance>=0.2.40
+python-dateutil>=2.8.0
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 7a0c5025..00000000
--- a/setup.py
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: UTF-8 -*-
-
-"""QuantStats: Portfolio analytics for quants
-https://github.com/ranaroussi/quantstats
-QuantStats performs portfolio profiling, to allow quants and
-portfolio managers to understand their performance better,
-by providing them with in-depth analytics and risk metrics.
-"""
-
-from setuptools import setup, find_packages
-# from codecs import open
-import io
-from os import path
-
-here = path.abspath(path.dirname(__file__))
-
-# Get the long description from the README file
-with io.open(path.join(here, 'README.rst'), encoding='utf-8') as f:
- long_description = f.read()
-
-setup(
- name='QuantStats',
- version='0.0.24',
- description='Portfolio analytics for quants',
- long_description=long_description,
- url='https://github.com/ranaroussi/quantstats',
- author='Ran Aroussi',
- author_email='ran@aroussi.com',
- license='Apache Software License',
- classifiers=[
- 'License :: OSI Approved :: Apache Software License',
- # 'Development Status :: 1 - Planning',
- # 'Development Status :: 2 - Pre-Alpha',
- # 'Development Status :: 3 - Alpha',
- 'Development Status :: 4 - Beta',
- # 'Development Status :: 5 - Production/Stable',
-
- 'Operating System :: OS Independent',
-
- 'Intended Audience :: Developers',
- 'Intended Audience :: Financial and Insurance Industry',
- 'Intended Audience :: Science/Research',
-
- 'Topic :: Office/Business :: Financial',
- 'Topic :: Office/Business :: Financial :: Investment',
- 'Topic :: Software Development :: Libraries',
- 'Topic :: Software Development :: Libraries :: Python Modules',
- 'Topic :: Scientific/Engineering',
- 'Topic :: Scientific/Engineering :: Information Analysis',
- 'Topic :: Scientific/Engineering :: Mathematics',
-
- # 'Programming Language :: Python :: 3.5',
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.7',
- ],
-
- platforms=['any'],
- keywords="""quant algotrading algorithmic-trading quantitative-trading
- quantitative-analysis algo-trading visualization plotting""",
- packages=find_packages(exclude=['contrib', 'docs', 'tests', 'examples']),
- install_requires=['pandas>=0.24.0', 'numpy>=1.15.0', 'scipy>=1.2.0',
- 'matplotlib>=3.0.0', 'seaborn>=0.9.0',
- 'tabulate>=0.8.0', 'yfinance>=0.1.44'],
- entry_points={
- 'console_scripts': [
- 'sample=sample:main',
- ],
- },
-
- include_package_data=True,
- # package_data={
- # 'static': 'quantstats/report.html*'
- # },
-)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..aff3341e
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# QuantStats test suite
diff --git a/tests/test_compat.py b/tests/test_compat.py
new file mode 100644
index 00000000..7ec809b5
--- /dev/null
+++ b/tests/test_compat.py
@@ -0,0 +1,133 @@
+"""
+Tests for quantstats._compat module
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+
+from quantstats._compat import (
+ get_frequency_alias,
+ normalize_timezone,
+ safe_resample,
+ safe_concat,
+ safe_append,
+)
+
+
+@pytest.fixture
+def sample_series():
+ """Generate sample time series data."""
+ dates = pd.date_range("2020-01-01", periods=100, freq="D")
+ return pd.Series(np.random.randn(100), index=dates)
+
+
+@pytest.fixture
+def sample_series_tz():
+ """Generate sample time series with timezone."""
+ dates = pd.date_range("2020-01-01", periods=100, freq="D", tz="US/Eastern")
+ return pd.Series(np.random.randn(100), index=dates)
+
+
+class TestFrequencyAlias:
+ """Test frequency alias compatibility."""
+
+ def test_monthly_alias(self):
+ """Test monthly frequency alias."""
+ result = get_frequency_alias("M")
+ assert result in ["M", "ME"] # Depends on pandas version
+
+ def test_quarterly_alias(self):
+ """Test quarterly frequency alias."""
+ result = get_frequency_alias("Q")
+ assert result in ["Q", "QE"]
+
+ def test_yearly_alias(self):
+ """Test yearly frequency alias."""
+ result = get_frequency_alias("Y")
+ assert result in ["Y", "YE"]
+
+ def test_daily_unchanged(self):
+ """Test daily frequency unchanged."""
+ result = get_frequency_alias("D")
+ assert result == "D"
+
+
+class TestNormalizeTimezone:
+ """Test timezone normalization."""
+
+ def test_normalize_tz_aware(self, sample_series_tz):
+ """Test normalizing timezone-aware series."""
+ result = normalize_timezone(sample_series_tz)
+ assert result.index.tz is None
+
+ def test_normalize_tz_naive(self, sample_series):
+ """Test that tz-naive series passes through."""
+ result = normalize_timezone(sample_series)
+ assert result.index.tz is None
+ pd.testing.assert_series_equal(result, sample_series)
+
+
+class TestSafeResample:
+ """Test safe resample function."""
+
+ def test_resample_sum(self, sample_series):
+ """Test resample with sum aggregation."""
+ result = safe_resample(sample_series, "M", "sum")
+ assert len(result) < len(sample_series)
+
+ def test_resample_mean(self, sample_series):
+ """Test resample with mean aggregation."""
+ result = safe_resample(sample_series, "M", "mean")
+ assert len(result) < len(sample_series)
+
+ def test_resample_no_func(self, sample_series):
+ """Test resample without aggregation function."""
+ result = safe_resample(sample_series, "M", None)
+ # Should return resampler object
+ assert hasattr(result, "sum")
+
+
+class TestSafeConcat:
+ """Test safe concatenation."""
+
+ def test_concat_series(self, sample_series):
+ """Test concatenating series."""
+ s1 = sample_series[:50]
+ s2 = sample_series[50:]
+ result = safe_concat([s1, s2])
+ assert len(result) == len(sample_series)
+
+ def test_concat_dataframes(self, sample_series):
+ """Test concatenating DataFrames."""
+ df1 = pd.DataFrame({"A": sample_series[:50]})
+ df2 = pd.DataFrame({"A": sample_series[50:]})
+ result = safe_concat([df1, df2])
+ assert len(result) == len(sample_series)
+
+ def test_concat_axis1(self, sample_series):
+ """Test concatenating along axis 1."""
+ s1 = sample_series.copy()
+ s1.name = "A"
+ s2 = sample_series.copy()
+ s2.name = "B"
+ result = safe_concat([s1, s2], axis=1)
+ assert result.shape[1] == 2
+
+
+class TestSafeAppend:
+ """Test safe append function."""
+
+ def test_append_dataframes(self, sample_series):
+ """Test appending DataFrames."""
+ df1 = pd.DataFrame({"A": sample_series[:50]})
+ df2 = pd.DataFrame({"A": sample_series[50:]})
+ result = safe_append(df1, df2)
+ assert len(result) == len(sample_series)
+
+ def test_append_ignore_index(self, sample_series):
+ """Test append with ignore_index."""
+ df1 = pd.DataFrame({"A": [1, 2, 3]})
+ df2 = pd.DataFrame({"A": [4, 5, 6]})
+ result = safe_append(df1, df2, ignore_index=True)
+ assert list(result.index) == [0, 1, 2, 3, 4, 5]
diff --git a/tests/test_extend_pandas.py b/tests/test_extend_pandas.py
new file mode 100644
index 00000000..a0a12085
--- /dev/null
+++ b/tests/test_extend_pandas.py
@@ -0,0 +1,89 @@
+"""
+Tests for quantstats.extend_pandas functionality
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+
+import quantstats as qs
+
+
+@pytest.fixture
+def sample_returns():
+ """Generate sample returns as a pandas Series."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=252, freq="D")
+ returns = pd.Series(np.random.randn(252) * 0.02, index=dates, name="Strategy")
+ return returns
+
+
+class TestExtendPandas:
+ """Test extend_pandas functionality."""
+
+ def test_extend_pandas_adds_methods(self, sample_returns):
+ """Test that extend_pandas adds methods to Series."""
+ qs.extend_pandas()
+
+ # Check that quantstats methods are now available on Series
+ assert hasattr(sample_returns, "sharpe")
+ assert hasattr(sample_returns, "sortino")
+ assert hasattr(sample_returns, "max_drawdown")
+ assert hasattr(sample_returns, "cagr")
+
+ def test_sharpe_via_pandas(self, sample_returns):
+ """Test Sharpe ratio via pandas extension."""
+ qs.extend_pandas()
+ result = sample_returns.sharpe()
+ assert np.isfinite(result)
+
+ def test_sortino_via_pandas(self, sample_returns):
+ """Test Sortino ratio via pandas extension."""
+ qs.extend_pandas()
+ result = sample_returns.sortino()
+ assert np.isfinite(result)
+
+ def test_max_drawdown_via_pandas(self, sample_returns):
+ """Test max drawdown via pandas extension."""
+ qs.extend_pandas()
+ result = sample_returns.max_drawdown()
+ assert result <= 0
+
+ def test_cagr_via_pandas(self, sample_returns):
+ """Test CAGR via pandas extension."""
+ qs.extend_pandas()
+ result = sample_returns.cagr()
+ assert np.isfinite(result)
+
+ def test_volatility_via_pandas(self, sample_returns):
+ """Test volatility via pandas extension."""
+ qs.extend_pandas()
+ result = sample_returns.volatility()
+ assert result > 0
+
+ def test_calmar_via_pandas(self, sample_returns):
+ """Test Calmar ratio via pandas extension."""
+ qs.extend_pandas()
+ result = sample_returns.calmar()
+ assert np.isfinite(result)
+
+
+class TestExtendPandasWithParams:
+ """Test extend_pandas with parameters."""
+
+ def test_sharpe_with_rf(self, sample_returns):
+ """Test Sharpe with risk-free rate via pandas."""
+ qs.extend_pandas()
+ result_no_rf = sample_returns.sharpe(rf=0)
+ result_with_rf = sample_returns.sharpe(rf=0.02)
+ # Should be different
+ assert result_no_rf != result_with_rf
+
+ def test_cagr_compounded(self, sample_returns):
+ """Test CAGR with compounded option via pandas."""
+ qs.extend_pandas()
+ result_comp = sample_returns.cagr(compounded=True)
+ result_simple = sample_returns.cagr(compounded=False)
+ # May or may not be different depending on returns
+ assert np.isfinite(result_comp)
+ assert np.isfinite(result_simple)
diff --git a/tests/test_montecarlo.py b/tests/test_montecarlo.py
new file mode 100644
index 00000000..24a6147a
--- /dev/null
+++ b/tests/test_montecarlo.py
@@ -0,0 +1,197 @@
+"""
+Tests for quantstats Monte Carlo simulation functionality
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+
+import quantstats as qs
+from quantstats import stats
+from quantstats._montecarlo import MonteCarloResult, run_montecarlo
+
+
+@pytest.fixture
+def sample_returns():
+ """Generate sample daily returns for testing."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=252, freq="D")
+ returns = pd.Series(np.random.randn(252) * 0.02, index=dates, name="Strategy")
+ return returns
+
+
+class TestMonteCarloResult:
+ """Test MonteCarloResult dataclass."""
+
+ def test_stats_property(self, sample_returns):
+ """Test stats property returns correct keys."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+ stats_dict = mc.stats
+
+ assert "min" in stats_dict
+ assert "max" in stats_dict
+ assert "mean" in stats_dict
+ assert "median" in stats_dict
+ assert "std" in stats_dict
+ assert "percentile_5" in stats_dict
+ assert "percentile_95" in stats_dict
+
+ def test_maxdd_property(self, sample_returns):
+ """Test maxdd property returns correct keys."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+ maxdd_dict = mc.maxdd
+
+ assert "min" in maxdd_dict
+ assert "max" in maxdd_dict
+ assert "mean" in maxdd_dict
+ assert "median" in maxdd_dict
+ # Max drawdown should be negative
+ assert maxdd_dict["min"] < 0
+ assert maxdd_dict["max"] <= 0
+
+ def test_bust_probability(self, sample_returns):
+ """Test bust probability calculation."""
+ mc = run_montecarlo(sample_returns, sims=100, bust=-0.05, seed=42)
+
+ assert mc.bust_probability is not None
+ assert 0 <= mc.bust_probability <= 1
+
+ def test_bust_probability_none_without_threshold(self, sample_returns):
+ """Test bust probability is None when no threshold set."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+ assert mc.bust_probability is None
+
+ def test_goal_probability(self, sample_returns):
+ """Test goal probability calculation."""
+ mc = run_montecarlo(sample_returns, sims=100, goal=0.1, seed=42)
+
+ assert mc.goal_probability is not None
+ assert 0 <= mc.goal_probability <= 1
+
+ def test_goal_probability_none_without_threshold(self, sample_returns):
+ """Test goal probability is None when no threshold set."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+ assert mc.goal_probability is None
+
+ def test_percentile(self, sample_returns):
+ """Test percentile method."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+
+ p50 = mc.percentile(50)
+ assert isinstance(p50, pd.Series)
+ assert len(p50) == len(sample_returns)
+
+ def test_confidence_band(self, sample_returns):
+ """Test confidence band method."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+
+ lower, upper = mc.confidence_band(0.95)
+ assert isinstance(lower, pd.Series)
+ assert isinstance(upper, pd.Series)
+ # Lower should be less than upper at all points
+ assert (lower <= upper).all()
+
+
+class TestRunMontecarlo:
+ """Test run_montecarlo function."""
+
+ def test_basic_simulation(self, sample_returns):
+ """Test basic Monte Carlo simulation."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+
+ assert isinstance(mc, MonteCarloResult)
+ assert mc.data.shape[1] == 100 # 100 simulations
+ assert len(mc.original) == len(sample_returns)
+
+ def test_reproducibility_with_seed(self, sample_returns):
+ """Test that seed produces reproducible results."""
+ mc1 = run_montecarlo(sample_returns, sims=50, seed=123)
+ mc2 = run_montecarlo(sample_returns, sims=50, seed=123)
+
+ pd.testing.assert_frame_equal(mc1.data, mc2.data)
+
+ def test_different_seeds_produce_different_results(self, sample_returns):
+ """Test that different seeds produce different results."""
+ mc1 = run_montecarlo(sample_returns, sims=50, seed=123)
+ mc2 = run_montecarlo(sample_returns, sims=50, seed=456)
+
+ # Results should be different
+ assert not mc1.data.equals(mc2.data)
+
+ def test_first_column_is_original(self, sample_returns):
+ """Test that first simulation column matches original."""
+ mc = run_montecarlo(sample_returns, sims=100, seed=42)
+
+ # First column should be the original (unshuffled) returns compounded
+ pd.testing.assert_series_equal(
+ mc.data.iloc[:, 0], mc.original, check_names=False
+ )
+
+ def test_handles_nan_values(self):
+ """Test that NaN values are handled properly."""
+ returns = pd.Series([0.01, np.nan, -0.02, 0.03, np.nan, 0.01])
+ mc = run_montecarlo(returns, sims=50, seed=42)
+
+ # Should have 4 periods (NaN dropped)
+ assert mc.data.shape[0] == 4
+
+
+class TestStatsMonteCarlo:
+ """Test Monte Carlo functions exposed via stats module."""
+
+ def test_montecarlo_function(self, sample_returns):
+ """Test stats.montecarlo function."""
+ mc = stats.montecarlo(sample_returns, sims=100, seed=42)
+
+ assert isinstance(mc, MonteCarloResult)
+ assert mc.data.shape[1] == 100
+
+ def test_montecarlo_with_bust_and_goal(self, sample_returns):
+ """Test stats.montecarlo with bust and goal thresholds."""
+ mc = stats.montecarlo(
+ sample_returns, sims=100, bust=-0.1, goal=0.5, seed=42
+ )
+
+ assert mc.bust_threshold == -0.1
+ assert mc.goal_threshold == 0.5
+ assert mc.bust_probability is not None
+ assert mc.goal_probability is not None
+
+
+class TestMonteCarloEdgeCases:
+ """Test edge cases for Monte Carlo simulations."""
+
+ def test_single_simulation(self, sample_returns):
+ """Test with single simulation."""
+ mc = run_montecarlo(sample_returns, sims=1, seed=42)
+
+ assert mc.data.shape[1] == 1
+
+ def test_large_number_of_simulations(self, sample_returns):
+ """Test with large number of simulations."""
+ mc = run_montecarlo(sample_returns, sims=1000, seed=42)
+
+ assert mc.data.shape[1] == 1000
+
+ def test_short_return_series(self):
+ """Test with short return series."""
+ returns = pd.Series([0.01, -0.02, 0.03])
+ mc = run_montecarlo(returns, sims=50, seed=42)
+
+ assert mc.data.shape[0] == 3
+
+ def test_all_positive_returns(self):
+ """Test with all positive returns."""
+ returns = pd.Series([0.01, 0.02, 0.01, 0.03, 0.02])
+ mc = run_montecarlo(returns, sims=50, seed=42)
+
+ # Terminal values should all be positive
+ assert (mc.data.iloc[-1] > 0).all()
+
+ def test_all_negative_returns(self):
+ """Test with all negative returns."""
+ returns = pd.Series([-0.01, -0.02, -0.01, -0.03, -0.02])
+ mc = run_montecarlo(returns, sims=50, seed=42)
+
+ # Terminal values should all be negative
+ assert (mc.data.iloc[-1] < 0).all()
diff --git a/tests/test_plots.py b/tests/test_plots.py
new file mode 100644
index 00000000..8fa5b509
--- /dev/null
+++ b/tests/test_plots.py
@@ -0,0 +1,157 @@
+"""
+Tests for quantstats.plots module
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+import tempfile
+import os
+
+import quantstats as qs
+from quantstats import plots
+
+
+@pytest.fixture
+def sample_returns():
+ """Generate sample daily returns for testing."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=500, freq="D")
+ returns = pd.Series(np.random.randn(500) * 0.02, index=dates, name="Strategy")
+ return returns
+
+
+@pytest.fixture
+def sample_benchmark():
+ """Generate sample benchmark returns for testing."""
+ np.random.seed(123)
+ dates = pd.date_range("2020-01-01", periods=500, freq="D")
+ returns = pd.Series(np.random.randn(500) * 0.015, index=dates, name="Benchmark")
+ return returns
+
+
+class TestPlotFunctions:
+ """Test that plot functions run without errors."""
+
+ def test_snapshot(self, sample_returns):
+ """Test snapshot plot generates without error."""
+ # snapshot should work with show=False
+ fig = plots.snapshot(sample_returns, show=False)
+ assert fig is not None
+
+ def test_returns_plot(self, sample_returns, sample_benchmark):
+ """Test returns plot."""
+ fig = plots.returns(sample_returns, sample_benchmark, show=False)
+ assert fig is not None
+
+ def test_log_returns_plot(self, sample_returns):
+ """Test log returns plot."""
+ fig = plots.log_returns(sample_returns, show=False)
+ assert fig is not None
+
+ def test_yearly_returns(self, sample_returns):
+ """Test yearly returns bar chart."""
+ fig = plots.yearly_returns(sample_returns, show=False)
+ assert fig is not None
+
+ def test_histogram(self, sample_returns, sample_benchmark):
+ """Test histogram plot."""
+ fig = plots.histogram(sample_returns, sample_benchmark, show=False)
+ assert fig is not None
+
+ def test_daily_returns(self, sample_returns, sample_benchmark):
+ """Test daily returns scatter plot."""
+ fig = plots.daily_returns(sample_returns, sample_benchmark, show=False)
+ assert fig is not None
+
+ def test_drawdown(self, sample_returns):
+ """Test drawdown plot."""
+ fig = plots.drawdown(sample_returns, show=False)
+ assert fig is not None
+
+ def test_drawdowns_periods(self, sample_returns):
+ """Test drawdown periods plot."""
+ fig = plots.drawdowns_periods(sample_returns, show=False)
+ assert fig is not None
+
+ def test_rolling_volatility(self, sample_returns):
+ """Test rolling volatility plot."""
+ fig = plots.rolling_volatility(sample_returns, show=False)
+ assert fig is not None
+
+ def test_rolling_sharpe(self, sample_returns):
+ """Test rolling Sharpe plot."""
+ fig = plots.rolling_sharpe(sample_returns, show=False)
+ assert fig is not None
+
+ def test_rolling_sortino(self, sample_returns):
+ """Test rolling Sortino plot."""
+ fig = plots.rolling_sortino(sample_returns, show=False)
+ assert fig is not None
+
+ def test_rolling_beta(self, sample_returns, sample_benchmark):
+ """Test rolling beta plot."""
+ fig = plots.rolling_beta(sample_returns, sample_benchmark, show=False)
+ assert fig is not None
+
+ def test_monthly_heatmap(self, sample_returns):
+ """Test monthly heatmap."""
+ fig = plots.monthly_heatmap(sample_returns, show=False)
+ assert fig is not None
+
+ def test_distribution(self, sample_returns):
+ """Test distribution box plot."""
+ fig = plots.distribution(sample_returns, show=False)
+ assert fig is not None
+
+
+class TestPlotSaving:
+ """Test plot saving functionality."""
+
+ def test_save_to_file(self, sample_returns):
+ """Test saving plot to file."""
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
+ output_path = f.name
+
+ try:
+ plots.returns(sample_returns, savefig=output_path, show=False)
+ assert os.path.exists(output_path)
+ assert os.path.getsize(output_path) > 0
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_save_with_dict_params(self, sample_returns):
+ """Test saving plot with dict parameters."""
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
+ output_path = f.name
+
+ try:
+ plots.returns(
+ sample_returns,
+ savefig={"fname": output_path, "dpi": 100},
+ show=False,
+ )
+ assert os.path.exists(output_path)
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+
+class TestPlotOptions:
+ """Test plot configuration options."""
+
+ def test_grayscale_mode(self, sample_returns):
+ """Test grayscale plotting mode."""
+ fig = plots.returns(sample_returns, grayscale=True, show=False)
+ assert fig is not None
+
+ def test_custom_figsize(self, sample_returns):
+ """Test custom figure size."""
+ fig = plots.returns(sample_returns, figsize=(12, 8), show=False)
+ assert fig is not None
+
+ def test_no_subtitle(self, sample_returns):
+ """Test plot without subtitle."""
+ fig = plots.returns(sample_returns, subtitle=False, show=False)
+ assert fig is not None
diff --git a/tests/test_reports.py b/tests/test_reports.py
new file mode 100644
index 00000000..e03bcc97
--- /dev/null
+++ b/tests/test_reports.py
@@ -0,0 +1,256 @@
+"""
+Tests for quantstats.reports module
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+import tempfile
+import os
+
+import quantstats as qs
+from quantstats import reports
+
+
+@pytest.fixture
+def sample_returns():
+ """Generate sample daily returns for testing."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=500, freq="D")
+ returns = pd.Series(np.random.randn(500) * 0.02, index=dates, name="Strategy")
+ return returns
+
+
+@pytest.fixture
+def sample_benchmark():
+ """Generate sample benchmark returns for testing."""
+ np.random.seed(123)
+ dates = pd.date_range("2020-01-01", periods=500, freq="D")
+ returns = pd.Series(np.random.randn(500) * 0.015, index=dates, name="Benchmark")
+ return returns
+
+
+class TestHTMLReport:
+ """Test HTML report generation."""
+
+ def test_html_generates_file(self, sample_returns, sample_benchmark):
+ """Test that HTML report generates a file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(sample_returns, sample_benchmark, output=output_path)
+ assert os.path.exists(output_path)
+ # Check file has content
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert len(content) > 1000 # Should have substantial content
+ assert "" in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_html_contains_title(self, sample_returns):
+ """Test that HTML report contains proper title."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(sample_returns, output=output_path, title="My Strategy")
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert "My Strategy" in content
+ assert "(Compounded)" in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_html_simple_returns_title(self, sample_returns):
+ """Test title does NOT show (Compounded) when compounded=False."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(sample_returns, output=output_path, compounded=False)
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert "(Compounded)" not in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_html_contains_benchmark_param(self, sample_returns, sample_benchmark):
+ """Test that HTML report shows benchmark in parameters."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(sample_returns, sample_benchmark, output=output_path)
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert "Benchmark:" in content
+ assert "Periods/Year: 252" in content
+ assert "RF:" in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_html_matched_dates_indicator(self, sample_returns, sample_benchmark):
+ """Test matched dates indicator appears when appropriate."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(
+ sample_returns, sample_benchmark, output=output_path, match_dates=True
+ )
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert "(matched dates)" in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_html_no_dark_mode(self, sample_returns):
+ """Test that dark mode CSS is not present."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(sample_returns, output=output_path)
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert "prefers-color-scheme:dark" not in content
+ assert "color-scheme" not in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_html_custom_rf(self, sample_returns):
+ """Test custom risk-free rate in parameters."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(sample_returns, output=output_path, rf=0.05)
+ with open(output_path, "r") as f:
+ content = f.read()
+ assert "RF: 5.0%" in content
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+
+class TestMetrics:
+ """Test metrics function."""
+
+ def test_metrics_basic_mode(self, sample_returns):
+ """Test metrics in basic mode."""
+ result = reports.metrics(sample_returns, display=False, mode="basic")
+ assert isinstance(result, pd.DataFrame)
+ assert len(result) > 0
+
+ def test_metrics_full_mode(self, sample_returns):
+ """Test metrics in full mode."""
+ result = reports.metrics(sample_returns, display=False, mode="full")
+ assert isinstance(result, pd.DataFrame)
+ # Full mode should have more metrics than basic
+ basic_result = reports.metrics(sample_returns, display=False, mode="basic")
+ assert len(result) >= len(basic_result)
+
+ def test_metrics_with_benchmark(self, sample_returns, sample_benchmark):
+ """Test metrics with benchmark comparison."""
+ result = reports.metrics(
+ sample_returns, benchmark=sample_benchmark, display=False
+ )
+ assert isinstance(result, pd.DataFrame)
+ # Should have at least two columns (strategy + benchmark)
+ assert result.shape[1] >= 2
+
+ def test_metrics_with_rf(self, sample_returns):
+ """Test metrics with non-zero risk-free rate."""
+ result_no_rf = reports.metrics(sample_returns, rf=0, display=False)
+ result_with_rf = reports.metrics(sample_returns, rf=0.02, display=False)
+ # Results should be different
+ assert not result_no_rf.equals(result_with_rf)
+
+
+class TestMatchDates:
+ """Test date matching functionality."""
+
+ def test_match_dates_aligns_series(self):
+ """Test that _match_dates aligns returns and benchmark."""
+ # Create returns starting later than benchmark
+ dates1 = pd.date_range("2020-01-10", periods=100, freq="D")
+ dates2 = pd.date_range("2020-01-01", periods=110, freq="D")
+
+ returns = pd.Series(np.random.randn(100) * 0.01, index=dates1)
+ benchmark = pd.Series(np.random.randn(110) * 0.01, index=dates2)
+
+ # Set first values to non-zero
+ returns.iloc[0] = 0.01
+ benchmark.iloc[0] = 0.01
+
+ aligned_ret, aligned_bench = reports._match_dates(returns, benchmark)
+
+ # Both should now start from the same date
+ assert aligned_ret.index[0] == aligned_bench.index[0]
+
+
+class TestParametersTable:
+ """Test parameters table printing."""
+
+ def test_print_parameters_table_with_benchmark(self, capsys):
+ """Test parameters table output with benchmark."""
+ reports._print_parameters_table(
+ benchmark_title="SPY",
+ periods_per_year=252,
+ rf=0.02,
+ compounded=True,
+ match_dates=True,
+ )
+ captured = capsys.readouterr()
+ assert "SPY" in captured.out
+ assert "252" in captured.out
+ assert "2.0%" in captured.out
+ assert "Yes" in captured.out
+
+ def test_print_parameters_table_no_benchmark(self, capsys):
+ """Test parameters table output without benchmark."""
+ reports._print_parameters_table(
+ benchmark_title=None,
+ periods_per_year=252,
+ rf=0.0,
+ compounded=False,
+ match_dates=True,
+ )
+ captured = capsys.readouterr()
+ assert "Benchmark" not in captured.out
+ assert "252" in captured.out
+ assert "No" in captured.out # Compounded = No
+
+
+class TestEdgeCases:
+ """Test edge cases in reports."""
+
+ def test_html_with_dataframe(self, sample_returns, sample_benchmark):
+ """Test HTML generation with DataFrame input."""
+ df = pd.DataFrame({"Strategy": sample_returns})
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ output_path = f.name
+
+ try:
+ reports.html(df, sample_benchmark, output=output_path)
+ assert os.path.exists(output_path)
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+
+ def test_metrics_empty_benchmark_title(self, sample_returns, sample_benchmark):
+ """Test metrics when benchmark has no name."""
+ benchmark_no_name = sample_benchmark.copy()
+ benchmark_no_name.name = None
+ result = reports.metrics(
+ sample_returns, benchmark=benchmark_no_name, display=False
+ )
+ assert isinstance(result, pd.DataFrame)
diff --git a/tests/test_stats.py b/tests/test_stats.py
new file mode 100644
index 00000000..5a22f857
--- /dev/null
+++ b/tests/test_stats.py
@@ -0,0 +1,273 @@
+"""
+Tests for quantstats.stats module
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+from datetime import datetime
+
+import quantstats as qs
+from quantstats import stats
+
+
+@pytest.fixture
+def sample_returns():
+ """Generate sample daily returns for testing."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=500, freq="D")
+ returns = pd.Series(np.random.randn(500) * 0.02, index=dates, name="Strategy")
+ return returns
+
+
+@pytest.fixture
+def sample_benchmark():
+ """Generate sample benchmark returns for testing."""
+ np.random.seed(123)
+ dates = pd.date_range("2020-01-01", periods=500, freq="D")
+ returns = pd.Series(np.random.randn(500) * 0.015, index=dates, name="Benchmark")
+ return returns
+
+
+@pytest.fixture
+def positive_returns():
+ """Generate strictly positive returns for testing edge cases."""
+ dates = pd.date_range("2020-01-01", periods=100, freq="D")
+ returns = pd.Series(np.abs(np.random.randn(100) * 0.01) + 0.001, index=dates)
+ return returns
+
+
+@pytest.fixture
+def negative_returns():
+ """Generate strictly negative returns for testing edge cases."""
+ dates = pd.date_range("2020-01-01", periods=100, freq="D")
+ returns = pd.Series(-np.abs(np.random.randn(100) * 0.01) - 0.001, index=dates)
+ return returns
+
+
+class TestBasicStats:
+ """Test basic statistical functions."""
+
+ def test_comp(self, sample_returns):
+ """Test compound returns calculation."""
+ result = stats.comp(sample_returns)
+ assert isinstance(result, float)
+ # Compound return should be finite
+ assert np.isfinite(result)
+
+ def test_compsum(self, sample_returns):
+ """Test cumulative compound returns."""
+ result = stats.compsum(sample_returns)
+ assert isinstance(result, pd.Series)
+ assert len(result) == len(sample_returns)
+ # Final value should match comp()
+ np.testing.assert_almost_equal(result.iloc[-1], stats.comp(sample_returns), decimal=10)
+
+ def test_exposure(self, sample_returns):
+ """Test exposure calculation."""
+ result = stats.exposure(sample_returns)
+ assert 0 <= result <= 1
+
+ def test_win_rate(self, sample_returns):
+ """Test win rate calculation."""
+ result = stats.win_rate(sample_returns)
+ assert 0 <= result <= 1
+
+ def test_win_rate_all_positive(self, positive_returns):
+ """Test win rate with all positive returns."""
+ result = stats.win_rate(positive_returns)
+ assert result == 1.0
+
+ def test_win_rate_all_negative(self, negative_returns):
+ """Test win rate with all negative returns."""
+ result = stats.win_rate(negative_returns)
+ assert result == 0.0
+
+
+class TestRiskMetrics:
+ """Test risk-related metrics."""
+
+ def test_volatility(self, sample_returns):
+ """Test volatility calculation."""
+ result = stats.volatility(sample_returns)
+ assert result > 0
+ assert np.isfinite(result)
+
+ def test_volatility_annualized(self, sample_returns):
+ """Test annualized volatility."""
+ daily_vol = stats.volatility(sample_returns, annualize=False)
+ annual_vol = stats.volatility(sample_returns, annualize=True)
+ # Annualized should be approximately sqrt(252) times daily
+ expected_ratio = np.sqrt(252)
+ actual_ratio = annual_vol / daily_vol
+ np.testing.assert_almost_equal(actual_ratio, expected_ratio, decimal=5)
+
+ def test_max_drawdown(self, sample_returns):
+ """Test maximum drawdown calculation."""
+ result = stats.max_drawdown(sample_returns)
+ assert result <= 0 # Drawdown is always negative or zero
+ assert result >= -1 # Cannot lose more than 100%
+
+ def test_var(self, sample_returns):
+ """Test Value at Risk calculation."""
+ result = stats.var(sample_returns)
+ assert result < 0 # VaR is typically negative (loss)
+
+ def test_cvar(self, sample_returns):
+ """Test Conditional Value at Risk (Expected Shortfall)."""
+ var = stats.var(sample_returns)
+ cvar = stats.cvar(sample_returns)
+ # CVaR should be more extreme than VaR
+ assert cvar <= var
+
+
+class TestRatios:
+ """Test risk-adjusted return ratios."""
+
+ def test_sharpe(self, sample_returns):
+ """Test Sharpe ratio calculation."""
+ result = stats.sharpe(sample_returns)
+ assert np.isfinite(result)
+
+ def test_sharpe_with_rf(self, sample_returns):
+ """Test Sharpe ratio with risk-free rate."""
+ result_no_rf = stats.sharpe(sample_returns, rf=0)
+ result_with_rf = stats.sharpe(sample_returns, rf=0.02)
+ # Handle both scalar and Series results
+ if isinstance(result_no_rf, pd.Series):
+ result_no_rf = result_no_rf.iloc[0]
+ result_with_rf = result_with_rf.iloc[0]
+ # Higher rf should lower Sharpe ratio
+ assert result_with_rf < result_no_rf
+
+ def test_sortino(self, sample_returns):
+ """Test Sortino ratio calculation."""
+ result = stats.sortino(sample_returns)
+ # Handle both scalar and Series results
+ if isinstance(result, pd.Series):
+ result = result.iloc[0]
+ assert np.isfinite(result)
+
+ def test_sortino_vs_sharpe(self, sample_returns):
+ """Test that Sortino uses downside deviation."""
+ sharpe = stats.sharpe(sample_returns)
+ sortino = stats.sortino(sample_returns)
+ # Handle both scalar and Series results
+ if hasattr(sharpe, 'values'):
+ sharpe = float(sharpe.values[0]) if len(sharpe) > 0 else float(sharpe)
+ if hasattr(sortino, 'values'):
+ sortino = float(sortino.values[0]) if len(sortino) > 0 else float(sortino)
+ # They should be different (Sortino only penalizes downside)
+ assert sharpe != sortino
+
+ def test_calmar(self, sample_returns):
+ """Test Calmar ratio calculation."""
+ result = stats.calmar(sample_returns)
+ assert np.isfinite(result)
+
+ def test_omega(self, sample_returns):
+ """Test Omega ratio calculation."""
+ result = stats.omega(sample_returns)
+ assert result > 0 # Omega is always positive
+
+ def test_cagr(self, sample_returns):
+ """Test CAGR calculation."""
+ result = stats.cagr(sample_returns)
+ assert np.isfinite(result)
+
+
+class TestBenchmarkComparison:
+ """Test benchmark comparison functions."""
+
+ def test_greeks(self, sample_returns, sample_benchmark):
+ """Test alpha/beta calculation."""
+ result = stats.greeks(sample_returns, sample_benchmark)
+ assert "alpha" in result
+ assert "beta" in result
+ assert np.isfinite(result["alpha"])
+ assert np.isfinite(result["beta"])
+
+ def test_r_squared(self, sample_returns, sample_benchmark):
+ """Test R-squared calculation."""
+ result = stats.r_squared(sample_returns, sample_benchmark)
+ assert 0 <= result <= 1
+
+ def test_information_ratio(self, sample_returns, sample_benchmark):
+ """Test Information Ratio calculation."""
+ result = stats.information_ratio(sample_returns, sample_benchmark)
+ assert np.isfinite(result)
+
+ def test_treynor_ratio(self, sample_returns, sample_benchmark):
+ """Test Treynor Ratio calculation."""
+ result = stats.treynor_ratio(sample_returns, sample_benchmark)
+ assert np.isfinite(result)
+
+
+class TestDrawdown:
+ """Test drawdown-related functions."""
+
+ def test_to_drawdown_series(self, sample_returns):
+ """Test drawdown series conversion."""
+ result = stats.to_drawdown_series(sample_returns)
+ assert isinstance(result, pd.Series)
+ assert len(result) == len(sample_returns)
+ # All values should be <= 0 (drawdowns are negative)
+ assert (result <= 0).all()
+
+ def test_drawdown_details(self, sample_returns):
+ """Test drawdown details extraction."""
+ dd = stats.to_drawdown_series(sample_returns)
+ result = stats.drawdown_details(dd)
+ assert isinstance(result, pd.DataFrame)
+ # Should have standard columns
+ assert "start" in result.columns
+ assert "end" in result.columns
+ assert "max drawdown" in result.columns
+
+
+class TestConsecutive:
+ """Test consecutive wins/losses functions."""
+
+ def test_consecutive_wins(self, sample_returns):
+ """Test consecutive wins calculation."""
+ result = stats.consecutive_wins(sample_returns)
+ assert isinstance(result, (int, np.integer))
+ assert result >= 0
+
+ def test_consecutive_losses(self, sample_returns):
+ """Test consecutive losses calculation."""
+ result = stats.consecutive_losses(sample_returns)
+ assert isinstance(result, (int, np.integer))
+ assert result >= 0
+
+
+class TestEdgeCases:
+ """Test edge cases and error handling."""
+
+ def test_empty_returns(self):
+ """Test handling of empty returns."""
+ empty = pd.Series([], dtype=float)
+ with pytest.raises(Exception):
+ stats.sharpe(empty)
+
+ def test_single_return(self):
+ """Test handling of single return value."""
+ single = pd.Series([0.01], index=pd.date_range("2020-01-01", periods=1))
+ # Should not raise error
+ result = stats.comp(single)
+ assert np.isfinite(result)
+
+ def test_nan_handling(self, sample_returns):
+ """Test that NaN values are handled properly."""
+ returns_with_nan = sample_returns.copy()
+ returns_with_nan.iloc[10:20] = np.nan
+ # Should still compute without error
+ result = stats.sharpe(returns_with_nan)
+ assert np.isfinite(result)
+
+ def test_dataframe_input(self, sample_returns, sample_benchmark):
+ """Test DataFrame input handling."""
+ df = pd.DataFrame({"A": sample_returns, "B": sample_benchmark})
+ result = stats.sharpe(df)
+ assert isinstance(result, pd.Series)
+ assert len(result) == 2
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..e42d0e72
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,183 @@
+"""
+Tests for quantstats.utils module
+"""
+
+import pytest
+import pandas as pd
+import numpy as np
+
+import quantstats as qs
+from quantstats import utils
+
+
+@pytest.fixture
+def sample_prices():
+ """Generate sample price data."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=100, freq="D")
+ prices = pd.Series(100 * np.cumprod(1 + np.random.randn(100) * 0.02), index=dates)
+ return prices
+
+
+@pytest.fixture
+def sample_returns():
+ """Generate sample return data."""
+ np.random.seed(42)
+ dates = pd.date_range("2020-01-01", periods=100, freq="D")
+ returns = pd.Series(np.random.randn(100) * 0.02, index=dates)
+ return returns
+
+
+class TestPrepareReturns:
+ """Test _prepare_returns function."""
+
+ def test_converts_prices_to_returns(self, sample_prices):
+ """Test that prices are converted to returns."""
+ result = utils._prepare_returns(sample_prices)
+ # First value should be NaN or 0 (pct_change result)
+ assert len(result) == len(sample_prices)
+ # Returns should be small values (not prices)
+ assert result.dropna().abs().max() < 1
+
+ def test_passes_through_returns(self, sample_returns):
+ """Test that returns pass through unchanged in magnitude."""
+ result = utils._prepare_returns(sample_returns)
+ # Should still be returns (small values)
+ assert result.abs().max() < 1
+
+ def test_handles_dataframe(self, sample_prices):
+ """Test DataFrame handling."""
+ df = pd.DataFrame({"A": sample_prices, "B": sample_prices * 1.1})
+ result = utils._prepare_returns(df)
+ assert isinstance(result, pd.DataFrame)
+ assert len(result.columns) == 2
+
+
+class TestToReturns:
+ """Test to_returns function."""
+
+ def test_to_returns_from_prices(self, sample_prices):
+ """Test conversion from prices to returns."""
+ result = utils.to_returns(sample_prices)
+ assert isinstance(result, pd.Series)
+ # Should be small values (returns)
+ assert result.dropna().abs().max() < 1
+
+
+class TestToPrices:
+ """Test to_prices function."""
+
+ def test_to_prices_from_returns(self, sample_returns):
+ """Test conversion from returns to prices."""
+ result = utils.to_prices(sample_returns, base=100)
+ assert isinstance(result, pd.Series)
+ # First value should be close to base
+ assert abs(result.iloc[0] - 100) < 10
+
+
+class TestLogReturns:
+ """Test log returns conversion."""
+
+ def test_log_returns(self, sample_returns):
+ """Test log returns calculation."""
+ result = utils.log_returns(sample_returns)
+ assert isinstance(result, pd.Series)
+ # Log returns should be close to simple returns for small values
+ np.testing.assert_array_almost_equal(
+ result.dropna().values, sample_returns.dropna().values, decimal=2
+ )
+
+
+class TestGroupReturns:
+ """Test group_returns function."""
+
+ def test_group_by_year(self, sample_returns):
+ """Test grouping returns by year."""
+ # Create returns spanning multiple years
+ dates = pd.date_range("2019-01-01", periods=400, freq="D")
+ returns = pd.Series(np.random.randn(400) * 0.01, index=dates)
+ result = utils.group_returns(returns, returns.index.year)
+ assert len(result) >= 2 # Should have at least 2 years
+
+
+class TestAggregateReturns:
+ """Test aggregate_returns function."""
+
+ def test_aggregate_monthly(self, sample_returns):
+ """Test monthly aggregation."""
+ # Need returns spanning multiple months
+ dates = pd.date_range("2020-01-01", periods=100, freq="D")
+ returns = pd.Series(np.random.randn(100) * 0.01, index=dates)
+ result = utils.aggregate_returns(returns, "month")
+ assert len(result) <= len(returns)
+
+ def test_aggregate_yearly(self, sample_returns):
+ """Test yearly aggregation."""
+ dates = pd.date_range("2019-01-01", periods=400, freq="D")
+ returns = pd.Series(np.random.randn(400) * 0.01, index=dates)
+ result = utils.aggregate_returns(returns, "year")
+ assert len(result) <= len(returns)
+
+
+class TestRebase:
+ """Test rebase function."""
+
+ def test_rebase_to_100(self, sample_prices):
+ """Test rebasing prices to 100."""
+ result = utils.rebase(sample_prices, base=100)
+ assert abs(result.iloc[0] - 100) < 0.01
+
+
+class TestMakePortfolio:
+ """Test make_portfolio function."""
+
+ def test_make_portfolio_comp(self, sample_returns):
+ """Test portfolio creation with compounding."""
+ result = utils.make_portfolio(sample_returns, start_balance=10000, mode="comp")
+ assert isinstance(result, pd.Series)
+ # Should start near the initial balance
+ assert abs(result.iloc[0] - 10000) < 1000
+
+
+class TestValidation:
+ """Test input validation."""
+
+ def test_validate_series(self, sample_returns):
+ """Test validation accepts valid Series."""
+ assert utils.validate_input(sample_returns) is True
+
+ def test_validate_dataframe(self, sample_returns):
+ """Test validation accepts valid DataFrame."""
+ df = pd.DataFrame({"A": sample_returns})
+ assert utils.validate_input(df) is True
+
+ def test_validate_none_raises(self):
+ """Test validation raises for None input."""
+ with pytest.raises(utils.DataValidationError):
+ utils.validate_input(None)
+
+ def test_validate_empty_raises(self):
+ """Test validation raises for empty input."""
+ with pytest.raises(utils.DataValidationError):
+ utils.validate_input(pd.Series([], dtype=float))
+
+
+class TestInNotebook:
+ """Test notebook detection."""
+
+ def test_in_notebook_returns_bool(self):
+ """Test that _in_notebook returns a boolean."""
+ result = utils._in_notebook()
+ assert isinstance(result, bool)
+ # In pytest, should return False
+ assert result is False
+
+
+class TestFileStream:
+ """Test file stream creation."""
+
+ def test_file_stream_creation(self):
+ """Test _file_stream returns BytesIO object."""
+ result = utils._file_stream()
+ assert hasattr(result, "read")
+ assert hasattr(result, "write")