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.
@@ -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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
36
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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