How do I get the performance stats without writing them?

Call ffn.calc_stats on a price series to get CAGR, volatility, Sharpe, Sortino, drawdown and forty more, and check them against the ones you built by hand.

ffn.calc_stats(close) takes a series of prices and returns 46 performance figures. Lesson 33 wrote six of them by hand: CAGR, annualised volatility, Sharpe, Sortino, max drawdown and Calmar.

In this lesson I run calc_stats on AAA, put its numbers next to the hand built ones from Lesson 33, run it on all four tickers at once, and pull the drawdown table that Lesson 32 built by slicing.

Step 1. One call on one price series

ffn works on prices, not returns. It differences them itself, so the input is the close column straight out of prices.csv, which sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025.

import pandas as pd
import ffn

prices = pd.read_csv("prices.csv", parse_dates=["Date"])          # long frame, one row per ticker per day
aapl   = prices[prices["Ticker"] == "AAA"].sort_values("Date")   # AAA only, oldest date first
close  = aapl.set_index("Date")["Close"]                          # a dated series of prices

perf = ffn.calc_stats(close)          # the whole calculation, one line

print(type(perf).__name__)            # -> PerformanceStats
print(len(perf.stats))                # -> 46
PerformanceStats
46

perf.stats is a Series holding all 46, keyed by name.

print(list(perf.stats.index[:8]))
# -> ['start', 'end', 'rf', 'total_return', 'cagr', 'max_drawdown', 'calmar', 'mtd']
['start', 'end', 'rf', 'total_return', 'cagr', 'max_drawdown', 'calmar', 'mtd']

start, end and rf describe the run rather than measure it, which makes the Series object dtype. Picking the six that Lesson 33 built by hand and casting to float gets .round() working.

picked = perf.stats[["cagr", "daily_vol", "daily_sharpe",         # six of the 46, chosen by name
                     "daily_sortino", "max_drawdown", "calmar"]]
picked = picked.astype(float)                                     # dates gone, so the column is numeric

print(picked.round(4))
# -> cagr             0.1767
# -> daily_vol        0.2350
# -> daily_sharpe     0.7865
# -> daily_sortino    1.1489
# -> max_drawdown    -0.2982
# -> calmar           0.5927
# -> dtype: float64
cagr             0.1767
daily_vol        0.2350
daily_sharpe     0.7865
daily_sortino    1.1489
max_drawdown    -0.2982
calmar           0.5927
dtype: float64

The daily_ prefix says the figure was annualised from daily data. monthly_sharpe and yearly_sharpe are also in the 46, computed off resampled returns.

Step 2. Against the hand built numbers

Lesson 33’s perf_stats is eighteen lines and returns six numbers. Here are those six printed figures next to what calc_stats gives.

hand = pd.Series({"cagr":           0.1768,      # every figure printed in Lesson 33
                  "daily_vol":      0.2350,
                  "daily_sharpe":   0.7865,
                  "daily_sortino":  1.1489,
                  "max_drawdown":  -0.2982,
                  "calmar":         0.5930})

print(pd.DataFrame({"lesson 33": hand, "ffn": picked.round(4)}))
# ->                lesson 33     ffn
# -> cagr              0.1768  0.1767
# -> daily_vol         0.2350  0.2350
# -> daily_sharpe      0.7865  0.7865
# -> daily_sortino     1.1489  1.1489
# -> max_drawdown     -0.2982 -0.2982
# -> calmar            0.5930  0.5927
               lesson 33     ffn
cagr              0.1768  0.1767
daily_vol         0.2350  0.2350
daily_sharpe      0.7865  0.7865
daily_sortino     1.1489  1.1489
max_drawdown     -0.2982 -0.2982
calmar            0.5930  0.5927

Four of the six agree to four decimals. CAGR differs in the last one, and the reason is which date the clock starts on: ffn measures the elapsed years from the first price, Lesson 33 measured them from the first return, which is one day later. Calmar is CAGR divided by the drawdown, so it carries the same day.

total = (1 + close.pct_change().dropna()).prod() - 1     # 165.45% either way

print((close.index[-1] - close.index[0]).days)                # -> 2191
print(round((1 + total) ** (365.25 / 2191) - 1, 6))           # -> 0.176739   <- ffn, from the first price
print(round((1 + total) ** (365.25 / 2190) - 1, 6))           # -> 0.176826   <- Lesson 33, from the first return
2191
0.176739
0.176826

The same growth spread over one extra day is a slightly lower yearly rate.

Packages disagree on the downside deviation. ffn clips the gains to zero and averages the squares over every day, the np.minimum(ret, 0) convention of Lesson 33, so both give 1.1489. A package that averaged over the 748 down days only would print a different number on the same prices.

Step 3. Four tickers in one call

calc_stats accepts a DataFrame and treats each column as its own price series. pivot turns the long file into that shape.

wide = prices.pivot(index="Date", columns="Ticker", values="Close")   # one column per ticker

print(wide.shape)                     # -> (1566, 4)
print(wide.head())
# -> Ticker        AAA     BBB     CCC    DDD
# -> Date
# -> 2020-01-01  75.08  144.61  160.26  58.98
# -> 2020-01-02  76.94  144.85  160.44  59.55
# -> 2020-01-03  77.34  145.17  159.84  61.45
# -> 2020-01-06  78.32  145.80  161.90  62.74
# -> 2020-01-07  77.50  146.50  162.89  63.62
(1566, 4)
Ticker        AAA     BBB     CCC    DDD
Date                                    
2020-01-01  75.08  144.61  160.26  58.98
2020-01-02  76.94  144.85  160.44  59.55
2020-01-03  77.34  145.17  159.84  61.45
2020-01-06  78.32  145.80  161.90  62.74
2020-01-07  77.50  146.50  162.89  63.62

Now .stats comes back as a DataFrame with one column per ticker and the same 46 rows.

four = ffn.calc_stats(wide).stats                        # four price series in, four columns out

rows = ["total_return", "cagr", "daily_vol", "daily_sharpe",   # the rows I want to see
        "daily_sortino", "max_drawdown", "calmar"]

print(four.loc[rows].astype(float).round(4))
# ->                  AAA     BBB     CCC     DDD
# -> total_return   1.6545  1.0703  4.1698  2.7255
# -> cagr           0.1767  0.1290  0.3150  0.2451
# -> daily_vol      0.2350  0.1445  0.2081  0.3845
# -> daily_sharpe   0.7865  0.8830  1.3756  0.7429
# -> daily_sortino  1.1489  1.2981  2.0619  1.1039
# -> max_drawdown  -0.2982 -0.2017 -0.3295 -0.5597
# -> calmar         0.5927  0.6395  0.9560  0.4380
                  AAA     BBB     CCC     DDD
total_return   1.6545  1.0703  4.1698  2.7255
cagr           0.1767  0.1290  0.3150  0.2451
daily_vol      0.2350  0.1445  0.2081  0.3845
daily_sharpe   0.7865  0.8830  1.3756  0.7429
daily_sortino  1.1489  1.2981  2.0619  1.1039
max_drawdown  -0.2982 -0.2017 -0.3295 -0.5597
calmar         0.5927  0.6395  0.9560  0.4380

That is the Lesson 33 table row for row. Every figure matches except cagr and calmar, and Calmar is CAGR divided by the drawdown, so it carries the same extra day.

Step 4. The drawdown table

Importing ffn attaches to_drawdown_series to any Series, and it does what Lesson 32 did in two lines: divide the curve by its own running peak.

dd = close.to_drawdown_series()       # zero on a new high, negative everywhere else

print(dd.head())
# -> Date
# -> 2020-01-01    0.00000
# -> 2020-01-02    0.00000
# -> 2020-01-03    0.00000
# -> 2020-01-06    0.00000
# -> 2020-01-07   -0.01047
# -> Name: Close, dtype: float64

print(f"{dd.min():.2%}", dd.idxmin().date())     # -> -29.82% 2023-06-23
Date
2020-01-01    0.00000
2020-01-02    0.00000
2020-01-03    0.00000
2020-01-06    0.00000
2020-01-07   -0.01047
Name: Close, dtype: float64
-29.82% 2023-06-23

Lesson 32 found the same -29.82% on the same day. drawdown_details then splits that series into one row per fall, from the first day below a peak to the day the curve got back to it.

details = ffn.drawdown_details(dd)    # every completed fall in the sample

print(len(details))                   # -> 47
print(details.sort_values("drawdown").head(4))
# ->                   Start                  End Length  drawdown
# -> 29  2022-05-04 00:00:00  2024-06-07 00:00:00    765 -0.298206
# -> 23  2021-01-15 00:00:00  2022-04-07 00:00:00    447   -0.2295
# -> 30  2024-06-11 00:00:00  2024-11-26 00:00:00    168 -0.171346
# -> 43  2025-08-14 00:00:00  2025-10-23 00:00:00     70 -0.166908
47
                  Start                  End Length  drawdown
29  2022-05-04 00:00:00  2024-06-07 00:00:00    765 -0.298206
23  2021-01-15 00:00:00  2022-04-07 00:00:00    447   -0.2295
30  2024-06-11 00:00:00  2024-11-26 00:00:00    168 -0.171346
43  2025-08-14 00:00:00  2025-10-23 00:00:00     70 -0.166908

AAA had 47 separate falls, and the deepest ran from May 2022 to June 2024. Lesson 32 dated that one peak 2022-05-03, trough 2023-06-23, back to the peak 2024-06-07. ffn starts its row the day after the peak, and its Length is in calendar days.

worst = details.sort_values("drawdown").iloc[0]           # the deepest of the 47

print(worst["Start"].date(), "to", worst["End"].date())   # -> 2022-05-04 to 2024-06-07
print(worst["Length"])                                    # -> 765
print(len(close.loc[worst["Start"]:worst["End"]]) - 1)    # -> 547
2022-05-04 to 2024-06-07
765
547

765 calendar days hold 547 trading days between those two dates, and Lesson 32 counted 548 because it started one row earlier, on the peak itself.

Your turn

Take DDD. Use to_drawdown_series and drawdown_details to count its separate falls, how many of them went deeper than 10%, and which three were the worst.

import pandas as pd
import ffn

prices = pd.read_csv("prices.csv", parse_dates=["Date"])
nvda   = prices[prices["Ticker"] == "DDD"].set_index("Date")["Close"]

dd      = nvda.to_drawdown_series()             # the curve over its own running peak
details = ffn.drawdown_details(dd)              # one row per fall

deep = details[details["drawdown"] < -0.10]     # keep the falls worse than 10%

print(len(details))                             # -> 39
print(len(deep))                                # -> 9
print(deep.sort_values("drawdown").head(3))
# ->                   Start                  End Length  drawdown
# -> 35  2022-10-04 00:00:00  2025-10-29 00:00:00   1121 -0.559696
# -> 15  2020-10-27 00:00:00  2021-07-02 00:00:00    248  -0.29695
# -> 1   2020-01-13 00:00:00  2020-04-10 00:00:00     88 -0.229781

The 55.97% fall of Lesson 32 took 1121 calendar days from the day after the peak to the recovery.