Skip to content

Commit 23b5475

Browse files
committed
Add cumulative and calendar returns
1 parent 880473c commit 23b5475

File tree

8 files changed

+272
-7
lines changed

8 files changed

+272
-7
lines changed

pyform/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
# flake8: noqa
2+
13
# Versioneer
24
from ._version import get_versions
35

46
__version__ = get_versions()["version"]
57
del get_versions
8+
9+
from pyform.returnseries import ReturnSeries, CashSeries

pyform/analysis/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# flake8: noqa
2+
3+
from pyform.analysis.returns import table_calendar_return

pyform/analysis/returns.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import calendar
2+
import pandas as pd
3+
from pyform import ReturnSeries
4+
5+
6+
def table_calendar_return(
7+
return_series: ReturnSeries, use_month_abbr: bool = True
8+
) -> pd.DataFrame:
9+
"""Create calendar like monthly return table
10+
11+
Args:
12+
return_series: A return series. Should be of minimum monthly frequency
13+
use_month_abbr: Whether to use 3 letter month abbreviations instead of numerical
14+
month. Defaults to True.
15+
16+
Returns:
17+
pd.DataFrame: DataFrame with columns: Year, Jan, Feb, ..., Dec, Total
18+
"""
19+
20+
# create monthly and annual returns for output rows
21+
monthly = return_series.to_month()
22+
annual = return_series.to_year()
23+
24+
# append necessary information
25+
monthly["Year"] = monthly.index.year
26+
monthly["Month"] = monthly.index.month
27+
monthly["Month"] = monthly["Month"]
28+
annual["Year"] = annual.index.year
29+
30+
# Pivot monthly data
31+
monthly = monthly.pivot(index="Year", columns="Month", values=monthly.columns[0])
32+
33+
# Rename annual column, and merge with monthly
34+
annual = annual.rename(columns={annual.columns[0]: "Total"})
35+
36+
output = monthly.merge(annual, how="left", on="Year")
37+
38+
if use_month_abbr:
39+
months = [*range(1, 13)]
40+
month_abbr_map = {month: calendar.month_abbr[month] for month in months}
41+
output = output.rename(columns=month_abbr_map)
42+
43+
return output

pyform/returns/compound.py

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def compound_arithmetic(returns: pd.Series) -> float:
2424
"""Performs arithmatic compounding.
2525
2626
e.g. if there are 3 returns r1, r2, r3,
27-
calculate ``r1 + r2`` + r3
27+
calculate r1 + r2 + r3
2828
2929
Args:
3030
returns: pandas series of returns, in decimals.
@@ -41,7 +41,7 @@ def compound_continuous(returns: pd.Series) -> float:
4141
"""Performs continuous compounding.
4242
4343
e.g. if there are 3 returns r1, r2, r3,
44-
calculate exp(``r1 + r2`` + r3) - 1
44+
calculate exp(r1 + r2 + r3) - 1
4545
4646
Args:
4747
returns: pandas series of returns, in decimals.
@@ -60,9 +60,9 @@ def compound(method: str) -> Callable:
6060
Args:
6161
method: method of compounding in the generated function
6262
63-
* 'geometric': geometric compounding ``(1+r1) * (1+r2) - 1``
64-
* 'arithmetic': arithmetic compounding ``r1 + r2``
65-
* 'continuous': continous compounding ``exp(r1+r2) - 1``
63+
* 'geometric': geometric compounding ``(1+r1) * (1+r2) - 1``
64+
* 'arithmetic': arithmetic compounding ``r1 + r2``
65+
* 'continuous': continous compounding ``exp(r1+r2) - 1``
6666
6767
Raises:
6868
ValueError: when method is not supported.
@@ -86,6 +86,99 @@ def compound(method: str) -> Callable:
8686
return compound[method]
8787

8888

89+
def cumseries_geometric(returns: pd.Series) -> pd.Series:
90+
"""Performs geometric compounding to create cumulative index series.
91+
92+
e.g. if there are 3 returns r1, r2, r3,
93+
calculate
94+
(1+r1) - 1,
95+
(1+r1) * (1+r2) - 1,
96+
(1+r1) * (1+r2) * (1+r3) - 1
97+
98+
Args:
99+
returns: pandas series of returns, in decimals.
100+
i.e. 3% should be expressed as 0.03, not 3.
101+
102+
Returns:
103+
returns: pandas series of cumulative index, in decimals.
104+
"""
105+
106+
return (1 + returns).cumprod() - 1
107+
108+
109+
def cumseries_arithmetic(returns: pd.Series) -> pd.Series:
110+
"""Performs arithmatic compounding to create cumulative index series.
111+
112+
e.g. if there are 3 returns r1, r2, r3,
113+
calculate
114+
r1
115+
r1 + r2
116+
r1 + r2 + r3
117+
118+
Args:
119+
returns: pandas series of returns, in decimals.
120+
i.e. 3% should be expressed as 0.03, not 3.
121+
122+
Returns:
123+
returns: pandas series of cumulative index, in decimals.
124+
"""
125+
126+
return returns.cumsum()
127+
128+
129+
def cumseries_continuous(returns: pd.Series) -> float:
130+
"""Performs continuous compounding to create cumulative index series.
131+
132+
e.g. if there are 3 returns r1, r2, r3,
133+
calculate
134+
exp(r1) - 1
135+
exp(r1 + r2) - 1
136+
exp(r1 + r2 + r3) - 1
137+
138+
Args:
139+
returns: pandas series of returns, in decimals.
140+
i.e. 3% should be expressed as 0.03, not 3.
141+
142+
Returns:
143+
returns: pandas series of cumulative index, in decimals.
144+
"""
145+
146+
return returns.cumsum().apply(lambda x: math.exp(x) - 1)
147+
148+
149+
def cumseries(method: str) -> Callable:
150+
"""Factory for producing compound functions.
151+
152+
Args:
153+
method: method of compounding in the generated function
154+
155+
* 'geometric': geometric compounding
156+
* 'arithmetic': arithmetic compounding
157+
* 'continuous': continous compounding
158+
159+
Raises:
160+
ValueError: when method is not supported.
161+
162+
Returns:
163+
Callable: a function that takes a pandas series as its argument, and
164+
compound it to create a cumulative index sereis according to the
165+
method specified.
166+
"""
167+
168+
if method not in ["arithmetic", "geometric", "continuous"]:
169+
raise ValueError(
170+
"Method should be one of 'geometric', 'arithmetic' or 'continuous'"
171+
)
172+
173+
cumseries = {
174+
"arithmetic": cumseries_arithmetic,
175+
"geometric": cumseries_geometric,
176+
"continuous": cumseries_continuous,
177+
}
178+
179+
return cumseries[method]
180+
181+
89182
def ret_to_period(df: pd.DataFrame, freq: str, method: str):
90183
"""Converts return series to a different (and lower) frequency.
91184

pyform/returnseries.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pandas as pd
77
from typing import Optional, Union, Dict
88
from pyform.timeseries import TimeSeries
9-
from pyform.returns.compound import compound, ret_to_period
9+
from pyform.returns.compound import compound, ret_to_period, cumseries
1010
from pyform.returns.metrics import calc_ann_vol, calc_ann_ret
1111
from pyform.util.freq import is_lower_freq, calc_samples_per_year
1212

@@ -354,6 +354,41 @@ def get_tot_ret(
354354

355355
return result
356356

357+
def get_index_series(
358+
self,
359+
freq: Optional[str] = "M",
360+
method: Optional[str] = "geometric",
361+
include_bm: Optional[bool] = True,
362+
) -> Dict[str, pd.DataFrame]:
363+
364+
# Store result in dictionary
365+
result = dict()
366+
367+
run_name, run_data = [self.name], [self]
368+
369+
if include_bm:
370+
run_name += list(self.benchmark.keys())
371+
run_data += list(self.benchmark.values())
372+
373+
for name, series in zip(run_name, run_data):
374+
375+
# keep record of start and so they can be reset later
376+
series_start, series_end = series.start, series.end
377+
378+
# modify series so it's in the same timerange as the main series
379+
self.align_daterange(series)
380+
381+
# compute rolling annualized volatility
382+
ret = series.to_period(freq=freq, method=method)
383+
384+
# store result in dictionary
385+
result[name] = ret.apply(cumseries(method))
386+
387+
# reset series date range
388+
series.set_daterange(series_start, series_end)
389+
390+
return result
391+
357392
def get_ann_ret(
358393
self,
359394
method: Optional[str] = "geometric",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from pyform.analysis import table_calendar_return
2+
from pyform import ReturnSeries
3+
4+
returns = ReturnSeries.read_csv("tests/unit/data/twitter_returns.csv")
5+
6+
7+
def test_calendar_return():
8+
9+
calendar_return = table_calendar_return(returns)
10+
assert (
11+
calendar_return.columns
12+
== [
13+
"Year",
14+
"Jan",
15+
"Feb",
16+
"Mar",
17+
"Apr",
18+
"May",
19+
"Jun",
20+
"Jul",
21+
"Aug",
22+
"Sep",
23+
"Oct",
24+
"Nov",
25+
"Dec",
26+
"Total",
27+
]
28+
).all()
29+
30+
assert calendar_return.iloc[0, 0] == 2013

tests/unit/returns/test_compound.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
compound_arithmetic,
66
compound_continuous,
77
compound,
8+
cumseries_geometric,
9+
cumseries_arithmetic,
10+
cumseries_continuous,
11+
cumseries,
812
)
913

1014

@@ -27,3 +31,24 @@ def test_compound_methods():
2731
assert compound_geometric(returns) == 0.055942142480424284
2832
assert compound_arithmetic(returns) == 0.05658200000000001
2933
assert compound_continuous(returns) == 0.05821338474015869
34+
35+
36+
def test_cumseries():
37+
38+
assert cumseries("geometric") == cumseries_geometric
39+
assert cumseries("arithmetic") == cumseries_arithmetic
40+
assert cumseries("continuous") == cumseries_continuous
41+
42+
with pytest.raises(ValueError):
43+
cumseries("contnuuous") # typo in continuous should cause failure
44+
45+
46+
def test_cumseries_methods():
47+
48+
returns = pd.Series(
49+
[0.030011999999999997, -0.02331, 0.016706000000000002, 0.049061, -0.015887]
50+
)
51+
52+
assert cumseries_geometric(returns).iloc[-1] == 0.055942142480424284
53+
assert cumseries_arithmetic(returns).iloc[-1] == 0.05658200000000001
54+
assert cumseries_continuous(returns).iloc[-1] == 0.05821338474015869

tests/unit/test_returnseries.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import datetime
22
import pytest
33
import pandas as pd
4-
from pyform.returnseries import ReturnSeries, CashSeries
4+
from pyform import ReturnSeries, CashSeries
55

66
returns = ReturnSeries.read_csv("tests/unit/data/twitter_returns.csv")
77
spy = ReturnSeries.read_csv("tests/unit/data/spy_returns.csv")
@@ -434,6 +434,38 @@ def test_rolling_ann_vol():
434434
assert roll_spy["SPY"][0] == 0.10810183559733508
435435

436436

437+
def test_index_series():
438+
439+
returns = ReturnSeries.read_csv("tests/unit/data/twitter_returns.csv")
440+
441+
# No benchmark
442+
index_series = returns.get_index_series()
443+
index_twitter = index_series["TWTR"]
444+
assert index_twitter.index[-1] == datetime.datetime.strptime(
445+
"2020-06-30", "%Y-%m-%d"
446+
)
447+
assert index_twitter["TWTR"][-1] == -0.35300922502128296
448+
449+
# Daily
450+
index_series = returns.get_index_series(freq="D")
451+
index_twitter = index_series["TWTR"]
452+
assert index_twitter.index[-1] == datetime.datetime.strptime(
453+
"2020-06-26", "%Y-%m-%d"
454+
)
455+
assert index_twitter["TWTR"][-1] == -0.35300922502128473
456+
457+
returns.add_bm(spy)
458+
index_series = returns.get_index_series()
459+
index_twitter = index_series["TWTR"]
460+
index_spy = index_series["SPY"]
461+
assert index_twitter.index[-1] == datetime.datetime.strptime(
462+
"2020-06-30", "%Y-%m-%d"
463+
)
464+
assert index_spy.index[-1] == datetime.datetime.strptime("2020-06-30", "%Y-%m-%d")
465+
assert index_twitter["TWTR"][-1] == -0.35300922502128296
466+
assert index_spy["SPY"][-1] == 0.6935467657365093
467+
468+
437469
def test_rolling_ann_return():
438470

439471
returns = ReturnSeries.read_csv("tests/unit/data/twitter_returns.csv")

0 commit comments

Comments
 (0)