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 pdimport ffnprices = pd.read_csv("prices.csv", parse_dates=["Date"]) # long frame, one row per ticker per dayaapl = prices[prices["Ticker"] =="AAA"].sort_values("Date") # AAA only, oldest date firstclose = aapl.set_index("Date")["Close"] # a dated series of pricesperf = ffn.calc_stats(close) # the whole calculation, one lineprint(type(perf).__name__) # -> PerformanceStatsprint(len(perf.stats)) # -> 46
PerformanceStats
46
perf.stats is a Series holding all 46, keyed by name.
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 numericprint(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
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 wayprint((close.index[-1] - close.index[0]).days) # -> 2191print(round((1+ total) ** (365.25/2191) -1, 6)) # -> 0.176739 <- ffn, from the first priceprint(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.
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 elseprint(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: float64print(f"{dd.min():.2%}", dd.idxmin().date()) # -> -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 sampleprint(len(details)) # -> 47print(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
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 47print(worst["Start"].date(), "to", worst["End"].date()) # -> 2022-05-04 to 2024-06-07print(worst["Length"]) # -> 765print(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.
TipShow answer
import pandas as pdimport ffnprices = 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 peakdetails = ffn.drawdown_details(dd) # one row per falldeep = details[details["drawdown"] <-0.10] # keep the falls worse than 10%print(len(details)) # -> 39print(len(deep)) # -> 9print(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.