zipfolio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zipfolio/__init__.py +58 -0
- zipfolio/factors.py +104 -0
- zipfolio/inputs.py +153 -0
- zipfolio/page.py +145 -0
- zipfolio/sections/__init__.py +1 -0
- zipfolio/sections/capacity.py +70 -0
- zipfolio/sections/execution.py +423 -0
- zipfolio/sections/factor_analysis.py +183 -0
- zipfolio/sections/instruments.py +204 -0
- zipfolio/sections/performance.py +32 -0
- zipfolio/sections/portfolio_risk.py +274 -0
- zipfolio/sections/positions.py +35 -0
- zipfolio/sections/round_trips.py +80 -0
- zipfolio/sections/sector_analysis.py +49 -0
- zipfolio/sections/trading_costs.py +219 -0
- zipfolio/sections/transactions.py +89 -0
- zipfolio/sectors.py +96 -0
- zipfolio/style.py +370 -0
- zipfolio/tables.py +127 -0
- zipfolio/tearsheet.py +176 -0
- zipfolio/ziplime.py +117 -0
- zipfolio-0.1.0.dist-info/METADATA +316 -0
- zipfolio-0.1.0.dist-info/RECORD +25 -0
- zipfolio-0.1.0.dist-info/WHEEL +4 -0
- zipfolio-0.1.0.dist-info/licenses/LICENSE +201 -0
zipfolio/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Zipfolio: what a strategy does with its portfolio, position by position.
|
|
2
|
+
|
|
3
|
+
Part of the ZipLime ecosystem: the report ZipLime builds beside the QuantStats
|
|
4
|
+
tearsheet for every backtest, starting where a returns tearsheet stops. QuantStats and
|
|
5
|
+
its kind describe the return series; Zipfolio describes the book behind it --
|
|
6
|
+
positions and exposure, instruments and sectors, factor exposures, risk by
|
|
7
|
+
holding, how orders were executed and how far to believe those fills, trading
|
|
8
|
+
costs and the cost model, round trips, capacity -- as one self-contained HTML
|
|
9
|
+
page in the QuantStats Pro look.
|
|
10
|
+
|
|
11
|
+
It computes with pyfolio-reloaded, skfolio and statsmodels, reads the
|
|
12
|
+
Fama-French factors from Kenneth French's data library, and draws every chart
|
|
13
|
+
and table itself. Its inputs are the frames a ziplime run produces, as plain
|
|
14
|
+
pandas::
|
|
15
|
+
|
|
16
|
+
from zipfolio import build_tearsheet
|
|
17
|
+
|
|
18
|
+
build_tearsheet(
|
|
19
|
+
"report.html",
|
|
20
|
+
returns, # per-period strategy returns
|
|
21
|
+
benchmark, # benchmark returns, or None
|
|
22
|
+
position_records=positions, # date, symbol, quantity, last_sale_price
|
|
23
|
+
cash=ending_cash, # cash at the end of each period
|
|
24
|
+
transactions=fills, # dt, symbol, quantity, price
|
|
25
|
+
market_data=daily_bars, # symbol, day, price, volume (optional)
|
|
26
|
+
orders=orders, # id, dt, symbol, commission (optional)
|
|
27
|
+
cost_settings={...}, # commission / slippage settings (optional)
|
|
28
|
+
fill_bars=fill_bars, # OHLCV of each fill's bar, by fill id (optional)
|
|
29
|
+
decision_prices=decisions, # close at each order's creation, by order id (optional)
|
|
30
|
+
factor_model="ff5_mom", # or "ff5", "ff3", "ff3_mom", None
|
|
31
|
+
sector_mappings={"AAPL": "Information Technology"}, # optional
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
pyfolio and skfolio are imported when a page is built, not on import: a host
|
|
35
|
+
without skfolio loses that one section, not the report.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
from .inputs import daily_returns, market_data_frame, positions_in_dollars, transactions_frame # noqa: E402
|
|
41
|
+
from .page import DEFAULT_TITLE, REPOSITORY_URL # noqa: E402
|
|
42
|
+
from .sectors import GICS_SECTORS, UNCLASSIFIED, US_MICS, us_sector_mappings # noqa: E402
|
|
43
|
+
from .tearsheet import build_tearsheet # noqa: E402
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"DEFAULT_TITLE",
|
|
47
|
+
"GICS_SECTORS",
|
|
48
|
+
"REPOSITORY_URL",
|
|
49
|
+
"UNCLASSIFIED",
|
|
50
|
+
"US_MICS",
|
|
51
|
+
"__version__",
|
|
52
|
+
"build_tearsheet",
|
|
53
|
+
"daily_returns",
|
|
54
|
+
"market_data_frame",
|
|
55
|
+
"positions_in_dollars",
|
|
56
|
+
"transactions_frame",
|
|
57
|
+
"us_sector_mappings",
|
|
58
|
+
]
|
zipfolio/factors.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Daily Fama-French factors, from Kenneth French's data library.
|
|
2
|
+
|
|
3
|
+
The library publishes each factor set as a zipped CSV of percentages, updated
|
|
4
|
+
monthly from CRSP. This is what ``pandas_datareader.famafrench`` reads, without
|
|
5
|
+
the dependency: one request per file, cached for a week -- the files change
|
|
6
|
+
once a month, and a backtest service builds many reports a day.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
import urllib.request
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
LIBRARY_URL = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/{name}_CSV.zip"
|
|
22
|
+
|
|
23
|
+
FIVE_FACTORS = "F-F_Research_Data_5_Factors_2x3_daily"
|
|
24
|
+
THREE_FACTORS = "F-F_Research_Data_Factors_daily"
|
|
25
|
+
MOMENTUM = "F-F_Momentum_Factor_daily"
|
|
26
|
+
|
|
27
|
+
#: Model name -> (description, the library files it is made of).
|
|
28
|
+
MODELS = {
|
|
29
|
+
"ff3": ("Fama-French 3 factors", (THREE_FACTORS,)),
|
|
30
|
+
"ff3_mom": ("Carhart 4 factors (Fama-French 3 + momentum)", (THREE_FACTORS, MOMENTUM)),
|
|
31
|
+
"ff5": ("Fama-French 5 factors", (FIVE_FACTORS,)),
|
|
32
|
+
"ff5_mom": ("Fama-French 5 factors + momentum", (FIVE_FACTORS, MOMENTUM)),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#: What each factor is long and short of, for the page's glossary.
|
|
36
|
+
FACTOR_DESCRIPTIONS = {
|
|
37
|
+
"Mkt-RF": "the US stock market over the risk-free rate",
|
|
38
|
+
"SMB": "size: small companies minus big ones",
|
|
39
|
+
"HML": "value: high book-to-market (cheap) minus low (expensive)",
|
|
40
|
+
"RMW": "profitability: robust operating profitability minus weak",
|
|
41
|
+
"CMA": "investment: companies that invest conservatively minus aggressively",
|
|
42
|
+
"MOM": "momentum: the past year's winners minus its losers",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
CACHE_MAX_AGE = 7 * 24 * 3600
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fama_french_daily(model: str = "ff5_mom", cache_dir: str | Path | None = None,
|
|
49
|
+
timeout: float = 8.0) -> pd.DataFrame:
|
|
50
|
+
"""Daily factor returns as decimals, one row per day at midnight UTC, plus ``RF``.
|
|
51
|
+
|
|
52
|
+
Raises when a file can be neither downloaded nor found in the cache; a
|
|
53
|
+
stale cached copy is used when the download fails.
|
|
54
|
+
"""
|
|
55
|
+
if model not in MODELS:
|
|
56
|
+
raise ValueError(f"unknown factor model {model!r}; one of {sorted(MODELS)}")
|
|
57
|
+
cache = Path(cache_dir or os.getenv("ZIPFOLIO_CACHE_DIR") or Path.home() / ".cache" / "zipfolio")
|
|
58
|
+
frames = [_parse(_fetch(name, cache, timeout)) for name in MODELS[model][1]]
|
|
59
|
+
factors = pd.concat(frames, axis="columns", join="inner")
|
|
60
|
+
return factors.loc[:, ~factors.columns.duplicated()] / 100.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def model_description(model: str) -> str:
|
|
64
|
+
return MODELS[model][0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fetch(name: str, cache: Path, timeout: float) -> bytes:
|
|
68
|
+
path = cache / f"{name}.zip"
|
|
69
|
+
if path.exists() and time.time() - path.stat().st_mtime < CACHE_MAX_AGE:
|
|
70
|
+
return path.read_bytes()
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(LIBRARY_URL.format(name=name), timeout=timeout) as response:
|
|
73
|
+
payload = response.read()
|
|
74
|
+
except Exception:
|
|
75
|
+
if path.exists():
|
|
76
|
+
print(f"Warning: could not refresh {name}; using the cached copy from "
|
|
77
|
+
f"{time.strftime('%Y-%m-%d', time.gmtime(path.stat().st_mtime))}.")
|
|
78
|
+
return path.read_bytes()
|
|
79
|
+
raise
|
|
80
|
+
try:
|
|
81
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
path.write_bytes(payload)
|
|
83
|
+
except OSError as error:
|
|
84
|
+
print(f"Warning: could not cache {name}: {error}")
|
|
85
|
+
return payload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse(payload: bytes) -> pd.DataFrame:
|
|
89
|
+
"""The daily table of one library file: a few lines of notes, a header, ``YYYYMMDD,...`` rows."""
|
|
90
|
+
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
|
91
|
+
text = archive.read(archive.namelist()[0]).decode("latin-1")
|
|
92
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
93
|
+
first = next(index for index, line in enumerate(lines) if re.match(r"^\d{8}\s*,", line))
|
|
94
|
+
header = next(lines[index] for index in range(first - 1, -1, -1) if lines[index].startswith(","))
|
|
95
|
+
columns = [column.strip() for column in header.split(",")[1:]]
|
|
96
|
+
rows = []
|
|
97
|
+
for line in lines[first:]:
|
|
98
|
+
if not re.match(r"^\d{8}\s*,", line):
|
|
99
|
+
break
|
|
100
|
+
cells = [cell.strip() for cell in line.split(",")]
|
|
101
|
+
rows.append([cells[0], *map(float, cells[1:len(columns) + 1])])
|
|
102
|
+
frame = pd.DataFrame(rows, columns=["date", *columns])
|
|
103
|
+
frame.index = pd.DatetimeIndex(pd.to_datetime(frame.pop("date"), format="%Y%m%d")).tz_localize("UTC")
|
|
104
|
+
return frame.rename(columns={"Mom": "MOM", "Mom ": "MOM"})
|
zipfolio/inputs.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Shaping a backtest into the frames pyfolio and skfolio read.
|
|
2
|
+
|
|
3
|
+
Each function takes the plain frames a backtester already has -- per-period returns,
|
|
4
|
+
one row per open position per day, one row per fill, daily bars -- and returns the
|
|
5
|
+
shape the analytics expect: one row per session at midnight UTC, positions in
|
|
6
|
+
dollars with a ``cash`` column, fills indexed by time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def daily_returns(returns: pd.Series) -> pd.Series:
|
|
16
|
+
"""Return ``returns`` as one compounded return per calendar day, indexed in UTC.
|
|
17
|
+
|
|
18
|
+
pyfolio annualises with 252 periods and aligns returns, positions and
|
|
19
|
+
transactions by normalised date, so it needs exactly one row per session.
|
|
20
|
+
ziplime emits one already, but at the session close rather than at
|
|
21
|
+
midnight, and an intraday emission would emit several -- compounding them
|
|
22
|
+
is the only way that keeps the curve the same.
|
|
23
|
+
|
|
24
|
+
UTC because the fills pyfolio lines up against these dates carry a zone, and
|
|
25
|
+
pyfolio compares them as they are.
|
|
26
|
+
"""
|
|
27
|
+
returns = pd.to_numeric(returns, errors="coerce").dropna()
|
|
28
|
+
if returns.empty:
|
|
29
|
+
return returns.astype(float)
|
|
30
|
+
|
|
31
|
+
dates = utc_dates(returns.index)
|
|
32
|
+
daily = (1.0 + returns.to_numpy()).astype(float)
|
|
33
|
+
compounded = pd.Series(daily, index=dates).groupby(level=0).prod() - 1.0
|
|
34
|
+
return compounded.rename(returns.name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def positions_in_dollars(
|
|
38
|
+
position_records: pd.DataFrame,
|
|
39
|
+
cash: pd.Series,
|
|
40
|
+
dates: pd.DatetimeIndex,
|
|
41
|
+
) -> pd.DataFrame | None:
|
|
42
|
+
"""Build pyfolio's positions frame: one column per symbol in dollars, plus ``cash``.
|
|
43
|
+
|
|
44
|
+
``position_records`` is one row per open position per day, with ``date``,
|
|
45
|
+
``symbol``, ``quantity`` and ``last_sale_price`` -- what a ziplime run
|
|
46
|
+
yields when its positions are snapshotted bar by bar. A day with no open
|
|
47
|
+
position has no row, so it comes back as zeros rather than missing.
|
|
48
|
+
|
|
49
|
+
The value is quantity times price. For a futures contract that leaves out
|
|
50
|
+
the multiplier, which understates its exposure; the records carry no
|
|
51
|
+
multiplier to correct it with.
|
|
52
|
+
"""
|
|
53
|
+
if cash is None or cash.empty or len(dates) == 0:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
cash = pd.to_numeric(cash, errors="coerce")
|
|
57
|
+
cash.index = utc_dates(cash.index)
|
|
58
|
+
cash = cash[~cash.index.duplicated(keep="last")].reindex(dates).ffill()
|
|
59
|
+
|
|
60
|
+
if position_records is None or position_records.empty:
|
|
61
|
+
holdings = pd.DataFrame(index=dates)
|
|
62
|
+
else:
|
|
63
|
+
records = position_records.dropna(subset=["symbol"]).copy()
|
|
64
|
+
records["date"] = utc_dates(records["date"])
|
|
65
|
+
records["value"] = (
|
|
66
|
+
pd.to_numeric(records["quantity"], errors="coerce")
|
|
67
|
+
* pd.to_numeric(records["last_sale_price"], errors="coerce")
|
|
68
|
+
)
|
|
69
|
+
holdings = records.pivot_table(
|
|
70
|
+
index="date", columns="symbol", values="value", aggfunc="sum",
|
|
71
|
+
)
|
|
72
|
+
holdings = holdings.reindex(dates).fillna(0.0)
|
|
73
|
+
holdings.columns = [str(column) for column in holdings.columns]
|
|
74
|
+
holdings.columns.name = None
|
|
75
|
+
|
|
76
|
+
holdings["cash"] = cash.fillna(0.0)
|
|
77
|
+
return holdings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def transactions_frame(transactions: pd.DataFrame) -> pd.DataFrame | None:
|
|
81
|
+
"""Build pyfolio's transactions frame from the flattened ziplime transactions.
|
|
82
|
+
|
|
83
|
+
pyfolio wants the fill time as the index and ``amount``, ``price`` and
|
|
84
|
+
``symbol`` as columns; the rest of what ziplime records is dropped.
|
|
85
|
+
"""
|
|
86
|
+
if transactions is None or transactions.empty:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
frame = pd.DataFrame({
|
|
90
|
+
"amount": pd.to_numeric(transactions["quantity"], errors="coerce"),
|
|
91
|
+
"price": pd.to_numeric(transactions["price"], errors="coerce"),
|
|
92
|
+
"symbol": transactions["symbol"].astype(str),
|
|
93
|
+
})
|
|
94
|
+
frame.index = pd.DatetimeIndex(pd.to_datetime(transactions["dt"], errors="coerce", utc=True))
|
|
95
|
+
frame = frame[frame.index.notna()].dropna(subset=["amount", "price"])
|
|
96
|
+
frame = frame[frame["amount"] != 0].sort_index()
|
|
97
|
+
return frame if not frame.empty else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def market_data_frame(
|
|
101
|
+
bars: pd.DataFrame | None,
|
|
102
|
+
symbols,
|
|
103
|
+
dates: pd.DatetimeIndex,
|
|
104
|
+
) -> pd.DataFrame | None:
|
|
105
|
+
"""Build pyfolio's ``market_data``: rows ``(date, "price" | "volume")``, a column per symbol.
|
|
106
|
+
|
|
107
|
+
``bars`` is one row per instrument per session with ``symbol``, ``day``,
|
|
108
|
+
``price`` and ``volume`` -- what ``backtesting`` reads out of the market
|
|
109
|
+
bundle the simulation ran on. Only the traded symbols are kept: the bundle
|
|
110
|
+
also holds the benchmark and, for a universe, hundreds of names the
|
|
111
|
+
strategy never bought.
|
|
112
|
+
"""
|
|
113
|
+
if bars is None or bars.empty or not len(symbols):
|
|
114
|
+
return None
|
|
115
|
+
bars = bars[bars["symbol"].astype(str).isin(set(symbols))]
|
|
116
|
+
if bars.empty:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
bars = bars.assign(date=utc_dates(bars["day"]))
|
|
120
|
+
wide = bars.pivot_table(index="date", columns="symbol", values=["price", "volume"], aggfunc="last")
|
|
121
|
+
wide = wide.reindex(index=dates)
|
|
122
|
+
frame = wide.stack(level=0, future_stack=True)
|
|
123
|
+
frame.index.names = ["date", "field"]
|
|
124
|
+
frame.columns.name = None
|
|
125
|
+
return frame.reindex(columns=sorted(set(symbols)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def utc_dates(values) -> pd.DatetimeIndex:
|
|
129
|
+
"""Normalise timestamps to midnight UTC of their own calendar date.
|
|
130
|
+
|
|
131
|
+
The date is taken in the timestamp's own zone first: a Moscow session
|
|
132
|
+
closing at 18:50 local is that day, and so is a New York one at 16:00.
|
|
133
|
+
"""
|
|
134
|
+
index = pd.DatetimeIndex(pd.to_datetime(values, errors="coerce"))
|
|
135
|
+
if index.tz is not None:
|
|
136
|
+
index = index.tz_localize(None)
|
|
137
|
+
return index.normalize().tz_localize("UTC")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def usable_benchmark(benchmark: pd.Series | None, dates: pd.DatetimeIndex) -> pd.Series | None:
|
|
141
|
+
"""Return the benchmark aligned to ``dates``, or ``None`` when there is none.
|
|
142
|
+
|
|
143
|
+
A backtester often hands a run without a benchmark a series of zeros, so
|
|
144
|
+
its own metrics have a column to fill. pyfolio would divide by its zero
|
|
145
|
+
variance for beta and draw a flat line, so that series counts as no
|
|
146
|
+
benchmark here.
|
|
147
|
+
"""
|
|
148
|
+
if benchmark is None:
|
|
149
|
+
return None
|
|
150
|
+
benchmark = daily_returns(benchmark)
|
|
151
|
+
if benchmark.empty or np.allclose(benchmark.to_numpy(), 0.0):
|
|
152
|
+
return None
|
|
153
|
+
return benchmark.reindex(dates).fillna(0.0)
|
zipfolio/page.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The page: sections of charts and tables in QuantStats Pro's layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .tables import Table, table_html
|
|
13
|
+
|
|
14
|
+
#: Tables wider than this many columns go under the charts rather than beside them.
|
|
15
|
+
SIDE_TABLE_MAX_COLUMNS = 3
|
|
16
|
+
|
|
17
|
+
#: Where Zipfolio lives; every report links to it, in the header and the footer,
|
|
18
|
+
#: next to the version that built it.
|
|
19
|
+
REPOSITORY_URL = "https://github.com/Limex-com/zipfolio"
|
|
20
|
+
|
|
21
|
+
#: The report's title unless the caller names it.
|
|
22
|
+
DEFAULT_TITLE = "Zipfolio Tearsheet: Portfolio, Execution & Cost Analysis"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Section:
|
|
27
|
+
"""One block of the page: charts on the left, narrow tables on the right, wide ones under both."""
|
|
28
|
+
|
|
29
|
+
title: str
|
|
30
|
+
charts: list[str] = field(default_factory=list)
|
|
31
|
+
side: list[str] = field(default_factory=list)
|
|
32
|
+
wide: list[str] = field(default_factory=list)
|
|
33
|
+
failed: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def empty(self) -> bool:
|
|
37
|
+
return not (self.charts or self.side or self.wide)
|
|
38
|
+
|
|
39
|
+
def chart(self, svg: str | None) -> None:
|
|
40
|
+
if svg:
|
|
41
|
+
self.charts.append(svg)
|
|
42
|
+
|
|
43
|
+
def table(self, table: "Table", **kwargs) -> None:
|
|
44
|
+
rendered = table_html(table, **kwargs)
|
|
45
|
+
wide = len(table.frame.columns) > SIDE_TABLE_MAX_COLUMNS
|
|
46
|
+
(self.wide if wide else self.side).append(rendered)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _section_html(section: Section) -> str:
|
|
50
|
+
parts = [f'<section><h2>{html.escape(section.title)}</h2>']
|
|
51
|
+
if section.failed:
|
|
52
|
+
parts.append('<p class="note">This section could not be computed for this backtest.</p>')
|
|
53
|
+
else:
|
|
54
|
+
charts = "\n".join(f'<div class="chart">{svg}</div>' for svg in section.charts)
|
|
55
|
+
side = "\n".join(section.side)
|
|
56
|
+
parts.append(f'<div class="row"><div class="left">{charts}</div><div class="right">{side}</div></div>')
|
|
57
|
+
if section.wide:
|
|
58
|
+
parts.append(f'<div class="wide">{"".join(section.wide)}</div>')
|
|
59
|
+
parts.append("</section>")
|
|
60
|
+
return "\n".join(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def page_html(title: str, returns: pd.Series, benchmark, analytics: str, sections: list[Section]) -> str:
|
|
64
|
+
"""The whole page. ``analytics`` names the libraries the numbers came from, with versions."""
|
|
65
|
+
period = f"{returns.index[0]:%-d %b, %Y} - {returns.index[-1]:%-d %b, %Y}"
|
|
66
|
+
generated = f"{datetime.now(timezone.utc):%-d %b, %Y %H:%M} UTC"
|
|
67
|
+
benchmark_name = html.escape(str(benchmark.name)) if benchmark is not None and benchmark.name else (
|
|
68
|
+
"Benchmark" if benchmark is not None else "none")
|
|
69
|
+
body = "\n<hr>\n".join(_section_html(section) for section in sections)
|
|
70
|
+
return f"""<!DOCTYPE html>
|
|
71
|
+
<html lang="en">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="utf-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
75
|
+
<meta name="generator" content="Zipfolio">
|
|
76
|
+
<meta name="robots" content="noindex, nofollow">
|
|
77
|
+
<title>{html.escape(title)}</title>
|
|
78
|
+
<style>
|
|
79
|
+
body {{ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
80
|
+
margin: 30px; background: #fff; color: #000; }}
|
|
81
|
+
body, p, table, td, th {{ font: 13px/1.4 Arial, sans-serif; }}
|
|
82
|
+
.container {{ max-width: 960px; margin: auto; }}
|
|
83
|
+
h1, h2, h3, h4 {{ font-weight: 400; margin: 0; }}
|
|
84
|
+
h1 {{ font-size: 26px; }}
|
|
85
|
+
h1 dt {{ display: inline; margin-left: 10px; font-size: 14px; }}
|
|
86
|
+
h2 {{ font-size: 18px; font-weight: 700; margin-bottom: 18px; }}
|
|
87
|
+
h3 {{ margin-bottom: 10px; font-weight: 700; }}
|
|
88
|
+
h4 {{ color: grey; margin-top: 4px; }}
|
|
89
|
+
h4 strong {{ color: #333; }}
|
|
90
|
+
h4 a, h4 a strong, footer a {{ color: #09c; text-decoration: none; white-space: nowrap; }}
|
|
91
|
+
h4 a:hover, footer a:hover {{ color: #069; text-decoration: underline; }}
|
|
92
|
+
hr {{ margin: 25px 0 40px; height: 0; border: 0; border-top: 1px solid #ccc; }}
|
|
93
|
+
.row {{ display: flex; gap: 20px; align-items: flex-start; }}
|
|
94
|
+
.left {{ flex: 0 0 620px; min-width: 0; }}
|
|
95
|
+
.right {{ flex: 1 1 320px; min-width: 0; }}
|
|
96
|
+
.chart {{ margin: 0 0 22px; }}
|
|
97
|
+
.chart svg {{ display: block; width: 100%; height: auto; }}
|
|
98
|
+
table {{ margin: 0 0 30px; border: 0; border-spacing: 0; width: 100%; }}
|
|
99
|
+
table td, table th {{ text-align: right; padding: 4px 5px 3px 5px; }}
|
|
100
|
+
table th {{ padding: 6px 5px 5px 5px; }}
|
|
101
|
+
table td:first-of-type, table th:first-of-type {{ text-align: left; padding-left: 2px; }}
|
|
102
|
+
table td:last-of-type, table th:last-of-type {{ padding-right: 2px; }}
|
|
103
|
+
table thead th {{ font-weight: 700; background: #2d2d2d; color: #fff; }}
|
|
104
|
+
tr.sep td {{ padding: 0; }}
|
|
105
|
+
tr.sep hr {{ margin: 5px 0; }}
|
|
106
|
+
.table, .wide {{ overflow-x: auto; }}
|
|
107
|
+
.empty {{ color: grey; font-style: italic; margin: -4px 0 26px; }}
|
|
108
|
+
.caption {{ color: grey; margin: -14px 0 26px; }}
|
|
109
|
+
.explain {{ margin: 0 0 10px; }}
|
|
110
|
+
.explain:last-of-type {{ margin-bottom: 26px; }}
|
|
111
|
+
code {{ font: 12px Menlo, Consolas, monospace; background: #f3f3f3; padding: 0 3px; }}
|
|
112
|
+
.check {{ display: flex; gap: 10px; margin: 0 0 12px; align-items: flex-start; }}
|
|
113
|
+
.check:last-child {{ margin-bottom: 26px; }}
|
|
114
|
+
.mark {{ flex: 0 0 20px; height: 20px; border-radius: 50%; color: #fff; font-weight: 700;
|
|
115
|
+
font-size: 12px; line-height: 20px; text-align: center; }}
|
|
116
|
+
.mark.ok {{ background: #6B9E00; }}
|
|
117
|
+
.mark.warn {{ background: #D9A400; }}
|
|
118
|
+
.mark.bad {{ background: #BA516B; }}
|
|
119
|
+
.mark.info {{ background: #8A8A8A; font-style: italic; font-family: Georgia, serif; }}
|
|
120
|
+
.note {{ color: #9a6700; }}
|
|
121
|
+
footer {{ color: grey; font-size: 12px; margin-top: 10px; }}
|
|
122
|
+
@media (max-width: 1000px) {{
|
|
123
|
+
body {{ margin: 16px; }}
|
|
124
|
+
.row {{ flex-direction: column; }}
|
|
125
|
+
.left, .right {{ flex: 1 1 auto; width: 100%; }}
|
|
126
|
+
}}
|
|
127
|
+
@media print {{
|
|
128
|
+
body {{ margin: 0; }}
|
|
129
|
+
.container {{ max-width: 100%; }}
|
|
130
|
+
section {{ break-inside: avoid-page; }}
|
|
131
|
+
}}
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<div class="container">
|
|
136
|
+
<h1>{html.escape(title)} <dt>{period}</dt></h1>
|
|
137
|
+
<h4>Benchmark: {benchmark_name} • Sessions: {len(returns):,} • Generated by <a href="{REPOSITORY_URL}" target="_blank" rel="noopener" title="{REPOSITORY_URL}"><strong>ZipLime Zipfolio v{__version__}</strong></a> on {generated}</h4>
|
|
138
|
+
<hr>
|
|
139
|
+
{body}
|
|
140
|
+
<hr>
|
|
141
|
+
<footer>Built with <a href="{REPOSITORY_URL}" target="_blank" rel="noopener">Zipfolio v{__version__}</a>, part of the ZipLime ecosystem · generated on {generated} · {analytics} · <a href="{REPOSITORY_URL}" target="_blank" rel="noopener">github.com/Limex-com/zipfolio</a></footer>
|
|
142
|
+
</div>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
145
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module per section of the tearsheet; each exposes ``build_<section>``."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""How much capital the strategy could take before liquidity binds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ..page import Section
|
|
8
|
+
from ..style import chart
|
|
9
|
+
from ..tables import Table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_capacity(returns, positions, transactions, market_data) -> Section:
|
|
13
|
+
"""Days to liquidate, low-liquidity fills and the capital-base sweep.
|
|
14
|
+
|
|
15
|
+
The same analysis as ``create_capacity_tear_sheet``, with its daily-volume
|
|
16
|
+
join repaired: ``capacity.daily_txns_with_bar_data`` ends in
|
|
17
|
+
``.set_index("date").asfreq("D")``, and the index it sets holds one row per
|
|
18
|
+
symbol per day -- so any strategy that trades two names on the same day
|
|
19
|
+
fails with "cannot reindex on an axis with duplicate labels". The ``asfreq``
|
|
20
|
+
only inserts empty days, which none of its callers need: the liquidity
|
|
21
|
+
tables group by symbol and the slippage sweep resamples by day. The repaired
|
|
22
|
+
copy is in place for this one call.
|
|
23
|
+
"""
|
|
24
|
+
from pyfolio import capacity, plotting
|
|
25
|
+
|
|
26
|
+
section = Section("Capacity")
|
|
27
|
+
original = capacity.daily_txns_with_bar_data
|
|
28
|
+
capacity.daily_txns_with_bar_data = _daily_txns_with_bar_data
|
|
29
|
+
try:
|
|
30
|
+
last_n_days = 126
|
|
31
|
+
section.side.append(
|
|
32
|
+
'<p class="explain">At a constant $1m capital base, taking at most 20% of '
|
|
33
|
+
'a day\'s volume, with the trailing 5-day mean as that volume.</p>')
|
|
34
|
+
for label, window in (("Whole Backtest", None), (f"Last {last_n_days} Sessions", last_n_days)):
|
|
35
|
+
days = capacity.get_max_days_to_liquidate_by_ticker(
|
|
36
|
+
positions, market_data, max_bar_consumption=0.2, capital_base=1e6,
|
|
37
|
+
mean_volume_window=5, last_n_days=window)
|
|
38
|
+
section.table(
|
|
39
|
+
Table(days[days["days_to_liquidate"] > 1], None, None,
|
|
40
|
+
{"days_to_liquidate": "{:.1f}".format, "pos_alloc_pct": "{:.2f}%".format}),
|
|
41
|
+
title=f"Over a Day to Liquidate: {label}", first_header="Symbol",
|
|
42
|
+
rename={"date": "Date", "days_to_liquidate": "Days", "pos_alloc_pct": "Allocation"})
|
|
43
|
+
for label, window in (("Whole Backtest", None), (f"Last {last_n_days} Sessions", last_n_days)):
|
|
44
|
+
fills = capacity.get_low_liquidity_transactions(transactions, market_data, last_n_days=window)
|
|
45
|
+
section.table(
|
|
46
|
+
Table(fills[fills["max_pct_bar_consumed"] > 5], None, None,
|
|
47
|
+
{"max_pct_bar_consumed": "{:.2f}%".format}),
|
|
48
|
+
title=f"Fills Over 5% of Daily Volume: {label}", first_header="Symbol",
|
|
49
|
+
rename={"date": "Date", "max_pct_bar_consumed": "Of Volume"})
|
|
50
|
+
|
|
51
|
+
starting_capital = positions.iloc[0].sum() / (1 + returns.iloc[0])
|
|
52
|
+
section.chart(chart("Capital Base Performance Sweep", lambda ax: plotting.plot_capacity_sweep(
|
|
53
|
+
returns, transactions, market_data, starting_capital,
|
|
54
|
+
min_pv=100000, max_pv=300000000, step_size=1000000, ax=ax), height=3.5))
|
|
55
|
+
finally:
|
|
56
|
+
capacity.daily_txns_with_bar_data = original
|
|
57
|
+
return section
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _daily_txns_with_bar_data(transactions, market_data):
|
|
61
|
+
"""``pyfolio.capacity.daily_txns_with_bar_data`` without the ``asfreq`` -- see ``build_capacity``."""
|
|
62
|
+
transactions.index.name = "date"
|
|
63
|
+
txn_daily = pd.DataFrame(
|
|
64
|
+
transactions.assign(amount=abs(transactions.amount))
|
|
65
|
+
.groupby(["symbol", pd.Grouper(freq="D")])
|
|
66
|
+
.sum()["amount"]
|
|
67
|
+
)
|
|
68
|
+
txn_daily["price"] = market_data.xs("price", level=1).unstack()
|
|
69
|
+
txn_daily["volume"] = market_data.xs("volume", level=1).unstack()
|
|
70
|
+
return txn_daily.reset_index().set_index("date").sort_index()
|