portpy-quant 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.
portpy/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ PortPy: portfolio analysis, optimization, and management.
3
+
4
+ The main entry point is :class:`Portfolio`. Every metric/chart/model/strategy
5
+ can explain itself in plain language - call `.explain()` on a result, or
6
+ `portpy.explain("name")` directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import portpy.explain as explain_module
14
+ from portpy import core, metrics
15
+ from portpy.explain import Explanation, MetricResult, available, get, register
16
+ from portpy.portfolio import Portfolio
17
+
18
+ __version__ = "0.1.0"
19
+
20
+
21
+ class _ExplainAPI:
22
+ """Callable explain facade that also exposes registry helpers."""
23
+
24
+ def __call__(self, obj: Any, *, value: Any = None, print_it: bool = True) -> str:
25
+ return explain_module.explain(obj, value=value, print_it=print_it)
26
+
27
+ def available(self, category: str | None = None) -> list[str]:
28
+ return explain_module.available(category)
29
+
30
+ def get(self, name: str) -> Explanation:
31
+ return explain_module.get(name)
32
+
33
+ def register(self, explanation: Explanation) -> Explanation:
34
+ return explain_module.register(explanation)
35
+
36
+
37
+ explain = _ExplainAPI()
38
+
39
+ __all__ = [
40
+ "Portfolio",
41
+ "explain",
42
+ "Explanation",
43
+ "MetricResult",
44
+ "available",
45
+ "get",
46
+ "register",
47
+ "core",
48
+ "metrics",
49
+ "__version__",
50
+ ]
@@ -0,0 +1,19 @@
1
+ """
2
+ Core building blocks: asset tagging, weights, calendar alignment, currency conversion.
3
+ """
4
+
5
+ from portpy.core.asset import ALWAYS_ON_CLASSES, AssetClass
6
+ from portpy.core.calendar import align_calendars, calendar_coverage_report, detect_frequency
7
+ from portpy.core.currency import convert_to_base_currency
8
+ from portpy.core.weights import equal_weights, normalize_weights
9
+
10
+ __all__ = [
11
+ "AssetClass",
12
+ "ALWAYS_ON_CLASSES",
13
+ "align_calendars",
14
+ "calendar_coverage_report",
15
+ "detect_frequency",
16
+ "convert_to_base_currency",
17
+ "equal_weights",
18
+ "normalize_weights",
19
+ ]
portpy/core/asset.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ Lightweight asset tagging, used only for reporting/grouping, but never required.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+
9
+
10
+ class AssetClass(str, Enum):
11
+ """
12
+ Optional tag for an asset's broad class.
13
+
14
+ Passing these to :class:`~portpy.portfolio.Portfolio` (via ``asset_classes=``)
15
+ is purely informational - it powers grouping in tearsheets and reminds you which
16
+ assets trade around the clock (crypto) vs. only on business days (equities,
17
+ bonds), but PortPy never uses it to silently reindex or fill your data.
18
+ """
19
+
20
+ EQUITY = "equity"
21
+ CRYPTO = "crypto"
22
+ BOND = "bond"
23
+ COMMODITY = "commodity"
24
+ FX = "fx"
25
+ REAL_ESTATE = "real_estate"
26
+ OTHER = "other"
27
+
28
+
29
+ """
30
+ Asset classes that typically trade 24/7 - useful when choosing a calendar-alignment method.
31
+ """
32
+ ALWAYS_ON_CLASSES = frozenset({AssetClass.CRYPTO, AssetClass.FX})
@@ -0,0 +1,110 @@
1
+ """
2
+ Calendar-alignment helpers for combining assets with different trading calendars.
3
+
4
+ PortPy's `Portfolio` never aligns or fills data for you (see the package README) -
5
+ if you build a DataFrame from, say, Bitcoin (trades every day) and a stock (trades
6
+ Mon-Fri), the stock's columns will have NaNs on weekends. Call one of these
7
+ functions *before* constructing a `Portfolio` to resolve that deliberately, rather
8
+ than accidentally.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pandas as pd
14
+
15
+ _METHODS = ("intersection", "ffill_union", "business_days")
16
+
17
+
18
+ def detect_frequency(index: pd.DatetimeIndex) -> str:
19
+ """
20
+ Roughly classify a DatetimeIndex's spacing (informational only).
21
+
22
+ Returns one of: "daily-7" (includes weekends, e.g. crypto), "daily-5" (business
23
+ days, e.g. equities/bonds), "weekly", "monthly", "irregular", or "unknown" (too
24
+ few points to tell).
25
+ """
26
+ if len(index) < 3:
27
+ return "unknown"
28
+ diffs = pd.Series(index).diff().dropna().dt.days
29
+ median = diffs.median()
30
+ if median <= 1.5:
31
+ weekdays = pd.Index(index).dayofweek
32
+ has_weekend = bool(((weekdays == 5) | (weekdays == 6)).any())
33
+ return "daily-7" if has_weekend else "daily-5"
34
+ if median <= 4:
35
+ return "weekly"
36
+ if median <= 10:
37
+ return "biweekly"
38
+ if median <= 35:
39
+ return "monthly"
40
+ return "irregular"
41
+
42
+
43
+ def align_calendars(
44
+ prices: dict[str, pd.Series] | pd.DataFrame,
45
+ method: str = "intersection",
46
+ ) -> pd.DataFrame:
47
+ """
48
+ Combine assets that trade on different calendars into one aligned DataFrame.
49
+
50
+ Args:
51
+ prices: Either a dict of ``{asset_name: price_series}`` or an already-
52
+ combined DataFrame (columns = assets) that may contain NaNs from
53
+ mismatched calendars.
54
+ method: One of:
55
+
56
+ - ``"intersection"``: keep only dates where *every* asset has a price
57
+ (drops all weekend/holiday rows if any asset is business-days-only).
58
+ Use this to analyze on the slowest asset's calendar (e.g. equities).
59
+ - ``"ffill_union"``: take the union of all dates, forward-fill gaps
60
+ (e.g. carries Friday's stock close through the weekend), then drop
61
+ any remaining leading NaNs. Use this to keep crypto's 24/7 calendar
62
+ while still pricing equities every day.
63
+ - ``"business_days"``: reindex everything onto a Mon-Fri business-day
64
+ calendar, forward-filling gaps, then drop remaining leading NaNs.
65
+ Use this to standardize on the equity/bond calendar even if the
66
+ combined frame currently has 7-day-a-week rows.
67
+
68
+ Returns:
69
+ A DataFrame with no NaNs, ready to pass to
70
+ :class:`~portpy.portfolio.Portfolio`.
71
+ """
72
+ if method not in _METHODS:
73
+ raise ValueError(f"method must be one of {_METHODS}, got {method!r}.")
74
+
75
+ if isinstance(prices, dict):
76
+ df = pd.concat(prices, axis=1)
77
+ else:
78
+ df = prices.copy()
79
+ df = df.sort_index()
80
+
81
+ if method == "intersection":
82
+ return df.dropna(how="any")
83
+ if method == "ffill_union":
84
+ return df.ffill().dropna(how="any")
85
+ # business_days
86
+ bdays = pd.bdate_range(df.index.min(), df.index.max())
87
+ return df.reindex(bdays).ffill().dropna(how="any")
88
+
89
+
90
+ def calendar_coverage_report(prices: pd.DataFrame) -> pd.DataFrame:
91
+ """
92
+ Per-asset diagnostic: detected frequency, date range, and NaN count.
93
+
94
+ Run this before choosing an `align_calendars` method - it tells you which
95
+ assets are actually mismatched and by how much.
96
+ """
97
+ rows = []
98
+ for col in prices.columns:
99
+ s = prices[col].dropna()
100
+ rows.append(
101
+ {
102
+ "asset": col,
103
+ "frequency": detect_frequency(pd.DatetimeIndex(s.index)),
104
+ "first_date": s.index.min() if len(s) else pd.NaT,
105
+ "last_date": s.index.max() if len(s) else pd.NaT,
106
+ "n_observations": len(s),
107
+ "n_missing_in_frame": int(prices[col].isna().sum()),
108
+ }
109
+ )
110
+ return pd.DataFrame(rows).set_index("asset")
@@ -0,0 +1,52 @@
1
+ """
2
+ Optional currency-conversion helper.
3
+
4
+ By default, PortPy assumes every asset in your DataFrame is denominated in the
5
+ same currency and never converts anything. If you *do* have a dated FX-rate
6
+ series, use `convert_to_base_currency` before constructing a `Portfolio` to bring
7
+ everything into one base currency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pandas as pd
13
+
14
+
15
+ def convert_to_base_currency(
16
+ prices: pd.DataFrame,
17
+ fx_rates: pd.DataFrame,
18
+ asset_currencies: dict[str, str],
19
+ base_currency: str,
20
+ ) -> pd.DataFrame:
21
+ """
22
+ Convert a multi-asset price DataFrame into a single base currency.
23
+
24
+ Args:
25
+ prices: Columns = asset symbols, index = dates, values = prices in each
26
+ asset's *native* currency.
27
+ fx_rates: Columns = 3-letter currency codes, index = dates, values = units
28
+ of `base_currency` per 1 unit of that currency (a direct multiplier -
29
+ e.g. an "EUR" column of 1.08 means 1 EUR = 1.08 of `base_currency`).
30
+ Reindexed to `prices.index` and forward-filled, so it doesn't need to
31
+ share the exact same calendar.
32
+ asset_currencies: Mapping of asset symbol -> currency code. Assets already
33
+ in `base_currency` (or missing from this dict) are left untouched.
34
+ base_currency: The target currency code (e.g. "USD").
35
+
36
+ Returns:
37
+ A new DataFrame, same shape as `prices`, denominated in `base_currency`.
38
+ """
39
+ converted = prices.copy()
40
+ fx_aligned = fx_rates.reindex(prices.index).ffill()
41
+
42
+ for asset, ccy in asset_currencies.items():
43
+ if asset not in converted.columns or ccy == base_currency:
44
+ continue
45
+ if ccy not in fx_aligned.columns:
46
+ raise ValueError(
47
+ f"No FX rate column found for currency {ccy!r} (needed by asset {asset!r}). "
48
+ f"fx_rates columns: {list(fx_rates.columns)}"
49
+ )
50
+ converted[asset] = converted[asset] * fx_aligned[ccy]
51
+
52
+ return converted
portpy/core/weights.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ Portfolio weight validation and normalization.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+
11
+ def equal_weights(names: list[str]) -> pd.Series:
12
+ """
13
+ Build a 1/N weight vector for the given asset names.
14
+ """
15
+ n = len(names)
16
+ if n == 0:
17
+ raise ValueError("Cannot build weights for zero assets.")
18
+ return pd.Series(np.full(n, 1.0 / n), index=list(names), name="weight")
19
+
20
+
21
+ def normalize_weights(
22
+ weights: pd.Series | np.ndarray | dict | list,
23
+ names: list[str] | None = None,
24
+ allow_negative: bool = True,
25
+ ) -> pd.Series:
26
+ """
27
+ Validate a weights input and rescale it so it sums to 1.
28
+
29
+ Args:
30
+ weights: A Series/dict keyed by asset name, or a plain array/list aligned
31
+ with `names`.
32
+ names: Required asset order when `weights` isn't already labeled; also used
33
+ to check that every asset has a weight.
34
+ allow_negative: If False, raise when any weight is negative (e.g. for
35
+ long-only optimizers).
36
+
37
+ Returns:
38
+ A Series of weights summing to 1.0, indexed by `names` (in that order) when
39
+ `names` is given.
40
+ """
41
+ if isinstance(weights, dict):
42
+ weights = pd.Series(weights, dtype=float)
43
+ elif isinstance(weights, pd.Series):
44
+ weights = weights.astype(float)
45
+ elif isinstance(weights, (list, tuple, np.ndarray)):
46
+ if names is None:
47
+ raise ValueError("`names` is required when `weights` is a list/array.")
48
+ arr = np.asarray(weights, dtype=float)
49
+ if len(arr) != len(names):
50
+ raise ValueError(f"weights has {len(arr)} entries but {len(names)} names were given.")
51
+ weights = pd.Series(arr, index=list(names))
52
+ else:
53
+ raise TypeError(f"Unsupported weights type: {type(weights).__name__}")
54
+
55
+ if names is not None:
56
+ missing = set(names) - set(weights.index)
57
+ if missing:
58
+ raise ValueError(f"Weights are missing for assets: {sorted(missing)}")
59
+ weights = weights.reindex(list(names))
60
+
61
+ if weights.isna().any():
62
+ raise ValueError("Weights contain NaN values.")
63
+ if not allow_negative and (weights < 0).any():
64
+ raise ValueError("Negative weights are not allowed here (long-only constraint).")
65
+
66
+ total = weights.sum()
67
+ if np.isclose(total, 0.0):
68
+ raise ValueError("Weights sum to zero; cannot normalize.")
69
+ return (weights / total).rename("weight")
portpy/explain.py ADDED
@@ -0,0 +1,235 @@
1
+ """
2
+ PortPy's built-in explainability layer.
3
+
4
+ This is what turns PortPy from a "numbers and charts out" library into a teaching
5
+ tool: every metric, chart, model, and strategy can tell you, in plain language,
6
+ what it is, how to read it, and whether the number you got is good or bad.
7
+
8
+ Three pieces work together:
9
+
10
+ - :class:`Explanation` - a structured knowledge card (what it is / how to read it /
11
+ good vs. bad / caveats / formula), optionally with a function that turns a
12
+ concrete computed value into a one-line verdict.
13
+ - A module-level registry mapping names ("sharpe_ratio", "drawdown_chart",
14
+ "capm", "buy_and_hold", ...) to their :class:`Explanation`.
15
+ - :class:`MetricResult` - a float subclass returned by metric functions when
16
+ as_result=True. It behaves exactly like a float everywhere (math, comparisons,
17
+ formatting, numpy/pandas operations) but also carries a .explain() method and
18
+ a .interpretation property.
19
+
20
+ The same registry backs charts (fig.explain() in :mod:`portpy.visualization`),
21
+ models, and strategies (.describe() / .diagnose() in :mod:`portpy.models`
22
+ and :mod:`portpy.strategies`), so portpy.explain(x) works uniformly on any of
23
+ them.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ __all__ = [
33
+ "Explanation",
34
+ "MetricResult",
35
+ "register",
36
+ "get",
37
+ "explain",
38
+ "available",
39
+ ]
40
+
41
+ _CATEGORIES = ("metric", "chart", "model", "strategy", "function")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Explanation:
46
+ """
47
+ A structured, human-readable knowledge card attached to a PortPy object.
48
+
49
+ Attributes:
50
+ name: Registry key, matching the function/chart/model/strategy name
51
+ (e.g. "sharpe_ratio", "drawdown_chart", "capm").
52
+ category: One of "metric", "chart", "model", "strategy".
53
+ summary: One or two sentences on what this *is*.
54
+ how_to_read: How to read the number/axis/output in practice.
55
+ good_vs_bad: Rules of thumb for judging whether a value is good or bad.
56
+ caveats: Known limitations, edge cases, or ways this can mislead.
57
+ formula: Optional plain-text formula for reference.
58
+ interpret: Optional function mapping a concrete value to a one-line verdict,
59
+ used to render a "this result" line when a value is available.
60
+ """
61
+
62
+ name: str
63
+ category: str
64
+ summary: str
65
+ how_to_read: str
66
+ good_vs_bad: str
67
+ caveats: str = ""
68
+ formula: str = ""
69
+ interpret: Callable[[Any], str] | None = field(default=None, repr=False, compare=False)
70
+
71
+ def __post_init__(self) -> None:
72
+ if self.category not in _CATEGORIES:
73
+ raise ValueError(f"category must be one of {_CATEGORIES}, got {self.category!r}")
74
+
75
+ def render(self, value: Any = None) -> str:
76
+ """
77
+ Render this card as plain text, optionally with a value-specific verdict.
78
+ """
79
+ lines = [f"{self.name} ({self.category})", "=" * len(f"{self.name} ({self.category})")]
80
+ lines += ["", "What it is:", f" {self.summary}"]
81
+ if self.formula:
82
+ lines += ["", "Formula:", f" {self.formula}"]
83
+ lines += ["", "How to read it:", f" {self.how_to_read}"]
84
+ lines += ["", "Good vs. bad:", f" {self.good_vs_bad}"]
85
+ if self.caveats:
86
+ lines += ["", "Caveats:", f" {self.caveats}"]
87
+ if value is not None and self.interpret is not None:
88
+ try:
89
+ lines += ["", "This result:", f" {self.interpret(value)}"]
90
+ except Exception:
91
+ pass
92
+ return "\n".join(lines)
93
+
94
+
95
+ _REGISTRY: dict[str, Explanation] = {}
96
+
97
+
98
+ def register(explanation: Explanation) -> Explanation:
99
+ """
100
+ Register (or overwrite) an :class:`Explanation` in the global registry.
101
+ """
102
+ _REGISTRY[explanation.name] = explanation
103
+ return explanation
104
+
105
+
106
+ def get(name: str) -> Explanation:
107
+ """
108
+ Look up a registered :class:`Explanation` by name.
109
+ """
110
+ try:
111
+ return _REGISTRY[name]
112
+ except KeyError as exc:
113
+ raise KeyError(
114
+ f"No explanation registered for {name!r}. "
115
+ f"Call portpy.explain.available() to list all {len(_REGISTRY)} registered names."
116
+ ) from exc
117
+
118
+
119
+ def available(category: str | None = None) -> list[str]:
120
+ """
121
+ List registered explanation names, optionally filtered by category."""
122
+ if category is None:
123
+ return sorted(_REGISTRY)
124
+ return sorted(k for k, v in _REGISTRY.items() if v.category == category)
125
+
126
+
127
+ def explain(obj: Any, *, value: Any = None, print_it: bool = True) -> str:
128
+ """
129
+ Explain a metric name, :class:`MetricResult`, chart, model, or strategy.
130
+
131
+ Args:
132
+ obj: A registered name (str), a :class:`MetricResult`, or any PortPy
133
+ object that exposes a _portpy_explain_name attribute (charts, fitted
134
+ models, strategies) or a pandas .attrs["portpy_explanation"] entry.
135
+ value: Optional concrete value to generate a one-line verdict for. Inferred
136
+ automatically for :class:`MetricResult` and objects carrying
137
+ _portpy_explain_value.
138
+ print_it: If True (default), also print() the rendered text.
139
+
140
+ Returns:
141
+ The rendered explanation text.
142
+ """
143
+ name: str | None = None
144
+
145
+ if isinstance(obj, str):
146
+ name = obj
147
+ elif isinstance(obj, MetricResult):
148
+ name = obj.name
149
+ if value is None:
150
+ value = float(obj)
151
+ elif hasattr(obj, "_portpy_explain_name"):
152
+ name = obj._portpy_explain_name
153
+ if value is None:
154
+ value = getattr(obj, "_portpy_explain_value", None)
155
+ else:
156
+ attrs = getattr(obj, "attrs", None)
157
+ if isinstance(attrs, dict) and "portpy_explanation" in attrs:
158
+ name = attrs["portpy_explanation"]
159
+
160
+ if name is None:
161
+ raise TypeError(
162
+ f"Don't know how to explain an object of type {type(obj).__name__!r}. "
163
+ "Pass a registered name (str), a MetricResult, or a PortPy chart/model/strategy."
164
+ )
165
+
166
+ text = get(name).render(value)
167
+ if print_it:
168
+ print(text)
169
+ return text
170
+
171
+
172
+ class MetricResult(float):
173
+ """
174
+ A float that also knows what it means.
175
+
176
+ Arithmetic, comparisons, round(), string formatting, and numpy/pandas
177
+ interop all work exactly as they would on a plain float (this *is* a float
178
+ subclass). On top of that, it carries the metric's registered name and can
179
+ render a full explanation on demand.
180
+
181
+ Note:
182
+ str(result) prints the plain number (so print(sharpe) stays clean);
183
+ repr(result) - what you see when a bare expression is evaluated in a
184
+ REPL/notebook - includes the name and a one-line interpretation.
185
+ """
186
+
187
+ name: str
188
+ unit: str | None
189
+ meta: dict
190
+
191
+ def __new__(
192
+ cls,
193
+ value: float,
194
+ name: str,
195
+ unit: str | None = None,
196
+ meta: dict | None = None,
197
+ ) -> MetricResult:
198
+ obj = super().__new__(cls, value)
199
+ obj.name = name
200
+ obj.unit = unit
201
+ obj.meta = meta or {}
202
+ return obj
203
+
204
+ @property
205
+ def value(self) -> float:
206
+ return float(self)
207
+
208
+ @property
209
+ def interpretation(self) -> str:
210
+ """
211
+ A one-line, value-specific verdict (empty string if none is registered).
212
+ """
213
+ expl = _REGISTRY.get(self.name)
214
+ if expl is None or expl.interpret is None:
215
+ return ""
216
+ try:
217
+ return expl.interpret(float(self))
218
+ except Exception:
219
+ return ""
220
+
221
+ def explain(self, print_it: bool = True) -> str:
222
+ """
223
+ Print (by default) and return the full explanation for this result.
224
+ """
225
+ return explain(self, print_it=print_it)
226
+
227
+ def __str__(self) -> str:
228
+ return str(float(self))
229
+
230
+ def __repr__(self) -> str:
231
+ base = f"{float(self):.6g}"
232
+ unit_str = f" {self.unit}" if self.unit else ""
233
+ interp = self.interpretation
234
+ suffix = f" - {interp}" if interp else ""
235
+ return f"{self.name}={base}{unit_str}{suffix}"
@@ -0,0 +1,57 @@
1
+ """
2
+ Pure, stateless metric functions, grouped by topic.
3
+
4
+ Every public function here also works standalone (pass a plain pandas Series/
5
+ DataFrame) - :class:`~portpy.portfolio.Portfolio` methods are thin wrappers
6
+ around these.
7
+ """
8
+
9
+ from portpy.metrics import (
10
+ benchmarks,
11
+ costs,
12
+ covariance,
13
+ distributions,
14
+ drawdowns,
15
+ performance,
16
+ regressions,
17
+ returns,
18
+ risk,
19
+ rolling,
20
+ summary,
21
+ )
22
+ from portpy.metrics.benchmarks import * # noqa: F401,F403
23
+ from portpy.metrics.costs import * # noqa: F401,F403
24
+ from portpy.metrics.covariance import * # noqa: F401,F403
25
+ from portpy.metrics.distributions import * # noqa: F401,F403
26
+ from portpy.metrics.drawdowns import * # noqa: F401,F403
27
+ from portpy.metrics.performance import * # noqa: F401,F403
28
+ from portpy.metrics.regressions import * # noqa: F401,F403
29
+ from portpy.metrics.returns import * # noqa: F401,F403
30
+ from portpy.metrics.risk import * # noqa: F401,F403
31
+ from portpy.metrics.rolling import * # noqa: F401,F403
32
+ from portpy.metrics.summary import * # noqa: F401,F403
33
+
34
+ __all__ = [
35
+ "benchmarks",
36
+ "costs",
37
+ "covariance",
38
+ "distributions",
39
+ "drawdowns",
40
+ "performance",
41
+ "regressions",
42
+ "returns",
43
+ "risk",
44
+ "rolling",
45
+ "summary",
46
+ *benchmarks.__all__,
47
+ *costs.__all__,
48
+ *covariance.__all__,
49
+ *distributions.__all__,
50
+ *drawdowns.__all__,
51
+ *performance.__all__,
52
+ *regressions.__all__,
53
+ *returns.__all__,
54
+ *risk.__all__,
55
+ *rolling.__all__,
56
+ *summary.__all__,
57
+ ]