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.
Files changed (78) hide show
  1. finkritq/__init__.py +1 -0
  2. finkritq/__main__.py +6 -0
  3. finkritq/anal/__init__.py +9 -0
  4. finkritq/anal/performance/__init__.py +145 -0
  5. finkritq/anal/performance/annualized_return.py +135 -0
  6. finkritq/anal/performance/attribution.py +98 -0
  7. finkritq/anal/performance/calmar_ratio.py +142 -0
  8. finkritq/anal/performance/contribution.py +55 -0
  9. finkritq/anal/performance/fees.py +28 -0
  10. finkritq/anal/performance/flows.py +124 -0
  11. finkritq/anal/performance/information_ratio.py +165 -0
  12. finkritq/anal/performance/jensens_alpha.py +162 -0
  13. finkritq/anal/performance/sharpe_ratio.py +178 -0
  14. finkritq/anal/performance/sortino_ratio.py +176 -0
  15. finkritq/anal/performance/total_return.py +121 -0
  16. finkritq/anal/performance/treynor_ratio.py +159 -0
  17. finkritq/anal/risk/__init__.py +99 -0
  18. finkritq/anal/risk/beta.py +120 -0
  19. finkritq/anal/risk/componentrisk.py +28 -0
  20. finkritq/anal/risk/concentration.py +88 -0
  21. finkritq/anal/risk/conditionalvalueatrisk.py +184 -0
  22. finkritq/anal/risk/correlation.py +119 -0
  23. finkritq/anal/risk/covariance.py +164 -0
  24. finkritq/anal/risk/downside_deviation.py +147 -0
  25. finkritq/anal/risk/drawdown.py +217 -0
  26. finkritq/anal/risk/marginalrisk.py +34 -0
  27. finkritq/anal/risk/projection.py +129 -0
  28. finkritq/anal/risk/semivariance.py +139 -0
  29. finkritq/anal/risk/tracking_error.py +151 -0
  30. finkritq/anal/risk/valueatrisk.py +166 -0
  31. finkritq/anal/risk/variance.py +147 -0
  32. finkritq/anal/risk/volatility.py +102 -0
  33. finkritq/asset/__init__.py +7 -0
  34. finkritq/asset/asset.py +26 -0
  35. finkritq/asset/assetsnapshot.py +16 -0
  36. finkritq/asset/stock.py +19 -0
  37. finkritq/data/__init__.py +4 -0
  38. finkritq/data/interfaces/__init__.py +5 -0
  39. finkritq/data/interfaces/history.py +39 -0
  40. finkritq/data/interfaces/snapshot.py +13 -0
  41. finkritq/data/providers/__init__.py +5 -0
  42. finkritq/data/providers/memoizing.py +49 -0
  43. finkritq/data/providers/yfinanceprovider.py +76 -0
  44. finkritq/data/registry.py +53 -0
  45. finkritq/datatype/__init__.py +25 -0
  46. finkritq/datatype/assettype.py +9 -0
  47. finkritq/datatype/cashflow.py +45 -0
  48. finkritq/datatype/currency.py +17 -0
  49. finkritq/datatype/market.py +63 -0
  50. finkritq/datatype/pricehistory.py +185 -0
  51. finkritq/datatype/risk.py +78 -0
  52. finkritq/main.py +494 -0
  53. finkritq/optimize/__init__.py +113 -0
  54. finkritq/optimize/cashflow.py +144 -0
  55. finkritq/optimize/expected_returns.py +54 -0
  56. finkritq/optimize/harvest.py +115 -0
  57. finkritq/optimize/lotselection.py +136 -0
  58. finkritq/optimize/meanvariance.py +436 -0
  59. finkritq/optimize/rebalance.py +186 -0
  60. finkritq/optimize/taxrebalance.py +182 -0
  61. finkritq/policy/__init__.py +59 -0
  62. finkritq/policy/compliance.py +127 -0
  63. finkritq/policy/policy.py +141 -0
  64. finkritq/policy/scan.py +73 -0
  65. finkritq/policy/suitability.py +94 -0
  66. finkritq/portfolio/__init__.py +25 -0
  67. finkritq/portfolio/portfolio.py +139 -0
  68. finkritq/portfolio/portfoliodata.py +313 -0
  69. finkritq/portfolio/portfoliosnapshot.py +78 -0
  70. finkritq/portfolio/position.py +81 -0
  71. finkritq/portfolio/positionsnapshot.py +35 -0
  72. finkritq/portfolio/taxlot.py +76 -0
  73. finkritq/transform/__init__.py +14 -0
  74. finkritq/transform/returns.py +50 -0
  75. finkritq-0.1.0.dist-info/METADATA +80 -0
  76. finkritq-0.1.0.dist-info/RECORD +78 -0
  77. finkritq-0.1.0.dist-info/WHEEL +4 -0
  78. 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,6 @@
1
+ # finkrit/packages/finkritq/__main__.py
2
+ """Entry point for ``python -m finkritq``: runs the demo in main.py."""
3
+ from finkritq.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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