finkritq 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.
- finkritq/__init__.py +1 -0
- finkritq/__main__.py +6 -0
- finkritq/anal/__init__.py +9 -0
- finkritq/anal/performance/__init__.py +145 -0
- finkritq/anal/performance/annualized_return.py +135 -0
- finkritq/anal/performance/attribution.py +98 -0
- finkritq/anal/performance/calmar_ratio.py +142 -0
- finkritq/anal/performance/contribution.py +55 -0
- finkritq/anal/performance/fees.py +28 -0
- finkritq/anal/performance/flows.py +124 -0
- finkritq/anal/performance/information_ratio.py +165 -0
- finkritq/anal/performance/jensens_alpha.py +162 -0
- finkritq/anal/performance/sharpe_ratio.py +178 -0
- finkritq/anal/performance/sortino_ratio.py +176 -0
- finkritq/anal/performance/total_return.py +121 -0
- finkritq/anal/performance/treynor_ratio.py +159 -0
- finkritq/anal/risk/__init__.py +99 -0
- finkritq/anal/risk/beta.py +120 -0
- finkritq/anal/risk/componentrisk.py +28 -0
- finkritq/anal/risk/concentration.py +88 -0
- finkritq/anal/risk/conditionalvalueatrisk.py +184 -0
- finkritq/anal/risk/correlation.py +119 -0
- finkritq/anal/risk/covariance.py +164 -0
- finkritq/anal/risk/downside_deviation.py +147 -0
- finkritq/anal/risk/drawdown.py +217 -0
- finkritq/anal/risk/marginalrisk.py +34 -0
- finkritq/anal/risk/projection.py +129 -0
- finkritq/anal/risk/semivariance.py +139 -0
- finkritq/anal/risk/tracking_error.py +151 -0
- finkritq/anal/risk/valueatrisk.py +166 -0
- finkritq/anal/risk/variance.py +147 -0
- finkritq/anal/risk/volatility.py +102 -0
- finkritq/asset/__init__.py +7 -0
- finkritq/asset/asset.py +26 -0
- finkritq/asset/assetsnapshot.py +16 -0
- finkritq/asset/stock.py +19 -0
- finkritq/data/__init__.py +4 -0
- finkritq/data/interfaces/__init__.py +5 -0
- finkritq/data/interfaces/history.py +39 -0
- finkritq/data/interfaces/snapshot.py +13 -0
- finkritq/data/providers/__init__.py +5 -0
- finkritq/data/providers/memoizing.py +49 -0
- finkritq/data/providers/yfinanceprovider.py +76 -0
- finkritq/data/registry.py +53 -0
- finkritq/datatype/__init__.py +25 -0
- finkritq/datatype/assettype.py +9 -0
- finkritq/datatype/cashflow.py +45 -0
- finkritq/datatype/currency.py +17 -0
- finkritq/datatype/market.py +63 -0
- finkritq/datatype/pricehistory.py +185 -0
- finkritq/datatype/risk.py +78 -0
- finkritq/main.py +494 -0
- finkritq/optimize/__init__.py +113 -0
- finkritq/optimize/cashflow.py +144 -0
- finkritq/optimize/expected_returns.py +54 -0
- finkritq/optimize/harvest.py +115 -0
- finkritq/optimize/lotselection.py +136 -0
- finkritq/optimize/meanvariance.py +436 -0
- finkritq/optimize/rebalance.py +186 -0
- finkritq/optimize/taxrebalance.py +182 -0
- finkritq/policy/__init__.py +59 -0
- finkritq/policy/compliance.py +127 -0
- finkritq/policy/policy.py +141 -0
- finkritq/policy/scan.py +73 -0
- finkritq/policy/suitability.py +94 -0
- finkritq/portfolio/__init__.py +25 -0
- finkritq/portfolio/portfolio.py +139 -0
- finkritq/portfolio/portfoliodata.py +313 -0
- finkritq/portfolio/portfoliosnapshot.py +78 -0
- finkritq/portfolio/position.py +81 -0
- finkritq/portfolio/positionsnapshot.py +35 -0
- finkritq/portfolio/taxlot.py +76 -0
- finkritq/transform/__init__.py +14 -0
- finkritq/transform/returns.py +50 -0
- finkritq-0.1.0.dist-info/METADATA +80 -0
- finkritq-0.1.0.dist-info/RECORD +78 -0
- finkritq-0.1.0.dist-info/WHEEL +4 -0
- finkritq-0.1.0.dist-info/licenses/LICENSE +202 -0
finkritq/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# finkritq/__init__.py
|
finkritq/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# finkrit/packages/finkritq/anal/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
Analytics: measures computed from market data.
|
|
4
|
+
|
|
5
|
+
Two families live here: `risk` (how risky) and `performance` (how much it
|
|
6
|
+
returned, and how good that return was for the risk taken). The raw
|
|
7
|
+
prices-to-returns transform these build on is not analytics; it lives in
|
|
8
|
+
finkritq.transform.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# finkrit/packages/finkritq/anal/performance/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
Performance analytics: return magnitude and risk-adjusted performance.
|
|
4
|
+
|
|
5
|
+
Where anal/risk answers "how risky", this package answers "how much did it
|
|
6
|
+
return" and "how good was that return for the risk taken". It starts with raw
|
|
7
|
+
return magnitude (total, annualized) and builds up to the risk-adjusted ratios
|
|
8
|
+
(Sharpe, Sortino, Calmar, ...) that combine a return numerator with a risk
|
|
9
|
+
denominator from anal/risk.
|
|
10
|
+
"""
|
|
11
|
+
from finkritq.anal.performance.total_return import (
|
|
12
|
+
portfolio_total_return,
|
|
13
|
+
total_return,
|
|
14
|
+
total_return_asset,
|
|
15
|
+
total_return_from_prices,
|
|
16
|
+
total_return_from_returns,
|
|
17
|
+
)
|
|
18
|
+
from finkritq.anal.performance.annualized_return import (
|
|
19
|
+
annualized_return,
|
|
20
|
+
annualized_return_asset,
|
|
21
|
+
annualized_return_from_prices,
|
|
22
|
+
annualized_return_from_returns,
|
|
23
|
+
portfolio_annualized_return,
|
|
24
|
+
)
|
|
25
|
+
from finkritq.anal.performance.sharpe_ratio import (
|
|
26
|
+
portfolio_sharpe_ratio,
|
|
27
|
+
sharpe_ratio,
|
|
28
|
+
sharpe_ratio_asset,
|
|
29
|
+
sharpe_ratio_from_prices,
|
|
30
|
+
sharpe_ratio_from_returns,
|
|
31
|
+
)
|
|
32
|
+
from finkritq.anal.performance.sortino_ratio import (
|
|
33
|
+
portfolio_sortino_ratio,
|
|
34
|
+
sortino_ratio,
|
|
35
|
+
sortino_ratio_asset,
|
|
36
|
+
sortino_ratio_from_prices,
|
|
37
|
+
sortino_ratio_from_returns,
|
|
38
|
+
)
|
|
39
|
+
from finkritq.anal.performance.calmar_ratio import (
|
|
40
|
+
calmar_ratio,
|
|
41
|
+
calmar_ratio_asset,
|
|
42
|
+
calmar_ratio_from_prices,
|
|
43
|
+
calmar_ratio_from_returns,
|
|
44
|
+
portfolio_calmar_ratio,
|
|
45
|
+
)
|
|
46
|
+
from finkritq.anal.performance.treynor_ratio import (
|
|
47
|
+
portfolio_treynor_ratio,
|
|
48
|
+
treynor_ratio,
|
|
49
|
+
treynor_ratio_asset,
|
|
50
|
+
treynor_ratio_from_prices,
|
|
51
|
+
treynor_ratio_from_returns,
|
|
52
|
+
)
|
|
53
|
+
from finkritq.anal.performance.information_ratio import (
|
|
54
|
+
information_ratio,
|
|
55
|
+
information_ratio_asset,
|
|
56
|
+
information_ratio_from_prices,
|
|
57
|
+
information_ratio_from_returns,
|
|
58
|
+
portfolio_information_ratio,
|
|
59
|
+
)
|
|
60
|
+
from finkritq.anal.performance.jensens_alpha import (
|
|
61
|
+
jensens_alpha,
|
|
62
|
+
jensens_alpha_asset,
|
|
63
|
+
jensens_alpha_from_prices,
|
|
64
|
+
jensens_alpha_from_returns,
|
|
65
|
+
portfolio_jensens_alpha,
|
|
66
|
+
)
|
|
67
|
+
from finkritq.anal.performance.flows import (
|
|
68
|
+
money_weighted_return,
|
|
69
|
+
time_weighted_return,
|
|
70
|
+
)
|
|
71
|
+
from finkritq.anal.performance.attribution import (
|
|
72
|
+
AttributionResult,
|
|
73
|
+
SegmentAttribution,
|
|
74
|
+
brinson_attribution,
|
|
75
|
+
)
|
|
76
|
+
from finkritq.anal.performance.contribution import (
|
|
77
|
+
Contribution,
|
|
78
|
+
contribution_to_return,
|
|
79
|
+
)
|
|
80
|
+
from finkritq.anal.performance.fees import (
|
|
81
|
+
net_of_fees,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
# total (cumulative) return
|
|
86
|
+
"total_return_from_returns",
|
|
87
|
+
"total_return_from_prices",
|
|
88
|
+
"total_return",
|
|
89
|
+
"total_return_asset",
|
|
90
|
+
"portfolio_total_return",
|
|
91
|
+
# annualized (geometric / CAGR) return
|
|
92
|
+
"annualized_return_from_returns",
|
|
93
|
+
"annualized_return_from_prices",
|
|
94
|
+
"annualized_return",
|
|
95
|
+
"annualized_return_asset",
|
|
96
|
+
"portfolio_annualized_return",
|
|
97
|
+
# sharpe ratio
|
|
98
|
+
"sharpe_ratio_from_returns",
|
|
99
|
+
"sharpe_ratio_from_prices",
|
|
100
|
+
"sharpe_ratio",
|
|
101
|
+
"sharpe_ratio_asset",
|
|
102
|
+
"portfolio_sharpe_ratio",
|
|
103
|
+
# sortino ratio
|
|
104
|
+
"sortino_ratio_from_returns",
|
|
105
|
+
"sortino_ratio_from_prices",
|
|
106
|
+
"sortino_ratio",
|
|
107
|
+
"sortino_ratio_asset",
|
|
108
|
+
"portfolio_sortino_ratio",
|
|
109
|
+
# calmar ratio
|
|
110
|
+
"calmar_ratio_from_returns",
|
|
111
|
+
"calmar_ratio_from_prices",
|
|
112
|
+
"calmar_ratio",
|
|
113
|
+
"calmar_ratio_asset",
|
|
114
|
+
"portfolio_calmar_ratio",
|
|
115
|
+
# treynor ratio
|
|
116
|
+
"treynor_ratio_from_returns",
|
|
117
|
+
"treynor_ratio_from_prices",
|
|
118
|
+
"treynor_ratio",
|
|
119
|
+
"treynor_ratio_asset",
|
|
120
|
+
"portfolio_treynor_ratio",
|
|
121
|
+
# information ratio
|
|
122
|
+
"information_ratio_from_returns",
|
|
123
|
+
"information_ratio_from_prices",
|
|
124
|
+
"information_ratio",
|
|
125
|
+
"information_ratio_asset",
|
|
126
|
+
"portfolio_information_ratio",
|
|
127
|
+
# jensen's alpha
|
|
128
|
+
"jensens_alpha_from_returns",
|
|
129
|
+
"jensens_alpha_from_prices",
|
|
130
|
+
"jensens_alpha",
|
|
131
|
+
"jensens_alpha_asset",
|
|
132
|
+
"portfolio_jensens_alpha",
|
|
133
|
+
# cash-flow-aware returns
|
|
134
|
+
"time_weighted_return",
|
|
135
|
+
"money_weighted_return",
|
|
136
|
+
# attribution
|
|
137
|
+
"brinson_attribution",
|
|
138
|
+
"AttributionResult",
|
|
139
|
+
"SegmentAttribution",
|
|
140
|
+
# contribution to return
|
|
141
|
+
"contribution_to_return",
|
|
142
|
+
"Contribution",
|
|
143
|
+
# fees
|
|
144
|
+
"net_of_fees",
|
|
145
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# finkrit/packages/finkritq/anal/performance/annualized_return.py
|
|
2
|
+
"""
|
|
3
|
+
Annualized return (CAGR): the constant per-year growth rate that compounds to the
|
|
4
|
+
window's total return. A 21% gain over two years is not 10.5%/yr, it is
|
|
5
|
+
(1.21)^(1/2) - 1 = 10%/yr, because growth compounds.
|
|
6
|
+
|
|
7
|
+
This is the geometric annualized return, the "how fast did it actually grow per
|
|
8
|
+
year" figure. It is the numerator every risk-adjusted ratio (Sharpe, Sortino,
|
|
9
|
+
Calmar, ...) will scale against. It builds directly on total_return.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import date, timedelta
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
from finkritq.anal.performance.total_return import (
|
|
19
|
+
portfolio_total_return,
|
|
20
|
+
total_return_from_prices,
|
|
21
|
+
total_return_from_returns,
|
|
22
|
+
)
|
|
23
|
+
from finkritq.asset import Asset
|
|
24
|
+
from finkritq.data import DataRegistry
|
|
25
|
+
from finkritq.datatype import PriceHistory, ReturnCalculationMethod, WeightingBasis
|
|
26
|
+
from finkritq.portfolio import PortfolioData
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _annualize(total_return: float, n_periods: int, periods_per_year: int) -> float:
|
|
30
|
+
"""
|
|
31
|
+
Scale a window's total return to a constant per-year (geometric) rate.
|
|
32
|
+
|
|
33
|
+
`n_periods` is the number of return periods (compounding intervals), NOT the
|
|
34
|
+
number of price observations. `periods_per_year` says how many of those
|
|
35
|
+
intervals make a year (252 trading days, 12 months, ...), so the window spans
|
|
36
|
+
n_periods / periods_per_year years and:
|
|
37
|
+
|
|
38
|
+
annualized = (1 + total_return) ** (1 / years) - 1
|
|
39
|
+
|
|
40
|
+
A window shorter than one year is extrapolated to a full year (the exponent
|
|
41
|
+
exceeds 1), which is standard but makes short-window figures volatile.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
if n_periods <= 0:
|
|
45
|
+
# No elapsed periods, so no growth rate to speak of.
|
|
46
|
+
return 0.0
|
|
47
|
+
|
|
48
|
+
base = 1.0 + total_return
|
|
49
|
+
if base <= 0.0:
|
|
50
|
+
# Wealth reached zero (or a nonsensical negative). A fractional power of a
|
|
51
|
+
# non-positive base is not real, and total loss annualizes to -100%.
|
|
52
|
+
return -1.0
|
|
53
|
+
|
|
54
|
+
years = n_periods / periods_per_year
|
|
55
|
+
return float(base ** (1.0 / years) - 1.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def annualized_return_from_returns(
|
|
59
|
+
returns: NDArray[np.float64],
|
|
60
|
+
method: ReturnCalculationMethod = ReturnCalculationMethod.LOG,
|
|
61
|
+
periods_per_year: int = 252,
|
|
62
|
+
) -> float:
|
|
63
|
+
"""
|
|
64
|
+
Annualized (geometric) return from a periodic return series.
|
|
65
|
+
|
|
66
|
+
`method` states how the series compounds (see total_return_from_returns). The
|
|
67
|
+
period count is len(returns), one compounding interval per element.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
total = total_return_from_returns(returns, method=method)
|
|
71
|
+
return _annualize(total, len(returns), periods_per_year)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def annualized_return_from_prices(
|
|
75
|
+
prices: NDArray[np.float64],
|
|
76
|
+
periods_per_year: int = 252,
|
|
77
|
+
) -> float:
|
|
78
|
+
"""
|
|
79
|
+
Annualized (geometric) return from a price series.
|
|
80
|
+
|
|
81
|
+
No `method`: total return from prices is the convention-free endpoint ratio.
|
|
82
|
+
m prices span m - 1 intervals, so that is the period count.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
total = total_return_from_prices(prices)
|
|
86
|
+
return _annualize(total, len(prices) - 1, periods_per_year)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def annualized_return(
|
|
90
|
+
history: PriceHistory,
|
|
91
|
+
periods_per_year: int = 252,
|
|
92
|
+
) -> float:
|
|
93
|
+
"""
|
|
94
|
+
Annualized (geometric) return from a PriceHistory.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
return annualized_return_from_prices(history.close, periods_per_year=periods_per_year)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def annualized_return_asset(
|
|
101
|
+
asset: Asset,
|
|
102
|
+
registry: DataRegistry,
|
|
103
|
+
start: date | None = None,
|
|
104
|
+
end: date | None = None,
|
|
105
|
+
interval: str = "1d",
|
|
106
|
+
periods_per_year: int = 252,
|
|
107
|
+
) -> float:
|
|
108
|
+
"""
|
|
109
|
+
Annualized (geometric) return directly from an asset.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
end = end or date.today()
|
|
113
|
+
start = start or end - timedelta(days=365)
|
|
114
|
+
|
|
115
|
+
history = registry.history(asset, start=start, end=end, interval=interval)
|
|
116
|
+
|
|
117
|
+
return annualized_return(history, periods_per_year=periods_per_year)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def portfolio_annualized_return(
|
|
121
|
+
portfolio_data: PortfolioData,
|
|
122
|
+
basis: WeightingBasis = WeightingBasis.BUY_AND_HOLD,
|
|
123
|
+
periods_per_year: int = 252,
|
|
124
|
+
) -> float:
|
|
125
|
+
"""
|
|
126
|
+
Annualized (geometric) return of a portfolio.
|
|
127
|
+
|
|
128
|
+
`basis` selects which portfolio total return is annualized (see
|
|
129
|
+
portfolio_total_return / WeightingBasis). Both bases share the same period
|
|
130
|
+
count: the number of return intervals over the window, n_periods - 1.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
total = portfolio_total_return(portfolio_data, basis=basis)
|
|
134
|
+
return _annualize(total, portfolio_data.n_periods - 1, periods_per_year)
|
|
135
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# finkrit/packages/finkritq/anal/performance/attribution.py
|
|
2
|
+
"""
|
|
3
|
+
Brinson performance attribution (aka "return decomposition"), splitting a
|
|
4
|
+
portfolio's active return (portfolio minus benchmark) into the decisions that
|
|
5
|
+
produced it.
|
|
6
|
+
|
|
7
|
+
The classic Brinson-Hood-Beebower decomposition, per segment i (an asset, sector,
|
|
8
|
+
or asset class):
|
|
9
|
+
|
|
10
|
+
allocation_i = (w_p_i - w_b_i) * R_b_i (over/underweighting segments)
|
|
11
|
+
selection_i = w_b_i * (R_p_i - R_b_i) (picking within a segment)
|
|
12
|
+
interaction_i = (w_p_i - w_b_i) * (R_p_i - R_b_i) (the cross term)
|
|
13
|
+
|
|
14
|
+
Summed across segments these equal the total active return R_p - R_b exactly, so
|
|
15
|
+
the attribution always reconciles. Weights and segment returns are the inputs.
|
|
16
|
+
Where the segments come from (assets, GICS sectors, asset classes) is the caller's
|
|
17
|
+
choice.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Hashable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class SegmentAttribution:
|
|
27
|
+
segment: Hashable
|
|
28
|
+
allocation: float
|
|
29
|
+
selection: float
|
|
30
|
+
interaction: float
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def total(self) -> float:
|
|
34
|
+
return self.allocation + self.selection + self.interaction
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class AttributionResult:
|
|
39
|
+
segments: list[SegmentAttribution]
|
|
40
|
+
allocation: float
|
|
41
|
+
selection: float
|
|
42
|
+
interaction: float
|
|
43
|
+
active_return: float # portfolio return - benchmark return
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def total(self) -> float:
|
|
47
|
+
return self.allocation + self.selection + self.interaction
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def brinson_attribution(
|
|
51
|
+
portfolio_weights: dict[Hashable, float],
|
|
52
|
+
benchmark_weights: dict[Hashable, float],
|
|
53
|
+
portfolio_returns: dict[Hashable, float],
|
|
54
|
+
benchmark_returns: dict[Hashable, float],
|
|
55
|
+
) -> AttributionResult:
|
|
56
|
+
"""
|
|
57
|
+
Decompose active return into allocation, selection, and interaction by segment.
|
|
58
|
+
|
|
59
|
+
All four dicts are keyed by segment. A segment missing from a dict contributes
|
|
60
|
+
0 for that term. The per-segment effects sum to the total active return, so
|
|
61
|
+
``result.total`` equals ``result.active_return`` up to floating point.
|
|
62
|
+
"""
|
|
63
|
+
segments = (
|
|
64
|
+
set(portfolio_weights) | set(benchmark_weights)
|
|
65
|
+
| set(portfolio_returns) | set(benchmark_returns)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
per_segment: list[SegmentAttribution] = []
|
|
69
|
+
total_alloc = total_sel = total_inter = 0.0
|
|
70
|
+
portfolio_return = 0.0
|
|
71
|
+
benchmark_return = 0.0
|
|
72
|
+
|
|
73
|
+
for segment in segments:
|
|
74
|
+
wp = portfolio_weights.get(segment, 0.0)
|
|
75
|
+
wb = benchmark_weights.get(segment, 0.0)
|
|
76
|
+
rp = portfolio_returns.get(segment, 0.0)
|
|
77
|
+
rb = benchmark_returns.get(segment, 0.0)
|
|
78
|
+
|
|
79
|
+
allocation = (wp - wb) * rb
|
|
80
|
+
selection = wb * (rp - rb)
|
|
81
|
+
interaction = (wp - wb) * (rp - rb)
|
|
82
|
+
|
|
83
|
+
per_segment.append(SegmentAttribution(segment, allocation, selection, interaction))
|
|
84
|
+
total_alloc += allocation
|
|
85
|
+
total_sel += selection
|
|
86
|
+
total_inter += interaction
|
|
87
|
+
portfolio_return += wp * rp
|
|
88
|
+
benchmark_return += wb * rb
|
|
89
|
+
|
|
90
|
+
return AttributionResult(
|
|
91
|
+
segments=per_segment,
|
|
92
|
+
allocation=total_alloc,
|
|
93
|
+
selection=total_sel,
|
|
94
|
+
interaction=total_inter,
|
|
95
|
+
active_return=portfolio_return - benchmark_return,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# finkritq/anal/performance/calmar_ratio.py
|
|
2
|
+
"""
|
|
3
|
+
Calmar ratio: annualized return per unit of worst peak-to-trough loss.
|
|
4
|
+
|
|
5
|
+
calmar = annualized return / |maximum drawdown|
|
|
6
|
+
|
|
7
|
+
Where Sharpe and Sortino divide by a dispersion measure, Calmar divides by the
|
|
8
|
+
single worst drawdown, so it speaks to pain tolerance: how much growth did you
|
|
9
|
+
earn for the deepest loss you had to sit through. Standard Calmar takes no
|
|
10
|
+
risk-free rate.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import date, timedelta
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from finkritq.anal.performance.annualized_return import (
|
|
20
|
+
annualized_return_from_prices,
|
|
21
|
+
annualized_return_from_returns,
|
|
22
|
+
portfolio_annualized_return,
|
|
23
|
+
)
|
|
24
|
+
from finkritq.anal.risk.drawdown import (
|
|
25
|
+
maximum_drawdown_from_prices,
|
|
26
|
+
portfolio_maximum_drawdown,
|
|
27
|
+
)
|
|
28
|
+
from finkritq.asset import Asset
|
|
29
|
+
from finkritq.data import DataRegistry
|
|
30
|
+
from finkritq.datatype import PriceHistory, ReturnCalculationMethod, WeightingBasis
|
|
31
|
+
from finkritq.portfolio import PortfolioData
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _calmar(annualized_return: float, maximum_drawdown: float) -> float:
|
|
35
|
+
"""
|
|
36
|
+
Combine the annualized return with the maximum drawdown.
|
|
37
|
+
|
|
38
|
+
`maximum_drawdown` is non-positive (a drawdown of -0.2 is a 20% loss). Returns
|
|
39
|
+
nan when it is exactly zero: a series that never fell has no drawdown to
|
|
40
|
+
divide by and the ratio is undefined.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if maximum_drawdown == 0.0:
|
|
44
|
+
return float("nan")
|
|
45
|
+
|
|
46
|
+
return annualized_return / abs(maximum_drawdown)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def calmar_ratio_from_returns(
|
|
50
|
+
returns: NDArray[np.float64],
|
|
51
|
+
method: ReturnCalculationMethod = ReturnCalculationMethod.LOG,
|
|
52
|
+
periods_per_year: int = 252,
|
|
53
|
+
) -> float:
|
|
54
|
+
"""
|
|
55
|
+
Calmar ratio from a periodic return series.
|
|
56
|
+
|
|
57
|
+
The wealth path is reconstructed here (rather than via
|
|
58
|
+
maximum_drawdown_from_returns, which is simple-only) so it can honor `method`:
|
|
59
|
+
exp of the cumulative sum for log returns, cumulative product for simple. A
|
|
60
|
+
leading 1.0 is prepended so a first-period loss counts. With that, this
|
|
61
|
+
matches calmar_ratio_from_prices exactly (drawdown is scale-invariant).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
annualized = annualized_return_from_returns(
|
|
65
|
+
returns, method=method, periods_per_year=periods_per_year
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if method == ReturnCalculationMethod.LOG:
|
|
69
|
+
wealth = np.concatenate(([1.0], np.exp(np.cumsum(returns))))
|
|
70
|
+
else:
|
|
71
|
+
wealth = np.concatenate(([1.0], np.cumprod(1.0 + returns)))
|
|
72
|
+
|
|
73
|
+
return _calmar(annualized, maximum_drawdown_from_prices(wealth))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def calmar_ratio_from_prices(
|
|
77
|
+
prices: NDArray[np.float64],
|
|
78
|
+
periods_per_year: int = 252,
|
|
79
|
+
) -> float:
|
|
80
|
+
"""
|
|
81
|
+
Calmar ratio from a price series.
|
|
82
|
+
|
|
83
|
+
No `method`: both the annualized return and the drawdown are level-based and
|
|
84
|
+
convention-free.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
annualized = annualized_return_from_prices(prices, periods_per_year=periods_per_year)
|
|
88
|
+
|
|
89
|
+
return _calmar(annualized, maximum_drawdown_from_prices(prices))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def calmar_ratio(
|
|
93
|
+
history: PriceHistory,
|
|
94
|
+
periods_per_year: int = 252,
|
|
95
|
+
) -> float:
|
|
96
|
+
"""
|
|
97
|
+
Calmar ratio from a PriceHistory.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
return calmar_ratio_from_prices(history.close, periods_per_year=periods_per_year)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def calmar_ratio_asset(
|
|
104
|
+
asset: Asset,
|
|
105
|
+
registry: DataRegistry,
|
|
106
|
+
start: date | None = None,
|
|
107
|
+
end: date | None = None,
|
|
108
|
+
interval: str = "1d",
|
|
109
|
+
periods_per_year: int = 252,
|
|
110
|
+
) -> float:
|
|
111
|
+
"""
|
|
112
|
+
Calmar ratio directly from an asset.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
end = end or date.today()
|
|
116
|
+
start = start or end - timedelta(days=365)
|
|
117
|
+
|
|
118
|
+
history = registry.history(asset, start=start, end=end, interval=interval)
|
|
119
|
+
|
|
120
|
+
return calmar_ratio(history, periods_per_year=periods_per_year)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def portfolio_calmar_ratio(
|
|
124
|
+
portfolio_data: PortfolioData,
|
|
125
|
+
basis: WeightingBasis = WeightingBasis.BUY_AND_HOLD,
|
|
126
|
+
periods_per_year: int = 252,
|
|
127
|
+
) -> float:
|
|
128
|
+
"""
|
|
129
|
+
Calmar ratio of a portfolio.
|
|
130
|
+
|
|
131
|
+
`basis` is passed to BOTH the annualized return and the maximum drawdown, so
|
|
132
|
+
the ratio describes one portfolio (see WeightingBasis). Defaults to
|
|
133
|
+
BUY_AND_HOLD, the realized return over the realized worst drawdown.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
annualized = portfolio_annualized_return(
|
|
137
|
+
portfolio_data, basis=basis, periods_per_year=periods_per_year
|
|
138
|
+
)
|
|
139
|
+
max_dd = portfolio_maximum_drawdown(portfolio_data, basis=basis)
|
|
140
|
+
|
|
141
|
+
return _calmar(annualized, max_dd)
|
|
142
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# finkrit/packages/finkritq/anal/performance/contribution.py
|
|
2
|
+
"""
|
|
3
|
+
Contribution to return: how much each holding added to or subtracted from the
|
|
4
|
+
portfolio's total return over the window, the "top contributors and detractors"
|
|
5
|
+
line every performance report carries.
|
|
6
|
+
|
|
7
|
+
This is the plain-language companion to attribution. Attribution is
|
|
8
|
+
benchmark-relative (allocation vs selection), contribution is absolute: on a
|
|
9
|
+
buy-and-hold basis each holding's contribution is its START weight times its own
|
|
10
|
+
total return, and those pieces sum exactly to the portfolio's total return (the
|
|
11
|
+
value path holds today's share counts fixed, so there is no rebalancing term to
|
|
12
|
+
reconcile). Rank them and the ends of the list are the contributors and
|
|
13
|
+
detractors.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from finkritq.asset import Asset
|
|
20
|
+
from finkritq.portfolio import PortfolioData
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Contribution:
|
|
25
|
+
"""One holding's contribution to the portfolio's total return."""
|
|
26
|
+
|
|
27
|
+
asset: Asset
|
|
28
|
+
start_weight: float # weight at the start of the window
|
|
29
|
+
asset_return: float # the asset's own total return over the window
|
|
30
|
+
contribution: float # start_weight * asset_return
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def contribution_to_return(portfolio_data: PortfolioData) -> list[Contribution]:
|
|
34
|
+
"""
|
|
35
|
+
Each holding's contribution to total return, ranked best first.
|
|
36
|
+
|
|
37
|
+
``contribution = start_weight * asset_return``. Summed across holdings this
|
|
38
|
+
equals the portfolio's total return over the window. The first entries are the
|
|
39
|
+
top contributors, the last are the detractors.
|
|
40
|
+
"""
|
|
41
|
+
start_weights = portfolio_data.start_weights
|
|
42
|
+
|
|
43
|
+
contributions: list[Contribution] = []
|
|
44
|
+
for asset, weight in start_weights.items():
|
|
45
|
+
close = portfolio_data[asset].close
|
|
46
|
+
asset_return = float(close[-1] / close[0] - 1.0)
|
|
47
|
+
contributions.append(Contribution(
|
|
48
|
+
asset=asset,
|
|
49
|
+
start_weight=weight,
|
|
50
|
+
asset_return=asset_return,
|
|
51
|
+
contribution=weight * asset_return,
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
contributions.sort(key=lambda item: item.contribution, reverse=True)
|
|
55
|
+
return contributions
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# finkrit/packages/finkritq/anal/performance/fees.py
|
|
2
|
+
"""
|
|
3
|
+
Gross-to-net return: reduce a return by the management fee, the net-of-fees figure
|
|
4
|
+
every performance report shows alongside the gross.
|
|
5
|
+
|
|
6
|
+
finq only does the math. A flat annual fee rate is applied as a haircut on value,
|
|
7
|
+
net = (1 + gross) * (1 - annual_fee_rate) ** years - 1, which treats the fee as a
|
|
8
|
+
fraction of value taken each year. This ignores intra-year timing and tiered
|
|
9
|
+
schedules, the actual fee schedule assigned to an owner lives above finq, only the
|
|
10
|
+
rate and the horizon come in here.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def net_of_fees(gross_return: float, annual_fee_rate: float, years: float = 1.0) -> float:
|
|
16
|
+
"""
|
|
17
|
+
Net-of-fees return from a gross return over ``years`` at ``annual_fee_rate``.
|
|
18
|
+
|
|
19
|
+
The fee compounds as an annual haircut on value, so for a 1-year period
|
|
20
|
+
net is approximately ``gross - fee``, and over multiple years the fee
|
|
21
|
+
compounds like a negative return.
|
|
22
|
+
"""
|
|
23
|
+
if not 0.0 <= annual_fee_rate < 1.0:
|
|
24
|
+
raise ValueError("annual_fee_rate must be in [0, 1).")
|
|
25
|
+
if years < 0.0:
|
|
26
|
+
raise ValueError("years must be non-negative.")
|
|
27
|
+
fee_factor = (1.0 - annual_fee_rate) ** years
|
|
28
|
+
return (1.0 + gross_return) * fee_factor - 1.0
|