markowizard 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.
- markowizard/__init__.py +21 -0
- markowizard/allocation.py +138 -0
- markowizard/core.py +188 -0
- markowizard/data.py +91 -0
- markowizard/visualization.py +322 -0
- markowizard-0.1.0.dist-info/METADATA +225 -0
- markowizard-0.1.0.dist-info/RECORD +10 -0
- markowizard-0.1.0.dist-info/WHEEL +5 -0
- markowizard-0.1.0.dist-info/licenses/LICENSE +21 -0
- markowizard-0.1.0.dist-info/top_level.txt +1 -0
markowizard/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
markowizard: Markowitz portfolio optimization and analysis library.
|
|
3
|
+
|
|
4
|
+
Provides tools for mean-variance optimization, capital allocation,
|
|
5
|
+
visualization, and optional market data fetching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from markowizard.allocation import CapitalAllocator
|
|
11
|
+
from markowizard.core import MarkowitzOptimizer
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("markowizard")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CapitalAllocator",
|
|
20
|
+
"MarkowitzOptimizer",
|
|
21
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capital allocation line (CAL) analysis: mixing a risky portfolio with a
|
|
3
|
+
risk-free asset.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from markowizard.core import COL_RETURN, COL_RISK, COL_RISK_FREE, COL_SHARPE
|
|
11
|
+
|
|
12
|
+
# Keys expected in the portfolio dict/series
|
|
13
|
+
_PORTFOLIO_KEYS = {COL_RETURN, COL_RISK, COL_SHARPE}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CapitalAllocator:
|
|
17
|
+
"""
|
|
18
|
+
Combines a risky portfolio with a risk-free asset to explore how
|
|
19
|
+
different allocations affect overall expected return and risk.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
portfolio : pandas.Series or dict-like
|
|
24
|
+
A dictionary or Series representing a single portfolio, containing
|
|
25
|
+
at least 'Expected Return' (expected return) and 'Risk' (risk/std).
|
|
26
|
+
risk_free_rate : float
|
|
27
|
+
Risk-free rate (e.g., monthly rate).
|
|
28
|
+
|
|
29
|
+
Attributes
|
|
30
|
+
----------
|
|
31
|
+
portfolio : dict
|
|
32
|
+
The risky portfolio data.
|
|
33
|
+
rf : float
|
|
34
|
+
Risk-free rate.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
portfolio: pd.Series | Mapping[str, float],
|
|
40
|
+
risk_free_rate: float,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.portfolio = dict(portfolio)
|
|
43
|
+
self.rf = risk_free_rate
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def weigh_risk_free(value: float, risk_free_value: float, p: float) -> float:
|
|
47
|
+
"""
|
|
48
|
+
Combine a risky value with a risk-free value given proportion ``p``
|
|
49
|
+
allocated to the risk-free asset.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
value : float
|
|
54
|
+
Value from the risky portfolio (e.g., expected return or risk).
|
|
55
|
+
risk_free_value : float
|
|
56
|
+
Corresponding value for the risk-free asset (0 for risk, rf for
|
|
57
|
+
return).
|
|
58
|
+
p : float
|
|
59
|
+
Proportion allocated to the risk-free asset (0 to 1).
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
float
|
|
64
|
+
Weighted value.
|
|
65
|
+
"""
|
|
66
|
+
return p * risk_free_value + (1 - p) * value
|
|
67
|
+
|
|
68
|
+
def capital_allocation_line(self, steps: int = 21) -> list[dict]:
|
|
69
|
+
"""
|
|
70
|
+
Generate points along the Capital Allocation Line.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
steps : int, optional
|
|
75
|
+
Number of allocation points (default 21, i.e., 0% to 100% in 5%
|
|
76
|
+
increments).
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
list of dict
|
|
81
|
+
Each dict has keys 'p' (risk-free proportion), 'expected_return',
|
|
82
|
+
'risk', and 'label'.
|
|
83
|
+
"""
|
|
84
|
+
proportions = [i / (steps - 1) for i in range(steps)]
|
|
85
|
+
points: list[dict] = []
|
|
86
|
+
|
|
87
|
+
for p in proportions:
|
|
88
|
+
expected_return = self.weigh_risk_free(self.portfolio[COL_RETURN], self.rf, p)
|
|
89
|
+
risk = self.weigh_risk_free(self.portfolio[COL_RISK], 0.0, p)
|
|
90
|
+
points.append(
|
|
91
|
+
{
|
|
92
|
+
"p": p,
|
|
93
|
+
"expected_return": expected_return,
|
|
94
|
+
"risk": risk,
|
|
95
|
+
"label": f"{p:.0%} risk-free",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return points
|
|
100
|
+
|
|
101
|
+
def final_allocation(self, p: float) -> dict[str, float]:
|
|
102
|
+
"""
|
|
103
|
+
Compute the final allocation weights given proportion ``p`` in the
|
|
104
|
+
risk-free asset.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
p : float
|
|
109
|
+
Proportion allocated to the risk-free asset (0 to 1).
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
dict[str, float]
|
|
114
|
+
Mapping of asset names (including 'Risk-Free' for risk-free) to
|
|
115
|
+
their allocation percentages.
|
|
116
|
+
"""
|
|
117
|
+
allocation: dict[str, float] = {COL_RISK_FREE: p}
|
|
118
|
+
for key, value in self.portfolio.items():
|
|
119
|
+
if key not in _PORTFOLIO_KEYS:
|
|
120
|
+
allocation[key] = (1 - p) * value
|
|
121
|
+
return allocation
|
|
122
|
+
|
|
123
|
+
def expected_returns(self, p: float) -> tuple[float, float]:
|
|
124
|
+
"""
|
|
125
|
+
Expected return and risk for a given allocation to the risk-free asset.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
p : float
|
|
130
|
+
Proportion allocated to risk-free (0 to 1).
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
tuple of (expected_return, risk)
|
|
135
|
+
"""
|
|
136
|
+
ret = self.weigh_risk_free(self.portfolio[COL_RETURN], self.rf, p)
|
|
137
|
+
ris = self.weigh_risk_free(self.portfolio[COL_RISK], 0.0, p)
|
|
138
|
+
return ret, ris
|
markowizard/core.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core portfolio optimization using Markowitz Modern Portfolio Theory.
|
|
3
|
+
|
|
4
|
+
Uses scipy.optimize to compute the efficient frontier.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from scipy.optimize import minimize
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# English column names for display (region-agnostic)
|
|
17
|
+
COL_RETURN = "Expected Return"
|
|
18
|
+
COL_RISK = "Risk"
|
|
19
|
+
COL_SHARPE = "Sharpe"
|
|
20
|
+
COL_RISK_FREE = "Risk-Free"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_objective(
|
|
24
|
+
mu: float,
|
|
25
|
+
cov_matrix: np.ndarray,
|
|
26
|
+
mean_returns: np.ndarray,
|
|
27
|
+
) -> Callable[[np.ndarray], float]:
|
|
28
|
+
"""Build the quadratic objective function for a given risk-aversion parameter."""
|
|
29
|
+
|
|
30
|
+
def objective(w: np.ndarray) -> float:
|
|
31
|
+
return float(0.5 * mu * w @ cov_matrix @ w - mean_returns @ w)
|
|
32
|
+
|
|
33
|
+
return objective
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MarkowitzOptimizer:
|
|
37
|
+
"""
|
|
38
|
+
Performs Markowitz mean-variance optimization to find the efficient frontier.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
returns : pandas.DataFrame
|
|
43
|
+
DataFrame of historical asset returns, where each column is an asset
|
|
44
|
+
and each row is a time period (e.g., monthly returns). Returns should
|
|
45
|
+
be in decimal form (e.g., 0.01 = 1%), not percentage form.
|
|
46
|
+
|
|
47
|
+
Attributes
|
|
48
|
+
----------
|
|
49
|
+
tickers : pandas.Index
|
|
50
|
+
Asset tickers/column names.
|
|
51
|
+
returns : pandas.DataFrame
|
|
52
|
+
The input returns data.
|
|
53
|
+
portfolios : pandas.DataFrame | None
|
|
54
|
+
DataFrame of optimized portfolios along the efficient frontier,
|
|
55
|
+
containing weights for each asset plus 'Expected Return',
|
|
56
|
+
'Risk' (std), and 'Sharpe' (Sharpe ratio).
|
|
57
|
+
n_assets : int
|
|
58
|
+
Number of assets in the portfolio.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, returns: pd.DataFrame) -> None:
|
|
62
|
+
if returns.empty:
|
|
63
|
+
raise ValueError("returns DataFrame must not be empty.")
|
|
64
|
+
if returns.shape[1] < 1:
|
|
65
|
+
raise ValueError("returns DataFrame must have at least one asset column.")
|
|
66
|
+
|
|
67
|
+
self.returns = returns
|
|
68
|
+
self.tickers = returns.columns
|
|
69
|
+
self.portfolios: pd.DataFrame | None = None
|
|
70
|
+
self.n_assets = returns.shape[1]
|
|
71
|
+
|
|
72
|
+
def optimize(self) -> pd.DataFrame:
|
|
73
|
+
"""
|
|
74
|
+
Compute the efficient frontier by solving quadratic programming
|
|
75
|
+
problems for a range of risk-aversion parameters (mu).
|
|
76
|
+
|
|
77
|
+
Uses warm-starting: the optimal weights from one mu value serve as
|
|
78
|
+
the initial guess for the next, reducing total iterations.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
pandas.DataFrame
|
|
83
|
+
Efficient frontier portfolios with columns for each asset weight,
|
|
84
|
+
'Expected Return', 'Risk', and 'Sharpe'.
|
|
85
|
+
"""
|
|
86
|
+
returns_array = self.returns.values.T # shape: (n_assets, n_periods)
|
|
87
|
+
n = self.n_assets
|
|
88
|
+
|
|
89
|
+
# Mean returns vector and covariance matrix
|
|
90
|
+
mean_returns = np.mean(returns_array, axis=1)
|
|
91
|
+
cov_matrix = np.cov(returns_array)
|
|
92
|
+
|
|
93
|
+
# Constraints: sum(weights) = 1
|
|
94
|
+
constraints: dict = {"type": "eq", "fun": lambda w: np.sum(w) - 1.0}
|
|
95
|
+
|
|
96
|
+
# Bounds: no short selling (weights >= 0)
|
|
97
|
+
bounds: list[tuple[float, float | None]] = [(0.0, None) for _ in range(n)]
|
|
98
|
+
|
|
99
|
+
# Generate a range of risk-aversion parameters (mu)
|
|
100
|
+
# Higher mu = more risk-averse -> lower risk portfolios
|
|
101
|
+
mus = [10 ** (t / 20 - 1) for t in range(100)]
|
|
102
|
+
|
|
103
|
+
portfolios_list: list[np.ndarray] = []
|
|
104
|
+
n_failed = 0
|
|
105
|
+
|
|
106
|
+
# Warm-start: start with equal weights, then use previous solution
|
|
107
|
+
w0 = np.ones(n) / n
|
|
108
|
+
|
|
109
|
+
for mu in mus:
|
|
110
|
+
objective = _make_objective(mu, cov_matrix, mean_returns)
|
|
111
|
+
|
|
112
|
+
result = minimize(
|
|
113
|
+
objective,
|
|
114
|
+
w0,
|
|
115
|
+
method="SLSQP",
|
|
116
|
+
bounds=bounds,
|
|
117
|
+
constraints=constraints,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if result.success:
|
|
121
|
+
portfolios_list.append(result.x)
|
|
122
|
+
w0 = result.x # warm-start next iteration
|
|
123
|
+
else:
|
|
124
|
+
n_failed += 1
|
|
125
|
+
# Fall back to equal weights as a last resort
|
|
126
|
+
w_fallback = w0.copy()
|
|
127
|
+
portfolios_list.append(w_fallback)
|
|
128
|
+
logger.warning(
|
|
129
|
+
"Optimization failed for mu=%f (iteration %d). "
|
|
130
|
+
"Using previous weights as fallback.",
|
|
131
|
+
mu,
|
|
132
|
+
len(portfolios_list) - 1,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if n_failed > 0:
|
|
136
|
+
logger.warning("%d out of %d optimizations failed.", n_failed, len(mus))
|
|
137
|
+
|
|
138
|
+
# Build DataFrame
|
|
139
|
+
concat = np.array(portfolios_list)
|
|
140
|
+
df = pd.DataFrame(concat, columns=self.tickers)
|
|
141
|
+
|
|
142
|
+
# Compute expected return and risk for each portfolio
|
|
143
|
+
df[COL_RETURN] = concat @ mean_returns
|
|
144
|
+
df[COL_RISK] = np.sqrt(np.diag(concat @ cov_matrix @ concat.T))
|
|
145
|
+
|
|
146
|
+
# Sort by risk (ascending) so the frontier is ordered
|
|
147
|
+
df = df.sort_values(COL_RISK).reset_index(drop=True)
|
|
148
|
+
|
|
149
|
+
self.portfolios = df
|
|
150
|
+
return self.portfolios
|
|
151
|
+
|
|
152
|
+
def compute_sharpe(self, risk_free_rate: float) -> pd.DataFrame:
|
|
153
|
+
"""
|
|
154
|
+
Compute the Sharpe ratio for each portfolio on the efficient frontier.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
risk_free_rate : float
|
|
159
|
+
Risk-free rate (e.g., monthly rate). Should be in decimal
|
|
160
|
+
form (e.g., 0.005 for 0.5% a.m.).
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
pandas.DataFrame
|
|
165
|
+
The portfolios DataFrame with an added 'Sharpe' column.
|
|
166
|
+
"""
|
|
167
|
+
if self.portfolios is None:
|
|
168
|
+
raise ValueError("Call optimize() before computing Sharpe ratios.")
|
|
169
|
+
|
|
170
|
+
self.portfolios[COL_SHARPE] = (
|
|
171
|
+
self.portfolios[COL_RETURN] - risk_free_rate
|
|
172
|
+
) / self.portfolios[COL_RISK]
|
|
173
|
+
return self.portfolios
|
|
174
|
+
|
|
175
|
+
def max_sharpe_portfolio(self) -> pd.Series:
|
|
176
|
+
"""
|
|
177
|
+
Return the portfolio with the highest Sharpe ratio.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
pandas.Series
|
|
182
|
+
The tangency (maximum Sharpe) portfolio.
|
|
183
|
+
"""
|
|
184
|
+
if self.portfolios is None or COL_SHARPE not in self.portfolios.columns:
|
|
185
|
+
raise ValueError("Call optimize() and compute_sharpe() first.")
|
|
186
|
+
|
|
187
|
+
idx = self.portfolios[COL_SHARPE].idxmax()
|
|
188
|
+
return self.portfolios.loc[idx]
|
markowizard/data.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convenience functions for fetching market data and computing returns.
|
|
3
|
+
|
|
4
|
+
Use these to quickly download prices from Yahoo Finance. The core
|
|
5
|
+
analytical modules also accept a pre-computed returns DataFrame directly,
|
|
6
|
+
so these helpers are optional in practice.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import yfinance as yf
|
|
14
|
+
|
|
15
|
+
# Pattern for validating Yahoo Finance ticker symbols
|
|
16
|
+
_TICKER_PATTERN = re.compile(r"^[A-Z0-9.-]+$", re.IGNORECASE)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_tickers(tickers: list[str]) -> None:
|
|
20
|
+
"""Validate a list of ticker symbols against a safe pattern.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
tickers : list of str
|
|
25
|
+
Ticker symbols to validate.
|
|
26
|
+
|
|
27
|
+
Raises
|
|
28
|
+
------
|
|
29
|
+
ValueError
|
|
30
|
+
If any ticker contains characters other than letters, digits,
|
|
31
|
+
dots, hyphens, or is empty.
|
|
32
|
+
"""
|
|
33
|
+
for t in tickers:
|
|
34
|
+
if not t or not isinstance(t, str):
|
|
35
|
+
raise ValueError(f"Invalid ticker: {t!r}. Tickers must be non-empty strings.")
|
|
36
|
+
if not _TICKER_PATTERN.match(t):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Invalid ticker: {t!r}. Tickers may only contain letters, "
|
|
39
|
+
f"digits, dots, and hyphens."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fetch_prices(
|
|
44
|
+
tickers: list[str],
|
|
45
|
+
period: str = "5y",
|
|
46
|
+
auto_adjust: bool = True,
|
|
47
|
+
) -> pd.DataFrame:
|
|
48
|
+
"""
|
|
49
|
+
Download historical adjusted close prices for a list of tickers.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
tickers : list of str
|
|
54
|
+
Yahoo Finance ticker symbols (e.g., ['AAPL', 'MSFT', 'SPY']).
|
|
55
|
+
Each ticker must match ``^[A-Z0-9.-]+$``.
|
|
56
|
+
period : str, optional
|
|
57
|
+
Data period (default '5y'). See yfinance for valid periods.
|
|
58
|
+
auto_adjust : bool, optional
|
|
59
|
+
Whether to use auto-adjusted close prices (default True).
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
pd.DataFrame
|
|
64
|
+
DataFrame of closing prices with DatetimeIndex and tickers as columns.
|
|
65
|
+
"""
|
|
66
|
+
_validate_tickers(tickers)
|
|
67
|
+
t = yf.Tickers(" ".join(tickers))
|
|
68
|
+
df: Any = t.history(period=period, auto_adjust=auto_adjust, progress=False)
|
|
69
|
+
return df["Close"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compute_monthly_returns(prices: pd.DataFrame) -> pd.DataFrame:
|
|
73
|
+
"""
|
|
74
|
+
Convert daily close prices to monthly percentage returns.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
prices : pd.DataFrame
|
|
79
|
+
Daily closing prices with DatetimeIndex and tickers as columns.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
pd.DataFrame
|
|
84
|
+
DataFrame of monthly percentage returns.
|
|
85
|
+
"""
|
|
86
|
+
# Resample prices to end-of-month
|
|
87
|
+
monthly_prices = prices.resample("ME").last()
|
|
88
|
+
|
|
89
|
+
# Compute percentage change and drop NaN
|
|
90
|
+
returns: pd.DataFrame = monthly_prices.pct_change().dropna()
|
|
91
|
+
return returns
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plotly-based visualization functions for portfolio analysis.
|
|
3
|
+
|
|
4
|
+
Returns standalone Plotly ``Figure`` objects (not tied to Dash).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import plotly.graph_objects as go
|
|
9
|
+
from plotly.graph_objects import Figure
|
|
10
|
+
|
|
11
|
+
from markowizard.core import COL_RETURN, COL_RISK, COL_SHARPE
|
|
12
|
+
|
|
13
|
+
# Shared default layout margin
|
|
14
|
+
_DEFAULT_MARGIN = {"b": 10, "t": 10}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_hover_text(
|
|
18
|
+
risk: pd.Series,
|
|
19
|
+
expected_return: pd.Series,
|
|
20
|
+
sharpe: pd.Series | None = None,
|
|
21
|
+
) -> list[str]:
|
|
22
|
+
"""Build hover text from risk, return, and optional Sharpe columns."""
|
|
23
|
+
if sharpe is None:
|
|
24
|
+
sharpe = pd.Series([0.0] * len(risk), index=risk.index)
|
|
25
|
+
return [
|
|
26
|
+
f"<b>Expected Return:</b> {y:.1%}<br><b>Risk:</b> ±{x:.1%}<br><b>Sharpe Ratio:</b> {z:.2f}"
|
|
27
|
+
for x, y, z in zip(risk, expected_return, sharpe, strict=True)
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _marker_styles(
|
|
32
|
+
n: int,
|
|
33
|
+
highlight_idx: int,
|
|
34
|
+
base_color: str = "cyan",
|
|
35
|
+
highlight_color: str = "yellow",
|
|
36
|
+
) -> tuple[list[str], list[int]]:
|
|
37
|
+
"""Build marker color and size lists, highlighting one index."""
|
|
38
|
+
colors = [base_color if i != highlight_idx else highlight_color for i in range(n)]
|
|
39
|
+
sizes = [8 if i != highlight_idx else 12 for i in range(n)]
|
|
40
|
+
return colors, sizes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def efficiency_frontier_plot(
|
|
44
|
+
portfolios: pd.DataFrame,
|
|
45
|
+
highlight_portfolio: int = 0,
|
|
46
|
+
) -> Figure:
|
|
47
|
+
"""
|
|
48
|
+
Plot the efficient frontier as a scatter plot of expected return vs risk.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
portfolios : pd.DataFrame
|
|
53
|
+
DataFrame with 'Expected Return', 'Risk', and 'Sharpe' columns
|
|
54
|
+
(as produced by MarkowitzOptimizer).
|
|
55
|
+
highlight_portfolio : int, optional
|
|
56
|
+
Index of the portfolio to highlight (default 0).
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
plotly.graph_objects.Figure
|
|
61
|
+
"""
|
|
62
|
+
df = portfolios
|
|
63
|
+
|
|
64
|
+
sharpe_col = df.get(COL_SHARPE) if COL_SHARPE in df.columns else None
|
|
65
|
+
text = _build_hover_text(df[COL_RISK], df[COL_RETURN], sharpe_col)
|
|
66
|
+
|
|
67
|
+
marker_color, marker_size = _marker_styles(len(df), highlight_portfolio)
|
|
68
|
+
|
|
69
|
+
fig = go.Figure(
|
|
70
|
+
data=go.Scatter(
|
|
71
|
+
x=df[COL_RISK],
|
|
72
|
+
y=df[COL_RETURN],
|
|
73
|
+
name="Efficient Frontier",
|
|
74
|
+
mode="markers",
|
|
75
|
+
marker={
|
|
76
|
+
"size": marker_size,
|
|
77
|
+
"color": marker_color,
|
|
78
|
+
"opacity": 1,
|
|
79
|
+
"line": {"color": "blue", "width": 2},
|
|
80
|
+
},
|
|
81
|
+
hovertext=text,
|
|
82
|
+
hoverinfo="text",
|
|
83
|
+
),
|
|
84
|
+
layout={
|
|
85
|
+
"margin": _DEFAULT_MARGIN,
|
|
86
|
+
"xaxis": {
|
|
87
|
+
"tickformat": ",.1%",
|
|
88
|
+
"title": {"text": "Risk (Standard Deviation)"},
|
|
89
|
+
},
|
|
90
|
+
"yaxis": {
|
|
91
|
+
"tickformat": ",.1%",
|
|
92
|
+
"title": {"text": "Expected Return (% p.m.)"},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Annotate max Sharpe portfolio
|
|
98
|
+
if COL_SHARPE in df.columns:
|
|
99
|
+
max_sharpe = df[COL_SHARPE].idxmax()
|
|
100
|
+
fig.add_annotation(
|
|
101
|
+
x=df.loc[max_sharpe, COL_RISK],
|
|
102
|
+
y=df.loc[max_sharpe, COL_RETURN],
|
|
103
|
+
text="Max Sharpe Ratio",
|
|
104
|
+
showarrow=True,
|
|
105
|
+
arrowhead=1,
|
|
106
|
+
arrowwidth=2,
|
|
107
|
+
axref="pixel",
|
|
108
|
+
ax=100,
|
|
109
|
+
ayref="pixel",
|
|
110
|
+
ay=20,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return fig
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def allocation_pie(portfolio: pd.Series) -> Figure:
|
|
117
|
+
"""
|
|
118
|
+
Pie chart showing asset allocation for a single portfolio.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
portfolio : pd.Series
|
|
123
|
+
A single portfolio row from the efficient frontier DataFrame.
|
|
124
|
+
Non-zero asset weights are displayed; meta columns like
|
|
125
|
+
'Expected Return', 'Risk', 'Sharpe' are excluded.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
plotly.graph_objects.Figure
|
|
130
|
+
"""
|
|
131
|
+
# Filter out metadata columns and near-zero weights
|
|
132
|
+
keys_to_exclude = {COL_RETURN, COL_RISK, COL_SHARPE}
|
|
133
|
+
ds = portfolio[~portfolio.index.isin(keys_to_exclude)]
|
|
134
|
+
ds = ds[ds > 0.0001]
|
|
135
|
+
|
|
136
|
+
if ds.empty:
|
|
137
|
+
ds = pd.Series({"(no allocation)": 1.0})
|
|
138
|
+
|
|
139
|
+
fig = go.Figure(
|
|
140
|
+
data=go.Pie(
|
|
141
|
+
labels=ds.index.tolist(),
|
|
142
|
+
values=ds.values,
|
|
143
|
+
hole=0.4,
|
|
144
|
+
textinfo="label+percent",
|
|
145
|
+
hoverinfo="skip",
|
|
146
|
+
),
|
|
147
|
+
layout=go.Layout(margin={"b": 0, "t": 0}),
|
|
148
|
+
)
|
|
149
|
+
return fig
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def capital_allocation_line_plot(
|
|
153
|
+
cal_points: list[dict],
|
|
154
|
+
highlight_point: int = 0,
|
|
155
|
+
) -> Figure:
|
|
156
|
+
"""
|
|
157
|
+
Plot the Capital Allocation Line (CAL) showing risk-return trade-offs
|
|
158
|
+
for different mixes of risky portfolio and risk-free asset.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
cal_points : list of dict
|
|
163
|
+
Output from CapitalAllocator.capital_allocation_line().
|
|
164
|
+
highlight_point : int, optional
|
|
165
|
+
Index of the point to highlight (default 0).
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
plotly.graph_objects.Figure
|
|
170
|
+
"""
|
|
171
|
+
proportions = [p["p"] for p in cal_points]
|
|
172
|
+
retornos = [p["expected_return"] for p in cal_points]
|
|
173
|
+
|
|
174
|
+
text = [
|
|
175
|
+
f"<b>Risk-Free Proportion:</b> {p['p']:.0%}<br>"
|
|
176
|
+
f"<b>Expected Return:</b> {p['expected_return']:.1%} ± {p['risk']:.1%} p.m."
|
|
177
|
+
for p in cal_points
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
marker_color, marker_size = _marker_styles(len(cal_points), highlight_point)
|
|
181
|
+
|
|
182
|
+
fig = go.Figure(
|
|
183
|
+
data=go.Scatter(
|
|
184
|
+
x=proportions,
|
|
185
|
+
y=retornos,
|
|
186
|
+
mode="lines+markers",
|
|
187
|
+
hovertext=text,
|
|
188
|
+
hoverinfo="text",
|
|
189
|
+
marker={
|
|
190
|
+
"size": marker_size,
|
|
191
|
+
"color": marker_color,
|
|
192
|
+
"opacity": 1,
|
|
193
|
+
"line": {"color": "blue", "width": 2},
|
|
194
|
+
},
|
|
195
|
+
line={"color": "blue", "width": 3},
|
|
196
|
+
),
|
|
197
|
+
layout={
|
|
198
|
+
"margin": _DEFAULT_MARGIN,
|
|
199
|
+
"xaxis": {
|
|
200
|
+
"tickformat": ",.0%",
|
|
201
|
+
"autorange": "reversed",
|
|
202
|
+
"title": {"text": "Risk-Free Proportion"},
|
|
203
|
+
},
|
|
204
|
+
"yaxis": {
|
|
205
|
+
"tickformat": ",.1%",
|
|
206
|
+
"title": {"text": "Expected Return (% p.m.)"},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return fig
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def correlation_timeline(
|
|
215
|
+
prices: pd.DataFrame,
|
|
216
|
+
ticker_a: str,
|
|
217
|
+
ticker_b: str | None = None,
|
|
218
|
+
) -> Figure:
|
|
219
|
+
"""
|
|
220
|
+
Plot the price history of one or two assets, normalizing when comparing
|
|
221
|
+
two different assets.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
prices : pd.DataFrame
|
|
226
|
+
DataFrame of historical prices with DatetimeIndex and tickers as columns.
|
|
227
|
+
ticker_a : str
|
|
228
|
+
Primary ticker.
|
|
229
|
+
ticker_b : str or None, optional
|
|
230
|
+
Secondary ticker. If None or equal to ticker_a, plots a single line.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
plotly.graph_objects.Figure
|
|
235
|
+
"""
|
|
236
|
+
if ticker_b is None or ticker_a == ticker_b:
|
|
237
|
+
return _plot_single(prices, ticker_a)
|
|
238
|
+
|
|
239
|
+
return _plot_multi(prices, ticker_a, ticker_b)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _plot_single(prices: pd.DataFrame, ticker: str) -> Figure:
|
|
243
|
+
"""Plot a single ticker's price history."""
|
|
244
|
+
ds = prices[ticker].dropna()
|
|
245
|
+
|
|
246
|
+
fig = go.Figure(
|
|
247
|
+
data=go.Scatter(
|
|
248
|
+
x=ds.index,
|
|
249
|
+
y=ds,
|
|
250
|
+
name=ticker,
|
|
251
|
+
hoverinfo="skip",
|
|
252
|
+
),
|
|
253
|
+
layout={
|
|
254
|
+
"margin": _DEFAULT_MARGIN,
|
|
255
|
+
"showlegend": False,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
return fig
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _plot_multi(prices: pd.DataFrame, ticker_a: str, ticker_b: str) -> Figure:
|
|
262
|
+
"""Plot two tickers' price histories, normalized to [0, 1]."""
|
|
263
|
+
df = prices[[ticker_a, ticker_b]].dropna()
|
|
264
|
+
|
|
265
|
+
fig = go.Figure(
|
|
266
|
+
layout={
|
|
267
|
+
"yaxis": {"visible": False},
|
|
268
|
+
"margin": _DEFAULT_MARGIN,
|
|
269
|
+
"showlegend": False,
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
for ticker in [ticker_a, ticker_b]:
|
|
274
|
+
series = df[ticker]
|
|
275
|
+
min_val, max_val = series.min(), series.max()
|
|
276
|
+
normalized = (series - min_val) / (max_val - min_val) if max_val > min_val else series * 0
|
|
277
|
+
|
|
278
|
+
fig.add_trace(
|
|
279
|
+
go.Scatter(
|
|
280
|
+
x=df.index,
|
|
281
|
+
y=normalized,
|
|
282
|
+
name=ticker,
|
|
283
|
+
hoverinfo="skip",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return fig
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def correlation_heatmap(corr_matrix: pd.DataFrame) -> Figure:
|
|
291
|
+
"""
|
|
292
|
+
Plot a correlation matrix heatmap.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
corr_matrix : pd.DataFrame
|
|
297
|
+
Square correlation matrix with ticker names as index and columns.
|
|
298
|
+
|
|
299
|
+
Returns
|
|
300
|
+
-------
|
|
301
|
+
plotly.graph_objects.Figure
|
|
302
|
+
"""
|
|
303
|
+
fig = go.Figure(
|
|
304
|
+
data=go.Heatmap(
|
|
305
|
+
z=corr_matrix.values,
|
|
306
|
+
x=corr_matrix.columns.tolist(),
|
|
307
|
+
y=corr_matrix.index.tolist(),
|
|
308
|
+
text=corr_matrix.values.round(2),
|
|
309
|
+
texttemplate="%{text}",
|
|
310
|
+
colorscale="RdBu",
|
|
311
|
+
zmid=0,
|
|
312
|
+
zmin=-1,
|
|
313
|
+
zmax=1,
|
|
314
|
+
hovertemplate="%{x} vs %{y}: %{z:.2f}<extra></extra>",
|
|
315
|
+
),
|
|
316
|
+
layout={
|
|
317
|
+
"margin": {"b": 80, "t": 30, "l": 80, "r": 20},
|
|
318
|
+
"xaxis": {"side": "bottom"},
|
|
319
|
+
"yaxis": {"autorange": "reversed"},
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
return fig
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: markowizard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Markowitz portfolio optimization and analysis library
|
|
5
|
+
Author-email: GusFurtado <gustavofurtado2@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/OutliersAnalytics/MarkoWizard
|
|
8
|
+
Project-URL: Repository, https://github.com/OutliersAnalytics/MarkoWizard
|
|
9
|
+
Project-URL: Documentation, https://github.com/OutliersAnalytics/MarkoWizard#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/OutliersAnalytics/MarkoWizard/issues
|
|
11
|
+
Keywords: portfolio,finance,optimization,markowitz,efficient-frontier
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.22
|
|
25
|
+
Requires-Dist: pandas>=1.4
|
|
26
|
+
Requires-Dist: scipy>=1.8
|
|
27
|
+
Requires-Dist: yfinance>=0.1.63
|
|
28
|
+
Requires-Dist: plotly>=5.10
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# MarkoWizard
|
|
32
|
+
|
|
33
|
+
A modern Python library for Markowitz portfolio optimization and analysis.
|
|
34
|
+
|
|
35
|
+
[](https://opensource.org/licenses/MIT)
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
|
|
38
|
+
> **Previously known as _Diversificador_.** The original portfolio-analysis web app
|
|
39
|
+
> built with [Dash](https://dash.plotly.com/) is no longer maintained, but it is
|
|
40
|
+
> preserved on the [`dash-deprecated`](../../tree/dash-deprecated) branch for reference.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **Markowitz Mean-Variance Optimization** — Compute the efficient frontier using `scipy.optimize`
|
|
45
|
+
- **Capital Allocation Line** — Mix risky portfolios with risk-free assets
|
|
46
|
+
- **Visualization** — Plotly-based charts for efficient frontier, allocation pie, CAL, correlation heatmaps, and price timelines
|
|
47
|
+
- **Data Fetching** — Optional convenience functions for downloading market data via yfinance
|
|
48
|
+
- **Web Application** — FastAPI backend with a dark-themed interactive frontend
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install markowizard
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That's everything the library needs: optimization (`scipy`), market-data
|
|
57
|
+
fetching (`yfinance`), and visualization (`plotly`). No optional extras.
|
|
58
|
+
|
|
59
|
+
## Quick Start (Library)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from markowizard import MarkowitzOptimizer, CapitalAllocator
|
|
63
|
+
from markowizard.data import fetch_prices, compute_monthly_returns
|
|
64
|
+
from markowizard.visualization import efficiency_frontier_plot
|
|
65
|
+
|
|
66
|
+
# Fetch prices and compute monthly returns (decimal form, e.g. 0.01 = 1%)...
|
|
67
|
+
prices = fetch_prices(["AAPL", "MSFT", "GOOGL", "SPY"], period="5y")
|
|
68
|
+
returns = compute_monthly_returns(prices)
|
|
69
|
+
# ...or bring your own returns DataFrame (assets as columns).
|
|
70
|
+
|
|
71
|
+
# Optimize
|
|
72
|
+
optimizer = MarkowitzOptimizer(returns)
|
|
73
|
+
portfolios = optimizer.optimize()
|
|
74
|
+
|
|
75
|
+
# Compute Sharpe ratios (provide monthly risk-free rate)
|
|
76
|
+
risk_free_rate = 0.005 # 0.5% per month
|
|
77
|
+
portfolios = optimizer.compute_sharpe(risk_free_rate)
|
|
78
|
+
|
|
79
|
+
# Plot the efficient frontier
|
|
80
|
+
fig = efficiency_frontier_plot(portfolios, highlight_portfolio=50)
|
|
81
|
+
fig.show()
|
|
82
|
+
|
|
83
|
+
# Best portfolio (maximum Sharpe ratio)
|
|
84
|
+
best = optimizer.max_sharpe_portfolio()
|
|
85
|
+
print(best)
|
|
86
|
+
|
|
87
|
+
# Capital allocation line
|
|
88
|
+
allocator = CapitalAllocator(best, risk_free_rate)
|
|
89
|
+
cal_points = allocator.capital_allocation_line(steps=21)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Web Application
|
|
93
|
+
|
|
94
|
+
An interactive web UI (FastAPI + a dark-themed frontend) lives in `backend/` and
|
|
95
|
+
`frontend/`. It is **not part of the PyPI package** — run it from the container
|
|
96
|
+
image or a clone.
|
|
97
|
+
|
|
98
|
+
### Using Docker
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
docker run -p 8000:8000 ghcr.io/outliersanalytics/markowizard:latest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### From a clone
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git clone https://github.com/OutliersAnalytics/MarkoWizard
|
|
108
|
+
cd MarkoWizard
|
|
109
|
+
uv run --with-requirements backend/requirements.txt uvicorn backend.main:app --port 8000
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Open [http://localhost:8000](http://localhost:8000) — the app auto-submits with
|
|
113
|
+
default tickers on load.
|
|
114
|
+
|
|
115
|
+
It exposes a single endpoint, `POST /api/analyze`:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"tickers": ["AAPL", "MSFT", "GOOGL", "SPY"],
|
|
120
|
+
"period": "5y",
|
|
121
|
+
"risk_free_rate": 0.005
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
which returns the efficient frontier, max-Sharpe portfolio, capital-allocation-line
|
|
126
|
+
points, and correlation matrix as JSON. The frontend renders the charts.
|
|
127
|
+
|
|
128
|
+
## API Reference
|
|
129
|
+
|
|
130
|
+
### `markowizard` (top-level)
|
|
131
|
+
|
|
132
|
+
| Export | Description |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `MarkowitzOptimizer` | Efficient frontier optimization (from `core`) |
|
|
135
|
+
| `CapitalAllocator` | Risk-free asset allocation (from `allocation`) |
|
|
136
|
+
| `__version__` | Package version string |
|
|
137
|
+
|
|
138
|
+
### `markowizard.core`
|
|
139
|
+
|
|
140
|
+
#### `MarkowitzOptimizer`
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
class MarkowitzOptimizer:
|
|
144
|
+
def __init__(self, returns: pd.DataFrame) -> None
|
|
145
|
+
def optimize(self) -> pd.DataFrame
|
|
146
|
+
def compute_sharpe(self, risk_free_rate: float) -> pd.DataFrame
|
|
147
|
+
def max_sharpe_portfolio(self) -> pd.Series
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Constants**: `COL_RETURN = "Expected Return"`, `COL_RISK = "Risk"`, `COL_SHARPE = "Sharpe"`, `COL_RISK_FREE = "Risk-Free"`
|
|
151
|
+
|
|
152
|
+
**Parameters**:
|
|
153
|
+
- `returns`: DataFrame where each column is an asset and each row is a time period. Values must be in decimal form (e.g., 0.01 = 1%).
|
|
154
|
+
|
|
155
|
+
**`optimize()`** computes the efficient frontier by solving 100 quadratic programming problems with varying risk-aversion parameters. Uses warm-starting: each iteration's solution seeds the next.
|
|
156
|
+
|
|
157
|
+
**`compute_sharpe(risk_free_rate)`** adds a `Sharpe` column. `risk_free_rate` must match the period of `returns` (e.g., monthly).
|
|
158
|
+
|
|
159
|
+
**`max_sharpe_portfolio()`** returns the tangency portfolio row.
|
|
160
|
+
|
|
161
|
+
#### `MarkowitzOptimizer.portfolios` DataFrame columns
|
|
162
|
+
|
|
163
|
+
| Column | Description |
|
|
164
|
+
|---|---|
|
|
165
|
+
| (ticker columns) | Asset weights (sum to 1, all >= 0) |
|
|
166
|
+
| `Expected Return` | Expected portfolio return |
|
|
167
|
+
| `Risk` | Portfolio standard deviation (risk) |
|
|
168
|
+
| `Sharpe` | Sharpe ratio (after `compute_sharpe()`) |
|
|
169
|
+
|
|
170
|
+
### `markowizard.allocation`
|
|
171
|
+
|
|
172
|
+
#### `CapitalAllocator`
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
class CapitalAllocator:
|
|
176
|
+
def __init__(self, portfolio: pd.Series | Mapping, risk_free_rate: float) -> None
|
|
177
|
+
@staticmethod
|
|
178
|
+
def weigh_risk_free(value: float, risk_free_value: float, p: float) -> float
|
|
179
|
+
def capital_allocation_line(self, steps: int = 21) -> list[dict]
|
|
180
|
+
def final_allocation(self, p: float) -> dict[str, float]
|
|
181
|
+
def expected_returns(self, p: float) -> tuple[float, float]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**`capital_allocation_line()`** returns points along the CAL, each with keys `p`, `expected_return`, `risk`, and `label`.
|
|
185
|
+
|
|
186
|
+
**`final_allocation(p)`** returns asset weights including `Risk-Free` (risk-free portion).
|
|
187
|
+
|
|
188
|
+
### `markowizard.visualization`
|
|
189
|
+
|
|
190
|
+
| Function | Returns | Description |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `efficiency_frontier_plot(portfolios, highlight_portfolio=0)` | `Figure` | Scatter plot of expected return vs risk |
|
|
193
|
+
| `allocation_pie(portfolio)` | `Figure` | Pie chart of asset weights |
|
|
194
|
+
| `capital_allocation_line_plot(cal_points, highlight_point=0)` | `Figure` | CAL risk-return trade-off |
|
|
195
|
+
| `correlation_timeline(prices, ticker_a, ticker_b=None)` | `Figure` | Price history (single or normalized dual) |
|
|
196
|
+
| `correlation_heatmap(corr_matrix)` | `Figure` | Correlation matrix heatmap |
|
|
197
|
+
|
|
198
|
+
All visualization functions return Plotly `Figure` objects — call `.show()` to display.
|
|
199
|
+
|
|
200
|
+
### `markowizard.data`
|
|
201
|
+
|
|
202
|
+
| Function | Returns | Description |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `fetch_prices(tickers, period="5y", auto_adjust=True)` | `pd.DataFrame` | Historical close prices from Yahoo Finance |
|
|
205
|
+
| `compute_monthly_returns(prices)` | `pd.DataFrame` | Monthly returns from daily close prices |
|
|
206
|
+
|
|
207
|
+
## Modules
|
|
208
|
+
|
|
209
|
+
| Module | Description |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `core` | `MarkowitzOptimizer` — efficient frontier optimization |
|
|
212
|
+
| `allocation` | `CapitalAllocator` — risk-free asset allocation |
|
|
213
|
+
| `visualization` | Plotly chart functions (efficient frontier, pie, CAL, correlation) |
|
|
214
|
+
| `data` | Market-data fetching and monthly-return helpers (yfinance) |
|
|
215
|
+
|
|
216
|
+
The web application (`backend/`, `frontend/`) is kept in the repo but is not
|
|
217
|
+
part of the installable package — see [Web Application](#web-application).
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions and contribution guidelines.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
markowizard/__init__.py,sha256=RkdrrVq_FtvFlzEDZ2wJE6nvuBS3QQjGmrCmPL_xF5I,524
|
|
2
|
+
markowizard/allocation.py,sha256=FNgmvAEo_ILaQtOIJaxSvLgqNWSNc64GFlLIrxEceng,4140
|
|
3
|
+
markowizard/core.py,sha256=YAjAHopZl_ZkKUxIkV-noyvGmQZWJh8wa3WA4JKt9vE,6231
|
|
4
|
+
markowizard/data.py,sha256=vWibjfd4MZmDawdNK01xHuvqLAg8mjHu-J-UfF-ClY0,2635
|
|
5
|
+
markowizard/visualization.py,sha256=GYo8jbjNQrFJdahlM2wZC0M1KhbQQhag9WC3yY7h_zE,8914
|
|
6
|
+
markowizard-0.1.0.dist-info/licenses/LICENSE,sha256=ciVK0IDK6_Waky-Px_PPTIaurSZryh1qd40Uz8Z8c08,1072
|
|
7
|
+
markowizard-0.1.0.dist-info/METADATA,sha256=Mjy5bghlDPmcTgi6acrkli1eCE9Vo7q55X_4BDWBUkk,8354
|
|
8
|
+
markowizard-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
markowizard-0.1.0.dist-info/top_level.txt,sha256=RoPJgri1ExsDK7MJ_HVigphIwlMMc71zTPXrJdFZ54k,12
|
|
10
|
+
markowizard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Gustavo Furtado
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
markowizard
|