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 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} &bull; Sessions: {len(returns):,} &bull; 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 &middot; generated on {generated} &middot; {analytics} &middot; <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()