Returns analysis¶
If you have a returns Series — from a broker export, a notebook experiment,
or the output of the Simulator — one import fundcloud is the only setup
required to run a full metric suite.
One call to start¶
import numpy as np
import pandas as pd
import fundcloud # registers .fc on pandas Series and DataFrame
rng = np.random.default_rng(42)
idx = pd.bdate_range("2022-01-03", periods=504) # two years of business days
returns = pd.Series(
rng.normal(0.0007, 0.011, len(idx)), index=idx, name="my_strategy"
)
returns.fc.metrics()
Typical output:
cagr 0.1843
ann_volatility 0.1748
sharpe 1.0543
sortino 1.4912
calmar 0.7201
omega 1.2887
max_drawdown -0.2560
ulcer_index 0.0621
cvar 0.0151
win_rate 0.5476
avg_return 0.0007
Name: my_strategy, dtype: float64
What each metric tells you¶
CAGR — Compound Annual Growth Rate. The annualised geometric return.
Comparable across strategies with different track-record lengths, unlike
total_return. A CAGR of 0.18 means +18 % per year, compounded.
ann_volatility — Annualised standard deviation of daily returns. The denominator of Sharpe. High vol is not automatically bad — it depends whether the return justifies it.
Sharpe — (CAGR − risk_free) / ann_volatility. The single most widely
quoted risk-adjusted return measure. A Sharpe above 1.0 is considered strong
for a live strategy; many retail strategies sit between 0.3 and 0.8.
Sortino — Like Sharpe, but the denominator is downside deviation only (returns below the target). Penalises strategies that lose more than they should, while not penalising upside volatility. Usually higher than Sharpe for trend-following.
Calmar — CAGR / |max_drawdown|. Annualised return per unit of peak loss.
A Calmar of 0.5 means the strategy earns half its worst drawdown back per year.
CTAs and managed-futures funds typically target Calmars between 0.5 and 1.5.
Omega — Ratio of gains above a threshold to losses below it. A probability- weighted measure of the whole return distribution. Above 1 means the strategy earns more than it loses relative to the threshold.
max_drawdown — Peak-to-trough decline in the wealth curve. One of the most psychologically important metrics — it determines whether you can stay invested.
ulcer_index — Root-mean-square of the running drawdown path. Captures both depth and duration of underwater periods. A portfolio with a quick -15 % recovery has a lower ulcer index than one with a slow -8 % grind.
CVaR — Conditional Value at Risk at 95 %. The expected loss on the worst 5 % of days. More informative than VaR for tail-risk budgeting. A CVaR of 0.015 means losses average 1.5 % on the worst days.
win_rate — Fraction of periods with positive returns. A high win rate
doesn't guarantee profitability if losses are large; pair it with
payoff_ratio.
avg_return — Average per-period return. Multiply by 252 for a rough annualised figure (not the same as CAGR due to compounding, but useful for quick comparisons).
Same call on a DataFrame¶
When returns is a multi-column DataFrame, .fc.summary() returns a
DataFrame with metrics as rows and strategies as columns.
rng = np.random.default_rng(11)
idx = pd.bdate_range("2022-01-03", periods=504)
df = pd.DataFrame(
{
"conservative": rng.normal(0.0003, 0.006, len(idx)),
"balanced": rng.normal(0.0007, 0.011, len(idx)),
"aggressive": rng.normal(0.0012, 0.019, len(idx)),
},
index=idx,
)
df.fc.summary()
# Returns a DataFrame: rows = metrics, columns = strategy names
Pick a single row to compare all three at once:
df.fc.summary().loc["sharpe"]
# conservative 0.81
# balanced 1.05
# aggressive 0.94
# Name: sharpe, dtype: float64
The risk story: drawdowns¶
max_drawdown is a start, not an answer. A -20 % drawdown that lasts three
weeks is painful but manageable. The same loss spread over three years erodes
confidence, triggers investor redemptions, and — for levered strategies — can
force liquidation before recovery.
Duration is the missing dimension.
from fundcloud.portfolio import Portfolio
pf = Portfolio(returns=returns, name="my_strategy")
# Worst N drawdown episodes, display-formatted
pf.worst_drawdowns(top=5)
# Started Recovered Drawdown Days
# 2022-06-14 2023-01-09 -0.256 209
# 2022-02-28 2022-04-01 -0.118 32
# 2023-10-02 2023-11-17 -0.097 46
# 2023-07-18 2023-08-21 -0.071 34
# 2022-11-04 2022-12-01 -0.063 27
# Full episode table: start / valley / recovery / depth / duration / recovery time
pf.drawdown_details()
# Returns a DataFrame with columns:
# start, valley, recovery, max_drawdown, duration_days, days_to_recover
The recovery column is NaT for any episode still underwater at the end of
the sample. days_to_recover is NaN for the same reason.
# Running drawdown series for plotting or alerting
dd = returns.fc.drawdown_series() # pd.Series, always <= 0
deepest = dd.idxmin()
print(f"Deepest point: {dd.min():.1%} on {deepest.date()}")
# Plotly drawdown chart — one line per column for a DataFrame
returns.fc.plot_drawdown().show()
Duration is the psychological test
A -20 % drawdown lasting 3 months is a bump. The same loss lasting
3 years destroys the investor relationship. When screening strategies,
check days_to_recover in drawdown_details(), not just max_drawdown.
Still underwater at end of sample
If the most recent episode has recovery = NaT, the strategy has not
recovered yet. The reported max_drawdown and duration_days are current
as of the last data point, not necessarily the final state of the episode.
Period returns: the institutional view¶
MTD and YTD are what every performance report leads with. The 3Y and 5Y annualised figures are what institutional allocators actually care about when deciding whether to invest.
rng = np.random.default_rng(0)
idx = pd.date_range("2015-01-02", periods=2500, freq="B")
returns = pd.Series(rng.normal(0.0005, 0.012, 2500), index=idx, name="Strategy")
spy_rets = pd.Series(rng.normal(0.0003, 0.010, 2500), index=idx, name="SPY")
pf = Portfolio(returns=returns, name="Strategy")
# MTD / 3M / 6M / YTD / 1Y / 3Y / 5Y / 10Y / All-time (vs benchmark)
pf.period_returns(benchmark=spy_rets)
# SPY Strategy
# MTD -0.0186 0.0026
# 3M 0.1228 -0.0433
# 6M -0.0524 -0.0726
# YTD -0.0967 -0.0584
# 1Y 0.2325 -0.0310
# 3Y (ann.) 0.1448 -0.0530
# 5Y (ann.) 0.2126 0.0292
# 10Y (ann.) 0.1199 0.0207
# All-time (ann.) 0.1199 0.0207
# Calendar-year returns, one row per year
pf.yearly_returns(benchmark=spy_rets)
# SPY Strategy
# 2015 0.0121 0.0064
# 2016 0.1195 0.1438
# ...
3Y / 5Y are annualised
Multi-year rows use (1 + total_return)^(1/years) - 1 internally so they
are directly comparable to the 1Y and MTD rows. Do not mix these with
cumulative figures without rescaling.
Multi-asset comparison¶
rng = np.random.default_rng(11)
idx = pd.bdate_range("2022-01-03", periods=504)
df = pd.DataFrame(
{
"conservative": rng.normal(0.0003, 0.006, len(idx)),
"balanced": rng.normal(0.0007, 0.011, len(idx)),
"aggressive": rng.normal(0.0012, 0.019, len(idx)),
},
index=idx,
)
# 11-metric table — metrics as rows, strategies as columns
summary = df.fc.summary()
# Slice specific rows
summary.loc[["cagr", "sharpe", "max_drawdown", "cvar"]]
# conservative balanced aggressive
# cagr 0.0748 0.1843 0.3267
# sharpe 0.8106 1.0543 1.0219
# max_drawdown -0.0812 -0.2560 -0.4487
# cvar 0.0087 0.0154 0.0267
# Full metric-by-strategy table (optional benchmark for alpha/beta/etc.)
rng_spy = np.random.default_rng(99)
spy = pd.Series(rng_spy.normal(0.0004, 0.009, len(idx)), index=idx, name="SPY")
full = df.fc.metrics(benchmark=spy)
# Shape: ~55 rows × 3 columns
# With benchmark= included, also has: alpha, beta, correlation,
# r_squared, information_ratio, tracking_error, up/down capture, treynor_ratio
full.loc[["alpha", "beta", "up_capture", "down_capture"]]
# conservative balanced aggressive
# alpha 0.0631 0.1502 0.2918
# beta 0.0443 0.0872 0.1461
# up_capture 1.0062 1.1032 1.1785
# down_capture 1.0143 1.0718 1.1512
Rolling metrics on a single strategy¶
Rolling metrics show how characteristics evolve over time — invaluable for regime detection and overfitting checks.
window = 63 # ~1 quarter
rs = returns.fc.rolling_sharpe(window=window) # pd.Series
rv = returns.fc.rolling_volatility(window=21) # ~1 month vol
rd = returns.fc.rolling_drawdown() # current drawdown series
rb = returns.fc.rolling_beta(spy_rets, window=window) # pd.Series
# Flag when rolling Sharpe drops more than 1 standard deviation below its 6-month average
alert = rs < (rs.rolling(126).mean() - rs.rolling(126).std())
print(f"Rolling Sharpe alert on {alert.sum()} days")
Tear sheet in one line¶
from fundcloud.portfolio import Portfolio
from fundcloud.reports import Tearsheet
rng = np.random.default_rng(42)
idx = pd.bdate_range("2022-01-03", periods=504)
returns = pd.Series(rng.normal(0.0007, 0.011, len(idx)), index=idx, name="my_strategy")
pf = Portfolio(returns=returns, name="my_strategy")
Tearsheet(pf, title="My Strategy").render_html("tearsheet.html")
# PDF and Excel use identical syntax
Tearsheet(pf, title="My Strategy").render_pdf("report.pdf")
Tearsheet(pf, title="My Strategy").render_excel("report.xlsx")
The tear sheet includes:
- Cumulative returns vs benchmark
- Drawdown corridor
- Rolling Sharpe (63-day)
- Monthly heatmap
- Yearly returns bar chart
- Period performance table (MTD → All-time)
- Worst drawdowns episode table
- Full metric summary
Jupyter shortcut — no file needed
For inline notebook exploration, skip the Portfolio / Tearsheet
constructors entirely:
Shortcut: tear sheet from the accessor¶
# Returns Series → HTML (no Portfolio object required)
returns.fc.render_html("tearsheet.html", title="My Strategy")
returns.fc.render_html("vs_spy.html", benchmark=spy_rets, title="vs SPY")
# DataFrame → one tab per column
df.fc.render_html("multi.html", title="All strategies")
Metric cheat sheet¶
Risk-adjusted returns¶
| Metric | What it measures | Direction |
|---|---|---|
sharpe |
(CAGR − rf) / ann_vol — excess return per unit of total volatility |
Higher is better |
sortino |
(CAGR − rf) / downside_vol — penalises downside only |
Higher is better |
calmar |
CAGR / |max_drawdown| — return per unit of peak loss |
Higher is better |
omega |
Probability-weighted ratio of gains to losses relative to threshold | Higher is better |
smart_sharpe |
Sharpe adjusted for positive autocorrelation in returns; corrects for smooth NAV inflation | Higher is better |
smart_sortino |
Sortino adjusted for autocorrelation | Higher is better |
probabilistic_sharpe |
Probability (0–1) that the true Sharpe exceeds a benchmark Sharpe | Higher is better |
adjusted_sortino |
Sortino corrected for small-sample bias | Higher is better |
Return metrics¶
| Metric | What it measures | Direction |
|---|---|---|
total_return |
Cumulative growth: (1 + r₁)(1 + r₂)… − 1 |
Higher is better |
cagr |
Annualised compound return | Higher is better |
avg_return |
Arithmetic average of per-period returns | Higher is better |
best |
Single best period return | Context-dependent |
worst |
Single worst period return | Context-dependent |
volatility |
Annualised standard deviation of returns (total vol) | Lower is better |
downside_volatility |
Annualised standard deviation of returns below target | Lower is better |
Drawdown and underwater risk¶
| Metric | What it measures | Direction |
|---|---|---|
max_drawdown |
Largest peak-to-trough decline in the wealth curve | Lower magnitude is better |
drawdown_series |
Per-bar running drawdown (wealth / cummax − 1), always ≤ 0 | n/a (series, not scalar) |
ulcer_index |
Root-mean-square of the running drawdown path; penalises long underwater periods | Lower is better |
Tail risk and pain¶
| Metric | What it measures | Direction |
|---|---|---|
cvar |
Expected loss on the worst α % of periods (Conditional VaR / Expected Shortfall) | Lower magnitude is better |
value_at_risk |
Loss threshold exceeded on the worst α % of periods | Lower magnitude is better |
tail_ratio |
|P95 of gains| / |P5 of losses| — right tail vs left tail |
Higher is better |
common_sense_ratio |
tail_ratio × gain_to_pain_ratio combined into one figure |
Higher is better |
gain_to_pain_ratio |
Total net gain / sum of all individual period losses | Higher is better |
pain_index |
Mean of squared running drawdowns; severity-weighted | Lower is better |
pain_ratio |
(CAGR − rf) / pain_index — like Sharpe but with drawdown severity as denominator |
Higher is better |
ulcer_performance_index |
CAGR / ulcer_index — penalises both depth and duration of drawdowns |
Higher is better |
Trade statistics¶
| Metric | What it measures | Direction |
|---|---|---|
win_rate |
Fraction of periods with positive returns | Higher is better (paired with payoff_ratio) |
avg_win |
Average return on positive periods | Higher is better |
avg_loss |
Average return on negative periods (negative value) | Lower magnitude is better |
payoff_ratio |
avg_win / |avg_loss| — average win vs average loss size |
Higher is better |
profit_factor |
Total gross gains / total gross losses | Higher is better (>1 = net profitable) |
consecutive_wins |
Longest winning streak (count of periods) | Higher is better |
consecutive_losses |
Longest losing streak (count of periods) | Lower is better |
exposure |
Fraction of periods with non-zero returns (time in market) | Context-dependent |
Distribution¶
| Metric | What it measures | Direction |
|---|---|---|
skew |
Return distribution skewness; positive = right tail heavier | Positive preferred (right-skewed gains) |
kurtosis |
Excess kurtosis; measures tail heaviness relative to normal | Lower is better (fat tails = surprise risk) |
Position sizing¶
| Metric | What it measures | Direction |
|---|---|---|
kelly_criterion |
Optimal fraction of capital: win_rate − (1 − win_rate) / payoff_ratio |
n/a — use as input, not absolute signal |
risk_of_ruin |
Probability of losing a specified fraction of capital given current win/loss stats | Lower is better |
Kelly in practice
Full Kelly is almost never used directly — it assumes stationary returns and maximises long-run log-wealth, which implies very high variance. Multiply the Kelly fraction by 0.25–0.5 ("quarter-Kelly" or "half-Kelly") for a smoother equity curve at the cost of lower expected long-run growth.
Paired metrics matter
win_rate and payoff_ratio should always be read together. A 40 % win
rate with a payoff ratio of 3.0 is a profitable distribution. A 70 % win
rate with a payoff ratio of 0.4 is not.