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 +50 -0
- portpy/core/__init__.py +19 -0
- portpy/core/asset.py +32 -0
- portpy/core/calendar.py +110 -0
- portpy/core/currency.py +52 -0
- portpy/core/weights.py +69 -0
- portpy/explain.py +235 -0
- portpy/metrics/__init__.py +57 -0
- portpy/metrics/benchmarks.py +214 -0
- portpy/metrics/costs.py +80 -0
- portpy/metrics/covariance.py +189 -0
- portpy/metrics/distributions.py +318 -0
- portpy/metrics/drawdowns.py +243 -0
- portpy/metrics/performance.py +462 -0
- portpy/metrics/regressions.py +101 -0
- portpy/metrics/returns.py +311 -0
- portpy/metrics/risk.py +451 -0
- portpy/metrics/rolling.py +193 -0
- portpy/metrics/summary.py +130 -0
- portpy/models/__init__.py +1 -0
- portpy/portfolio.py +244 -0
- portpy/py.typed +0 -0
- portpy/strategies/__init__.py +1 -0
- portpy/utils/__init__.py +1 -0
- portpy/utils/constants.py +21 -0
- portpy/utils/validation.py +95 -0
- portpy/visualization/__init__.py +1 -0
- portpy_quant-0.1.0.dist-info/METADATA +190 -0
- portpy_quant-0.1.0.dist-info/RECORD +31 -0
- portpy_quant-0.1.0.dist-info/WHEEL +4 -0
- portpy_quant-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
portpy/core/__init__.py
ADDED
|
@@ -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})
|
portpy/core/calendar.py
ADDED
|
@@ -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")
|
portpy/core/currency.py
ADDED
|
@@ -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
|
+
]
|