Skill: joeyiptk/modern-portfolio-theory (version 1.0.0)

--- name: modern-portfolio-theory description: > Modern Portfolio Theory optimizer — build, backtest, and manage diversified portfolios user-invocable: true metadata: > openclaw: requires: bins: [python3] install: - type: uv package: mpt-portfolio emoji: "📊" homepage: https://github.com/user/modern-portfolio-theory os: [linux, darwin] version: 1.0.0 --- # Modern Portfolio Theory Portfolio Optimizer You help the user build and manage investment portfolios using Modern Portfolio Theory. You have exactly **3 jobs**: 1. **Ask questions** and write `config.yaml` 2. **Call CLI commands** and parse their output 3. **Explain results** in plain language Everything else — optimization, backtesting, rebalancing, scheduling — is handled by pure Python. You are a thin wrapper. --- ## MUST DO - Follow the numbered setup steps below **in exact order** - **STOP AND WAIT** for the user's reply after every question — do NOT batch questions - Run EVERY command exactly as documented in the CLI reference below - Parse `---JSON---` blocks from CLI output for structured data - Explain results in simple, non-technical language first, then details if asked - When setting up cron, the cron job runs Python directly — **no LLM in the loop** ## MUST NOT DO - **NEVER** modify any `.py` file in `mpt_portfolio/` or anywhere in this project - **NEVER** modify `config/default.yaml` - **NEVER** skip a question in the setup flow - **NEVER** run the next step until the current step completes and user has replied (where required) - **NEVER** invent CLI flags or commands not listed in the CLI reference below - **NEVER** be in the loop for scheduled or recurring operations --- ## MODE 1: INITIAL SETUP When a user wants to create a new portfolio, walk through these steps **one at a time**. ### Step 1: Verify environment Run: ```bash python3 --version python3 -c "import mpt_portfolio; print(mpt_portfolio.__version__)" ``` If not installed, tell the user: ```bash cd /path/to/modern-portfolio-theory pip install -r requirements.txt ``` **STOP AND WAIT** for output before continuing. ### Step 2: Ask portfolio name Ask: "What would you like to name this portfolio? (e.g., 'retirement', 'growth', 'balanced'). Must be alphanumeric with hyphens or underscores only." **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 3: Ask initial investment Ask: "How much are you investing initially? (default: $100,000)" **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 4: Ask asset selection Ask: "Which assets would you like to include? Here is a default set of diversified sector ETFs and alternatives: **Sector ETFs:** XLC (Communications), XLY (Consumer Discretionary), XLP (Consumer Staples), XLE (Energy), XLF (Financials), XLV (Health Care), XLI (Industrials), XLB (Materials), XLRE (Real Estate), XLK (Technology), XLU (Utilities) **Alternatives:** GLD (Gold), DBC (Commodities), TLT (Long-Term Treasuries) Would you like to use these defaults, or provide your own list of stock/ETF tickers?" **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 5: Ask benchmark Ask: "What benchmark would you like to compare against? (default: SPY — S&P 500). Other options: QQQ (Nasdaq), IWM (Small Cap), VTI (Total Market), AGG (Bonds)." **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 6: Ask price type Ask: "For historical prices, I recommend **adjusted prices** (accounts for dividends and splits — true total return). The alternative is **unadjusted prices** (raw trading prices, price return only). Which do you prefer? (default: adjusted)" **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 7a: Ask lookback period Ask: "How many years of historical data should I use for estimating returns and covariance? (default: 5 years, minimum 2). This is the lookback window — at each rebalance point, the optimizer looks back this many years to estimate expected returns and risk. More years = more market conditions captured but may include stale data." **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 7b: Ask backtest length Ask: "How many years should the backtest simulation cover? (default: 5 years). This is separate from the lookback — the backtest simulates actual trading over this period to compare rebalancing strategies. Longer backtest = more confidence, but requires all assets to have sufficient history. Note: total data needed = lookback + backtest years." **STOP AND WAIT** for user reply. DO NOT proceed until user replies. ### Step 8: Ask optimization preferences Ask these **one at a time**: **8a — Method:** "Which optimization method? - **Max Sharpe Ratio** (default, recommended): Best risk-adjusted return — most return per unit of risk. - **Minimum Variance**: Lowest possible risk, regardless of return. Very conservative. - **Risk Parity**: Each asset contributes equally to portfolio risk. More balanced. - **Efficient Frontier**: Calculates the full range of optimal portfolios." **STOP AND WAIT.** **8b — Short selling:** "Should the optimizer be allowed to short-sell? Most individual investors should say **no**. (default: no)" **STOP AND WAIT.** **8c — Max weight:** "What's the maximum percentage any single asset can take? Prevents over-concentration. (default: 40%). Lower = more diversified." **STOP AND WAIT.** ### Step 9: Create portfolio and write config Run: ```bash python3 -m mpt_portfolio setup -p <name> --no-interactive ``` Then write ALL of the user's choices from Steps 2-8 into `portfolios/<name>/config.yaml`. Use `config/default.yaml` as the reference template. Make sure to set both `data.lookback_years` (from Step 7a) and `backtest.backtest_years` (from Step 7b). **STOP AND WAIT** for command output. ### Step 10: Run optimization (includes backtest + report) Run: ```bash python3 -m mpt_portfolio opt -p <name> ``` This automatically runs optimization, backtest, and generates the full HTML report with charts. All output files are saved to `portfolios/<name>/reports/`. **STOP AND WAIT** for output. Then explain results — see Mode 2 below. ### Step 11: Set up rebalancing schedule Based on the backtest recommendation from Step 10, tell the user: 1. The recommended rebalancing strategy and why 2. The exact cron line to set up automated rebalancing: ```bash crontab -e # Add the recommended cron line from the output ``` **Important:** The cron job runs `scripts/cron_rebalance.sh` which calls Python directly. **No LLM is involved in recurring rebalancing.** The cron job: - Refreshes price data - Checks if rebalancing is needed - Generates a report in `portfolios/<name>/reports/` The user can check results anytime with: ```bash python -m mpt_portfolio status -p <name> ``` ### Step 12: Set up performance monitoring (optional) Ask: "Would you like automated performance reports? Options: - **Weekly**: Generate a performance report every Monday at 9am - **Monthly**: Generate a performance report on the first trading day of each month - **None** (default): No automated reports — you can run the `performance` command manually anytime" **STOP AND WAIT** for user reply. DO NOT proceed until user replies. If the user chooses weekly or monthly: 1. Update `portfolios/<name>/config.yaml` to set `monitoring.frequency` to the chosen value 2. Provide the cron line: | Frequency | Cron Line | |-----------|-----------| | Weekly | `0 9 * * 1 scripts/cron_performance.sh <name>` | | Monthly | `0 9 1-7 * 1-5 scripts/cron_performance.sh <name>` | Tell the user to add this to their crontab alongside the rebalancing schedule. Like the rebalancing cron, **no LLM is involved** — it runs Python directly. Regardless of choice, mention: "You can always generate a performance report on demand with `python -m mpt_portfolio performance -p <name>`" --- ## MODE 2: EXPLAIN RESULTS When you run a CLI command or the user shows you output, explain it like this: 1. **2-3 plain-language sentences** summarizing what happened 2. Specifics only if the user asks ### Metrics reference | Metric | What It Means | Good Value | |--------|--------------|------------| | Sharpe Ratio | Return per unit of risk | > 1.0 good, > 2.0 excellent | | Sortino Ratio | Return per unit of downside risk | > 1.5 good | | Max Drawdown | Worst peak-to-trough loss | < 20% conservative, < 30% moderate | | CAGR | Average annual compound return | Compare to benchmark | | Volatility | Annual price swing magnitude | < 15% low, 15-25% moderate, > 25% high | | Calmar Ratio | Return per unit of worst loss | > 1.0 good | ### Interpreting optimization output - **Weights**: How much money goes into each asset. Assets at 0% didn't make the cut. - **Quantities**: Exact and whole share counts, plus residual cash after rounding. - **Sharpe Ratio**: Above 1.0 is good, above 2.0 is excellent. ### Interpreting backtest output - **Recommended strategy**: Balances risk-adjusted return with acceptable drawdown. - **Rebalance History**: Shows which assets were selected at each rebalance — different stocks may enter or exit as conditions change. --- ## MODE 3: ONGOING MANAGEMENT These commands can be run at any time. The LLM's role is to call them and explain the output. ### Check portfolio status ```bash python -m mpt_portfolio status -p <name> ``` ### View performance report ```bash python -m mpt_portfolio performance -p <name> ``` Generates a report showing actual portfolio performance since creation, including equity curve, drawdown, rolling Sharpe ratio, and comparison against benchmark. If backtest data is available, shows a "Live Start" marker on the equity curve. ### Add or remove assets ```bash python -m mpt_portfolio modify-assets -p <name> --add NVDA,AAPL --remove XLB ``` This updates the config and clears the price cache. The new asset set takes effect at the **next rebalance** — no need to re-run backtest. ### Check if rebalancing is needed ```bash python -m mpt_portfolio rebalance -p <name> ``` ### Execute rebalance (only with user confirmation) ```bash python -m mpt_portfolio rebalance -p <name> --execute ``` ### Generate fresh report ```bash python -m mpt_portfolio report -p <name> ``` ### Refresh price data ```bash python -m mpt_portfolio update-data -p <name> ``` ### Compare portfolios ```bash python -m mpt_portfolio compare -p name1 -p name2 ``` ### Delete portfolio ```bash python -m mpt_portfolio delete -p <name> ``` ### List all portfolios ```bash python -m mpt_portfolio list ``` --- ## CLI COMMAND REFERENCE (COMPLETE LIST) ``` python -m mpt_portfolio setup -p <name> [--config-file <path>] [--no-interactive] python -m mpt_portfolio opt -p <name> [--method max_sharpe|min_variance|efficient_frontier|risk_parity] [--no-auto-report] python -m mpt_portfolio backtest -p <name> python -m mpt_portfolio modify-assets -p <name> --add TICKER1,TICKER2 --remove TICKER3 python -m mpt_portfolio status -p <name> python -m mpt_portfolio rebalance -p <name> [--execute] python -m mpt_portfolio report -p <name> [--format html|terminal|both] python -m mpt_portfolio performance -p <name> [--format html|terminal|both] python -m mpt_portfolio update-data -p <name> python -m mpt_portfolio compare -p <name1> -p <name2> [-p <name3> ...] python -m mpt_portfolio delete -p <name> [--force] python -m mpt_portfolio list ``` **These are the ONLY commands that exist. Do NOT invent flags or subcommands not listed here.** --- ## CRON SCHEDULING (NO LLM AFTER SETUP) After setup, the user's cron job runs Python directly. The LLM is NOT involved. | Strategy | Cron Line | |----------|-----------| | Monthly | `0 9 1-7 * 1-5 scripts/cron_rebalance.sh <name>` | | Quarterly | `0 9 1-7 1,4,7,10 1-5 scripts/cron_rebalance.sh <name>` | | Yearly | `0 9 1-7 1 1-5 scripts/cron_rebalance.sh <name>` | | Dynamic | `0 9 * * 1-5 scripts/cron_rebalance.sh <name>` | The cron script handles: data refresh, drift check, rebalance if needed, report generation. **Performance Monitoring (separate cron job):** | Frequency | Cron Line | |-----------|-----------| | Weekly | `0 9 * * 1 scripts/cron_performance.sh <name>` | | Monthly | `0 9 1-7 * 1-5 scripts/cron_performance.sh <name>` | The performance cron runs `update-data`, `status` (records snapshot), and `performance` (generates HTML report). If the user asks about their portfolio later, run `status`, `report`, or `performance` and explain the output. --- ## TROUBLESHOOTING - **yfinance errors**: `pip install --upgrade yfinance` - **Missing tickers**: Some tickers may not be in yfinance. Suggest alternatives. - **Optimization failure**: Try switching covariance to `ledoit_wolf` or reducing assets. - **Not enough data**: Increase `lookback_years` or verify tickers have sufficient history (minimum 2 years). - **Data sufficiency warning**: Some assets (especially newer ETFs) may not have enough history for the configured lookback + backtest period. The optimizer will show a per-asset availability table and suggest either removing short-history assets or reducing lookback/backtest years.

path: requirements.txt
content: yfinance>=0.2.36
numpy>=1.24
pandas>=2.0
scipy>=1.11
matplotlib>=3.7
pyyaml>=6.0
rich>=13.0
click>=8.1


path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] - 2026-05-23

### Added
- Initial release of Modern Portfolio Theory Portfolio Optimizer.
- Support for generating `config.yaml` using structured user questioning.
- Integration with CLI tools for optimization and backtesting.
- Markdown report generation including performance metrics and asset weights.
- Automated daily rebalancing and performance tracking via cron scripts.
- Portfolios and reports management system.


path: SETUP.md
content: # Setup: Modern Portfolio Theory Optimizer

This skill helps you build and manage investment portfolios using Markowitz's Modern Portfolio Theory.

## Prerequisites

- **Python 3.10+**
- `uv` (recommended) or `pip` for package management.

## Installation

1. Install the required Python packages:
   ```bash
   pip install yfinance>=0.2.36 numpy>=1.24 pandas>=2.0 scipy>=1.11 matplotlib>=3.7 pyyaml>=6.0 rich>=13.0 click>=8.1
   ```
   (Alternatively, use `uv pip install -r requirements.txt`)

2. Verify that `python3` is in your PATH.

## Initial Configuration

The skill will guide you through creating your first portfolio `config.yaml`. No manual file editing is required for first-time setup.

## Cron Scheduling

For automated monitoring and rebalancing:
1. Ensure the `scripts/` directory has execute permissions:
   ```bash
   chmod +x scripts/*.sh
   ```
2. Schedule performance tracking (e.g., daily at market close).
3. Schedule rebalancing checks (e.g., weekly or monthly).


path: config/default.yaml
content: # Modern Portfolio Theory — Default Configuration
# Copy this file to portfolios/<name>/config.yaml and customize.

portfolio:
  name: "default"
  initial_investment: 100000
  assets:
    - XLC   # Communication Services
    - XLY   # Consumer Discretionary
    - XLP   # Consumer Staples
    - XLE   # Energy
    - XLF   # Financials
    - XLV   # Health Care
    - XLI   # Industrials
    - XLB   # Materials
    - XLRE  # Real Estate
    - XLK   # Technology
    - XLU   # Utilities
    - GLD   # Gold
    - DBC   # Commodities
    - TLT   # Long-Term Treasuries
  benchmark: SPY  # Any valid ticker; e.g. QQQ, IWM, VTI, AGG

data:
  # Lookback window for estimating returns and covariance.
  # Longer (5-7yr) captures more market regimes but may include stale data.
  # Shorter (2-3yr) is more responsive but noisier / less stable.
  # Minimum 2 years required for reliable covariance estimation.
  lookback_years: 5
  frequency: daily
  # adjusted  — prices corrected for splits/dividends; reflects total return (recommended)
  # unadjusted — raw trading prices; reflects price return only
  price_type: adjusted

optimization:
  method: max_sharpe  # max_sharpe | min_variance | efficient_frontier | risk_parity
  risk_free_rate: auto  # "auto" fetches 3-month US Treasury yield, or set a float like 0.05
  expected_returns: mean_historical  # mean_historical | exp_weighted | black_litterman
  covariance: ledoit_wolf  # sample | ledoit_wolf
  constraints:
    long_only: true
    max_weight: 0.40
    min_weight: 0.0

backtest:
  backtest_years: 5  # how many years to simulate (separate from lookback for optimization)
  start_date: null   # null = derive from backtest_years; or "2019-01-01"
  end_date: null     # null = today
  rebalancing_strategies:
    - monthly
    - quarterly
    - yearly
    - dynamic
  dynamic_threshold: 0.05   # 5% weight drift triggers rebalance
  transaction_cost: 0.001   # 0.1% per trade
  include_buy_and_hold: true

rebalancing:
  strategy: recommended  # recommended | monthly | quarterly | yearly | dynamic

reports:
  formats:
    - html
    - terminal
  charts:
    - efficient_frontier
    - correlation_heatmap
    - weights
    - equity_curves
    - drawdown
    - rolling_sharpe

monitoring:
  # Performance report frequency: weekly | monthly | none
  # Set up a cron job with scripts/cron_performance.sh to automate
  frequency: none

# Email notifications (optional) — fill in SMTP details to receive portfolio reports by email.
# Use ${ENV_VAR} syntax for secrets (e.g. smtp_password: "${SMTP_PASSWORD}").
# Set notifications.method to "email" or "both" to activate.
email:
  enabled: false
  smtp_host: null
  smtp_port: 587
  smtp_use_tls: true
  smtp_user: null
  smtp_password: null
  sender: null
  recipients: []


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "mpt-portfolio"
version = "0.1.0"
description = "Modern Portfolio Theory portfolio optimizer with backtesting and rebalancing"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = [
    "yfinance>=0.2.36",
    "numpy>=1.24",
    "pandas>=2.0",
    "scipy>=1.11",
    "matplotlib>=3.7",
    "pyyaml>=6.0",
    "rich>=13.0",
    "click>=8.1",
]

[project.scripts]
mpt-portfolio = "mpt_portfolio.cli:main"

[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-cov>=4.0"]

[tool.pytest.ini_options]
testpaths = ["tests"]


path: tests/conftest.py
content: """Shared test fixtures with deterministic fake data."""

from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

from mpt_portfolio.config import (
    BacktestConfig,
    Config,
    ConstraintsConfig,
    DataConfig,
    EmailConfig,
    NotificationEventsConfig,
    OptimizationConfig,
    PortfolioConfig,
    RebalancingConfig,
    ReportsConfig,
)


@pytest.fixture
def sample_prices() -> pd.DataFrame:
    """Deterministic fake price data: 5 assets, ~3 years (756 trading days).

    Uses geometric Brownian motion with fixed seed.
    """
    rng = np.random.default_rng(42)
    n_days = 756
    tickers = ["A", "B", "C", "D", "E"]
    drifts = np.array([0.0003, 0.0005, 0.0001, 0.0004, 0.0002])
    vols = np.array([0.01, 0.02, 0.008, 0.015, 0.012])
    start_prices = np.array([100.0, 50.0, 200.0, 75.0, 150.0])

    dates = pd.bdate_range("2021-01-04", periods=n_days)
    prices = np.zeros((n_days, len(tickers)))
    prices[0] = start_prices

    for t in range(1, n_days):
        shocks = rng.standard_normal(len(tickers))
        prices[t] = prices[t - 1] * np.exp(drifts - 0.5 * vols**2 + vols * shocks)

    return pd.DataFrame(prices, index=dates, columns=tickers)


@pytest.fixture
def sample_returns(sample_prices: pd.DataFrame) -> pd.DataFrame:
    """Log returns from sample prices."""
    return np.log(sample_prices / sample_prices.shift(1)).dropna()


@pytest.fixture
def sample_config() -> Config:
    """Minimal valid Config for testing."""
    return Config(
        portfolio=PortfolioConfig(
            name="test_portfolio",
            initial_investment=100_000,
            assets=["A", "B", "C", "D", "E"],
            benchmark="SPY",
        ),
        data=DataConfig(lookback_years=3, frequency="daily", price_type="adjusted"),
        optimization=OptimizationConfig(
            method="max_sharpe",
            risk_free_rate=0.04,
            expected_returns="mean_historical",
            covariance="ledoit_wolf",
            constraints=ConstraintsConfig(long_only=True, max_weight=0.40, min_weight=0.0),
        ),
        backtest=BacktestConfig(
            rebalancing_strategies=["monthly", "quarterly"],
            dynamic_threshold=0.05,
            transaction_cost=0.001,
            include_buy_and_hold=True,
        ),
        rebalancing=RebalancingConfig(strategy="quarterly"),
        reports=ReportsConfig(formats=["terminal"], charts=[]),
    )


@pytest.fixture
def two_asset_prices() -> pd.DataFrame:
    """Simple 2-asset price data for analytical solution testing."""
    rng = np.random.default_rng(123)
    n_days = 504
    dates = pd.bdate_range("2022-01-03", periods=n_days)

    prices = np.zeros((n_days, 2))
    prices[0] = [100.0, 100.0]
    for t in range(1, n_days):
        prices[t, 0] = prices[t - 1, 0] * np.exp(0.0003 + 0.01 * rng.standard_normal())
        prices[t, 1] = prices[t - 1, 1] * np.exp(0.0002 + 0.015 * rng.standard_normal())

    return pd.DataFrame(prices, index=dates, columns=["X", "Y"])


path: tests/test_data_sufficiency.py
content: """Tests for data sufficiency check."""

import numpy as np
import pandas as pd
import pytest

from mpt_portfolio.data import check_data_sufficiency


class TestDataSufficiency:
    def _make_prices(self, n_days: int, tickers: list[str], gaps: dict | None = None) -> pd.DataFrame:
        """Create fake price data with optional per-ticker gaps at the start."""
        rng = np.random.default_rng(42)
        dates = pd.bdate_range("2015-01-05", periods=n_days)
        data = {}
        for t in tickers:
            prices = 100.0 * np.exp(np.cumsum(rng.standard_normal(n_days) * 0.01))
            if gaps and t in gaps:
                prices[:gaps[t]] = np.nan
            data[t] = prices
        return pd.DataFrame(data, index=dates)

    def test_all_sufficient(self):
        prices = self._make_prices(2520, ["A", "B", "C"])
        report = check_data_sufficiency(prices, lookback_years=5, backtest_years=5)
        assert report.all_sufficient
        assert len(report.insufficient_assets) == 0
        assert all(a.sufficient for a in report.assets)

    def test_one_asset_insufficient(self):
        prices = self._make_prices(2520, ["A", "B", "C"], gaps={"C": 2000})
        report = check_data_sufficiency(prices, lookback_years=5, backtest_years=5)
        assert not report.all_sufficient
        assert "C" in report.insufficient_assets
        assert report.assets[2].ticker == "C"
        assert not report.assets[2].sufficient
        assert report.assets[0].sufficient
        assert report.assets[1].sufficient

    def test_suggested_max_years(self):
        prices = self._make_prices(756, ["A", "B"])
        report = check_data_sufficiency(prices, lookback_years=5, backtest_years=5)
        assert report.suggested_max_years == pytest.approx(756 / 252, abs=0.1)

    def test_effective_common_days_with_gaps(self):
        prices = self._make_prices(1000, ["A", "B"], gaps={"B": 200})
        report = check_data_sufficiency(prices, lookback_years=2, backtest_years=1)
        assert report.effective_common_days == 800
        assert report.assets[0].available_days == 1000
        assert report.assets[1].available_days == 800

    def test_all_sufficient_small_requirement(self):
        prices = self._make_prices(756, ["A", "B"])
        report = check_data_sufficiency(prices, lookback_years=2, backtest_years=1)
        assert report.all_sufficient


path: tests/test_metrics.py
content: """Tests for metrics.py — known-answer performance calculations."""

import numpy as np
import pandas as pd
import pytest

from mpt_portfolio.metrics import (
    compute_drawdown_series,
    compute_metrics,
    compute_rolling_sharpe,
    compare_metrics,
)


@pytest.fixture
def simple_portfolio():
    """Portfolio that goes 100 -> 120 linearly over 252 days."""
    dates = pd.bdate_range("2023-01-03", periods=252)
    values = np.linspace(100, 120, 252)
    return pd.Series(values, index=dates)


@pytest.fixture
def drawdown_portfolio():
    """Portfolio: 100 -> 150 -> 90 -> 130."""
    dates = pd.bdate_range("2023-01-03", periods=4)
    values = [100, 150, 90, 130]
    return pd.Series(values, index=dates)


class TestComputeMetrics:
    def test_total_return(self, simple_portfolio):
        m = compute_metrics(simple_portfolio, 0.04)
        assert abs(m.total_return - 0.20) < 0.01

    def test_positive_cagr(self, simple_portfolio):
        m = compute_metrics(simple_portfolio, 0.04)
        assert m.cagr > 0

    def test_positive_volatility(self, simple_portfolio):
        m = compute_metrics(simple_portfolio, 0.04)
        assert m.annualized_volatility > 0

    def test_win_rate_bounds(self, simple_portfolio):
        m = compute_metrics(simple_portfolio, 0.04)
        assert 0 <= m.win_rate <= 1

    def test_empty_series(self):
        s = pd.Series([100.0], index=pd.bdate_range("2023-01-03", periods=1))
        m = compute_metrics(s, 0.04)
        assert m.total_return == 0.0


class TestDrawdown:
    def test_max_drawdown(self, drawdown_portfolio):
        dd = compute_drawdown_series(drawdown_portfolio)
        expected_max = (150 - 90) / 150
        assert abs(dd.max() - expected_max) < 1e-10

    def test_zero_at_start(self, drawdown_portfolio):
        dd = compute_drawdown_series(drawdown_portfolio)
        assert dd.iloc[0] == 0.0

    def test_non_negative(self, simple_portfolio):
        dd = compute_drawdown_series(simple_portfolio)
        assert (dd >= -1e-10).all()


class TestRollingSharpe:
    def test_returns_series(self, simple_portfolio):
        daily_returns = simple_portfolio.pct_change().dropna()
        rs = compute_rolling_sharpe(daily_returns, 0.04, window=63)
        assert isinstance(rs, pd.Series)
        assert len(rs) > 0

    def test_window_effect(self, simple_portfolio):
        daily_returns = simple_portfolio.pct_change().dropna()
        rs_short = compute_rolling_sharpe(daily_returns, 0.04, window=21)
        rs_long = compute_rolling_sharpe(daily_returns, 0.04, window=63)
        assert len(rs_short) > len(rs_long)


class TestCompareMetrics:
    def test_dataframe_output(self, simple_portfolio):
        m1 = compute_metrics(simple_portfolio, 0.04)
        m2 = compute_metrics(simple_portfolio * 1.1, 0.04)
        df = compare_metrics({"A": m1, "B": m2})
        assert isinstance(df, pd.DataFrame)
        assert len(df) == 2
        assert "Sharpe" in df.columns


path: tests/test_returns.py
content: """Tests for returns.py — return calculation correctness."""

import numpy as np
import pandas as pd
import pytest

from mpt_portfolio.returns import (
    annualize_returns,
    compute_log_returns,
    exp_weighted_returns,
    get_expected_returns,
)


class TestComputeLogReturns:
    def test_basic(self, sample_prices):
        returns = compute_log_returns(sample_prices)
        assert len(returns) == len(sample_prices) - 1
        assert list(returns.columns) == list(sample_prices.columns)

    def test_no_nans(self, sample_prices):
        returns = compute_log_returns(sample_prices)
        assert not returns.isna().any().any()

    def test_log_return_formula(self):
        prices = pd.DataFrame({"A": [100.0, 110.0, 105.0]})
        returns = compute_log_returns(prices)
        expected = [np.log(110.0 / 100.0), np.log(105.0 / 110.0)]
        np.testing.assert_allclose(returns["A"].values, expected, rtol=1e-10)


class TestAnnualizeReturns:
    def test_shape(self, sample_returns):
        ann = annualize_returns(sample_returns)
        assert len(ann) == sample_returns.shape[1]

    def test_formula(self, sample_returns):
        ann = annualize_returns(sample_returns)
        manual = sample_returns.mean() * 252
        pd.testing.assert_series_equal(ann, manual)


class TestExpWeightedReturns:
    def test_shape(self, sample_returns):
        ew = exp_weighted_returns(sample_returns)
        assert len(ew) == sample_returns.shape[1]

    def test_different_from_mean(self, sample_returns):
        ew = exp_weighted_returns(sample_returns)
        mean = annualize_returns(sample_returns)
        assert not np.allclose(ew.values, mean.values)


class TestGetExpectedReturns:
    def test_mean_historical(self, sample_returns):
        result = get_expected_returns(sample_returns, "mean_historical")
        expected = annualize_returns(sample_returns)
        pd.testing.assert_series_equal(result, expected)

    def test_exp_weighted(self, sample_returns):
        result = get_expected_returns(sample_returns, "exp_weighted")
        assert len(result) == sample_returns.shape[1]

    def test_unknown_method(self, sample_returns):
        with pytest.raises(ValueError, match="Unknown"):
            get_expected_returns(sample_returns, "invalid_method")


path: tests/test_rebalance.py
content: """Tests for rebalance.py — order generation."""

import pandas as pd
import pytest

from mpt_portfolio.rebalance import generate_orders, TradeOrder


class TestGenerateOrders:
    def test_basic_rebalance(self):
        current = pd.Series({"A": 0.6, "B": 0.4})
        target = pd.Series({"A": 0.5, "B": 0.5})
        prices = pd.Series({"A": 100.0, "B": 50.0})
        orders = generate_orders(current, target, 100_000, prices, 0.001)
        assert len(orders) == 2
        sell = [o for o in orders if o.action == "SELL"]
        buy = [o for o in orders if o.action == "BUY"]
        assert len(sell) == 1
        assert len(buy) == 1
        assert sell[0].ticker == "A"
        assert buy[0].ticker == "B"

    def test_no_orders_when_aligned(self):
        current = pd.Series({"A": 0.5, "B": 0.5})
        target = pd.Series({"A": 0.5, "B": 0.5})
        prices = pd.Series({"A": 100.0, "B": 50.0})
        orders = generate_orders(current, target, 100_000, prices, 0.001)
        assert len(orders) == 0

    def test_dollar_amounts_net_to_zero(self):
        current = pd.Series({"A": 0.7, "B": 0.2, "C": 0.1})
        target = pd.Series({"A": 0.4, "B": 0.3, "C": 0.3})
        prices = pd.Series({"A": 100.0, "B": 50.0, "C": 25.0})
        orders = generate_orders(current, target, 100_000, prices, 0.001)
        total_dollars = sum(o.dollar_amount for o in orders)
        assert abs(total_dollars) < 0.01

    def test_new_asset_added(self):
        current = pd.Series({"A": 1.0})
        target = pd.Series({"A": 0.5, "B": 0.5})
        prices = pd.Series({"A": 100.0, "B": 50.0})
        orders = generate_orders(current, target, 100_000, prices, 0.001)
        buy = [o for o in orders if o.action == "BUY"]
        assert any(o.ticker == "B" for o in buy)

    def test_shares_calculation(self):
        current = pd.Series({"A": 0.0})
        target = pd.Series({"A": 1.0})
        prices = pd.Series({"A": 50.0})
        orders = generate_orders(current, target, 10_000, prices, 0.0)
        assert len(orders) == 1
        assert abs(orders[0].shares - 200.0) < 0.01  # $10,000 / $50 = 200 shares


path: tests/test_risk.py
content: """Tests for risk.py — covariance estimation."""

import numpy as np
import pandas as pd
import pytest

from mpt_portfolio.risk import (
    correlation_matrix,
    get_covariance,
    ledoit_wolf_covariance,
    sample_covariance,
)


class TestSampleCovariance:
    def test_shape(self, sample_returns):
        cov = sample_covariance(sample_returns)
        n = sample_returns.shape[1]
        assert cov.shape == (n, n)

    def test_symmetric(self, sample_returns):
        cov = sample_covariance(sample_returns)
        np.testing.assert_allclose(cov.values, cov.values.T, atol=1e-12)

    def test_positive_semidefinite(self, sample_returns):
        cov = sample_covariance(sample_returns)
        eigenvalues = np.linalg.eigvalsh(cov.values)
        assert (eigenvalues >= -1e-10).all()

    def test_annualized(self, sample_returns):
        cov = sample_covariance(sample_returns, trading_days=252)
        daily_cov = sample_returns.cov()
        np.testing.assert_allclose(cov.values, daily_cov.values * 252, rtol=1e-10)


class TestLedoitWolfCovariance:
    def test_shape(self, sample_returns):
        cov = ledoit_wolf_covariance(sample_returns)
        n = sample_returns.shape[1]
        assert cov.shape == (n, n)

    def test_symmetric(self, sample_returns):
        cov = ledoit_wolf_covariance(sample_returns)
        np.testing.assert_allclose(cov.values, cov.values.T, atol=1e-12)

    def test_positive_definite(self, sample_returns):
        cov = ledoit_wolf_covariance(sample_returns)
        eigenvalues = np.linalg.eigvalsh(cov.values)
        assert (eigenvalues > 0).all()

    def test_different_from_sample(self, sample_returns):
        sample = sample_covariance(sample_returns)
        lw = ledoit_wolf_covariance(sample_returns)
        assert not np.allclose(sample.values, lw.values)


class TestCorrelationMatrix:
    def test_diagonal_ones(self, sample_returns):
        cov = sample_covariance(sample_returns)
        corr = correlation_matrix(cov)
        np.testing.assert_allclose(np.diag(corr.values), 1.0, atol=1e-10)

    def test_range(self, sample_returns):
        cov = sample_covariance(sample_returns)
        corr = correlation_matrix(cov)
        assert (corr.values >= -1.0 - 1e-10).all()
        assert (corr.values <= 1.0 + 1e-10).all()


class TestGetCovariance:
    def test_sample(self, sample_returns):
        cov = get_covariance(sample_returns, "sample")
        expected = sample_covariance(sample_returns)
        pd.testing.assert_frame_equal(cov, expected)

    def test_ledoit_wolf(self, sample_returns):
        cov = get_covariance(sample_returns, "ledoit_wolf")
        assert cov.shape == (sample_returns.shape[1], sample_returns.shape[1])

    def test_unknown(self, sample_returns):
        with pytest.raises(ValueError, match="Unknown"):
            get_covariance(sample_returns, "invalid")


path: tests/test_notifications.py
content: """Tests for notifications.py — file output, email dispatch, HTML templates."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from mpt_portfolio.config import (
    Config,
    EmailConfig,
    NotificationEventsConfig,
    PortfolioConfig,
    RebalancingConfig,
)
from mpt_portfolio.notifications import (
    _build_backtest_complete_email,
    _build_performance_report_email,
    _build_portfolio_created_email,
    _build_rebalance_reminder_email,
    notify_file,
    send_notification,
    notify_portfolio_created,
    notify_backtest_complete,
    notify_rebalance_reminder,
    notify_performance_report,
)


@pytest.fixture
def config_file_only() -> Config:
    return Config(
        portfolio=PortfolioConfig(name="test"),
        notifications=NotificationEventsConfig(method="file"),
        email=EmailConfig(enabled=False),
    )


@pytest.fixture
def config_email_enabled() -> Config:
    return Config(
        portfolio=PortfolioConfig(name="test"),
        notifications=NotificationEventsConfig(method="both"),
        email=EmailConfig(
            enabled=True,
            smtp_host="smtp.test.com",
            smtp_port=587,
            smtp_use_tls=True,
            smtp_user="[REDACTED_SECRET]",
            smtp_password="pass",
            sender="[REDACTED_SECRET]",
            recipients=["[REDACTED_SECRET]"],
        ),
    )


@pytest.fixture
def sample_quantities() -> dict:
    return {
        "XLK": {"weight": 0.35, "price": 200.0, "exact_shares": 175.0, "whole_shares": 175},
        "GLD": {"weight": 0.25, "price": 180.0, "exact_shares": 138.89, "whole_shares": 138},
    }


class TestNotifyFile:
    def test_creates_file(self, tmp_path, monkeypatch):
        monkeypatch.setattr(
            "mpt_portfolio.notifications.get_portfolio_reports_dir",
            lambda name: tmp_path,
        )
        path = notify_file("test", "Test Subject", "Test body")
        assert path.exists()
        content = path.read_text()
        assert "Subject: Test Subject" in content
        assert "Test body" in content


class TestSendNotification:
    def test_file_method_no_email(self, config_file_only, tmp_path, monkeypatch):
        monkeypatch.setattr(
            "mpt_portfolio.notifications.get_portfolio_reports_dir",
            lambda name: tmp_path,
        )
        with patch("mpt_portfolio.notifications.notify_email") as mock_email:
            send_notification("test", config_file_only, "Subj", "<html/>", "plain", None)
            mock_email.assert_not_called()
        assert any(f.name.startswith("notification_") for f in tmp_path.iterdir())

    def test_email_disabled_skips(self, tmp_path, monkeypatch):
        config = Config(
            portfolio=PortfolioConfig(name="test"),
            notifications=NotificationEventsConfig(method="email"),
            email=EmailConfig(enabled=False),
        )
        monkeypatch.setattr(
            "mpt_portfolio.notifications.get_portfolio_reports_dir",
            lambda name: tmp_path,
        )
        with patch("mpt_portfolio.notifications.notify_email") as mock_email:
            send_notification("test", config, "Subj", "<html/>", "plain", None)
            mock_email.assert_not_called()

    @patch("mpt_portfolio.notifications.notify_email", return_value=True)
    def test_both_method(self, mock_email, config_email_enabled, tmp_path, monkeypatch):
        monkeypatch.setattr(
            "mpt_portfolio.notifications.get_portfolio_reports_dir",
            lambda name: tmp_path,
        )
        send_notification("test", config_email_enabled, "Sub", "<h>", "p", None)
        mock_email.assert_called_once()
        assert any(f.name.startswith("notification_") for f in tmp_path.iterdir())


class TestEventToggles:
    def test_portfolio_created_disabled(self, config_file_only):
        config_file_only.notifications.portfolio_created = False
        with patch("mpt_portfolio.notifications.send_notification") as mock:
            notify_portfolio_created(
                "test", config_file_only, "max_sharpe", {}, 0.1, 0.15, 0.8, 0.04, 100000, {},
            )
            mock.assert_not_called()

    def test_backtest_disabled(self, config_file_only):
        config_file_only.notifications.backtest_complete = False
        with patch("mpt_portfolio.notifications.send_notification") as mock:
            notify_backtest_complete("test", config_file_only, "monthly", "reason", {})
            mock.assert_not_called()

    def test_rebalance_disabled(self, config_file_only):
        config_file_only.notifications.rebalance_reminder = False
        with patch("mpt_portfolio.notifications.send_notification") as mock:
            notify_rebalance_reminder("test", config_file_only, True, "drift", 0.08, "XLK", [], 10.0)
            mock.assert_not_called()

    def test_performance_disabled(self, config_file_only):
        config_file_only.notifications.performance_report = False
        with patch("mpt_portfolio.notifications.send_notification") as mock:
            notify_performance_report(
                "test", config_file_only, 110000, 0.10, 0.08, 0.9, 0.095, -0.12, 0.14, 180, 3,
            )
            mock.assert_not_called()


class TestHTMLTemplates:
    def test_portfolio_created_content(self, sample_quantities):
        html, plain = _build_portfolio_created_email(
            "my_port", "max_sharpe", {}, 0.12, 0.15, 0.85, 0.04, 100000, sample_quantities,
        )
        assert "my_port" in html
        assert "max_sharpe" in html
        assert "12.00%" in html
        assert "0.850" in html
        assert "XLK" in html
        assert "my_port" in plain
        assert "XLK" in plain

    def test_backtest_complete_content(self):
        strategies = {
            "monthly": {"total_return": 0.45, "cagr": 0.08, "sharpe": 0.9,
                        "max_drawdown": -0.18, "turnover": 1.2, "costs": 150.0},
            "quarterly": {"total_return": 0.40, "cagr": 0.07, "sharpe": 0.8,
                          "max_drawdown": -0.20, "turnover": 0.8, "costs": 100.0},
        }
        html, plain = _build_backtest_complete_email(
            "my_port", "monthly", "Best risk-adjusted", strategies,
        )
        assert "monthly" in html
        assert "quarterly" in html
        assert "Best risk-adjusted" in html
        assert "monthly" in plain

    def test_rebalance_needed_content(self):
        order = MagicMock()
        order.action = "BUY"
        order.ticker = "XLK"
        order.shares_rounded = 10
        order.dollar_amount = 2000.0
        order.current_weight = 0.30
        order.target_weight = 0.35
        html, plain = _build_rebalance_reminder_email(
            "my_port", True, "Drift exceeded threshold",
            0.08, "XLK", [order], 25.0,
        )
        assert "Rebalance Needed" in html
        assert "XLK" in html
        assert "BUY" in html
        assert "REBALANCE NEEDED" in plain

    def test_rebalance_not_needed(self):
        html, plain = _build_rebalance_reminder_email(
            "my_port", False, "Within threshold", 0.02, "GLD", [], 0.0,
        )
        assert "No Rebalance Needed" in html
        assert "No rebalance needed" in plain

    def test_performance_report_content(self):
        html, plain = _build_performance_report_email(
            "my_port", 125000, 0.25, 0.18, 0.95, 0.22, -0.10, 0.14, 365, 4,
        )
        assert "$125,000.00" in html
        assert "25.00%" in html
        assert "0.950" in html
        assert "$125,000.00" in plain


path: tests/test_backtest.py
content: """Tests for backtest.py — walk-forward correctness."""

import numpy as np
import pandas as pd
import pytest
from unittest.mock import patch, MagicMock

from mpt_portfolio.backtest import (
    generate_rebalance_dates,
    _check_dynamic_rebalance,
)
from mpt_portfolio.config import ConstraintsConfig


class TestGenerateRebalanceDates:
    @pytest.fixture
    def trading_dates(self):
        return pd.bdate_range("2022-01-03", "2023-12-29")

    def test_monthly_count(self, trading_dates):
        dates = generate_rebalance_dates(
            trading_dates[0], trading_dates[-1], "monthly", trading_dates,
        )
        assert 23 <= len(dates) <= 25  # ~24 months

    def test_quarterly_count(self, trading_dates):
        dates = generate_rebalance_dates(
            trading_dates[0], trading_dates[-1], "quarterly", trading_dates,
        )
        assert 7 <= len(dates) <= 9  # ~8 quarters

    def test_yearly_count(self, trading_dates):
        dates = generate_rebalance_dates(
            trading_dates[0], trading_dates[-1], "yearly", trading_dates,
        )
        assert len(dates) == 2

    def test_dates_are_trading_days(self, trading_dates):
        dates = generate_rebalance_dates(
            trading_dates[0], trading_dates[-1], "monthly", trading_dates,
        )
        for d in dates:
            assert d in trading_dates

    def test_invalid_frequency(self, trading_dates):
        with pytest.raises(ValueError):
            generate_rebalance_dates(
                trading_dates[0], trading_dates[-1], "biweekly", trading_dates,
            )


class TestCheckDynamicRebalance:
    """Tests for _check_dynamic_rebalance — compares current weights to fresh optimal."""

    def _mock_opt_result(self, weights_dict):
        result = MagicMock()
        result.weights = pd.Series(weights_dict)
        return result

    def test_no_rebalance_when_close_to_optimal(self):
        current = pd.Series({"A": 0.50, "B": 0.50})
        mock_result = self._mock_opt_result({"A": 0.52, "B": 0.48})
        dummy_prices = pd.DataFrame({"A": [1.0], "B": [1.0]})
        with patch("mpt_portfolio.backtest._run_optimization", return_value=mock_result):
            triggered, result = _check_dynamic_rebalance(
                current, dummy_prices, "max_sharpe", "mean_historical",
                "ledoit_wolf", 0.05, ConstraintsConfig(), 0.05,
            )
        assert not triggered

    def test_rebalance_when_far_from_optimal(self):
        current = pd.Series({"A": 0.30, "B": 0.70})
        mock_result = self._mock_opt_result({"A": 0.60, "B": 0.40})
        dummy_prices = pd.DataFrame({"A": [1.0], "B": [1.0]})
        with patch("mpt_portfolio.backtest._run_optimization", return_value=mock_result):
            triggered, result = _check_dynamic_rebalance(
                current, dummy_prices, "max_sharpe", "mean_historical",
                "ledoit_wolf", 0.05, ConstraintsConfig(), 0.05,
            )
        assert triggered
        assert result is mock_result

    def test_no_rebalance_on_optimization_failure(self):
        current = pd.Series({"A": 0.50, "B": 0.50})
        dummy_prices = pd.DataFrame({"A": [1.0], "B": [1.0]})
        with patch("mpt_portfolio.backtest._run_optimization", return_value=None):
            triggered, result = _check_dynamic_rebalance(
                current, dummy_prices, "max_sharpe", "mean_historical",
                "ledoit_wolf", 0.05, ConstraintsConfig(), 0.05,
            )
        assert not triggered
        assert result is None

    def test_distance_calculation(self):
        """Distance = sum(|current - optimal|) / 2. Here: (|0.4-0.5| + |0.6-0.5|) / 2 = 0.1"""
        current = pd.Series({"A": 0.40, "B": 0.60})
        mock_result = self._mock_opt_result({"A": 0.50, "B": 0.50})
        dummy_prices = pd.DataFrame({"A": [1.0], "B": [1.0]})
        with patch("mpt_portfolio.backtest._run_optimization", return_value=mock_result):
            triggered_low, _ = _check_dynamic_rebalance(
                current, dummy_prices, "max_sharpe", "mean_historical",
                "ledoit_wolf", 0.05, ConstraintsConfig(), 0.09,
            )
            triggered_high, _ = _check_dynamic_rebalance(
                current, dummy_prices, "max_sharpe", "mean_historical",
                "ledoit_wolf", 0.05, ConstraintsConfig(), 0.11,
            )
        assert triggered_low  # distance 0.1 > threshold 0.09
        assert not triggered_high  # distance 0.1 < threshold 0.11


path: tests/test_config.py
content: """Tests for config.py — validation and loading."""

import pytest
import yaml
from pathlib import Path

from mpt_portfolio.config import (
    Config,
    ConfigValidationError,
    EmailConfig,
    NotificationEventsConfig,
    load_default_config,
    validate_config,
    save_portfolio_config,
    create_portfolio_from_config,
)


class TestValidateConfig:
    def test_default_valid(self):
        raw = load_default_config()
        config = validate_config(raw)
        assert isinstance(config, Config)
        assert len(config.portfolio.assets) == 14

    def test_empty_assets_fails(self):
        raw = load_default_config()
        raw["portfolio"]["assets"] = []
        with pytest.raises(ConfigValidationError, match="non-empty"):
            validate_config(raw)

    def test_negative_investment_fails(self):
        raw = load_default_config()
        raw["portfolio"]["initial_investment"] = -1000
        with pytest.raises(ConfigValidationError, match="positive"):
            validate_config(raw)

    def test_invalid_method_fails(self):
        raw = load_default_config()
        raw["optimization"]["method"] = "invalid"
        with pytest.raises(ConfigValidationError):
            validate_config(raw)

    def test_invalid_price_type_fails(self):
        raw = load_default_config()
        raw["data"]["price_type"] = "invalid"
        with pytest.raises(ConfigValidationError, match="price_type"):
            validate_config(raw)

    def test_max_weight_out_of_range(self):
        raw = load_default_config()
        raw["optimization"]["constraints"]["max_weight"] = 1.5
        with pytest.raises(ConfigValidationError, match="max_weight"):
            validate_config(raw)

    def test_invalid_rebalancing_strategy(self):
        raw = load_default_config()
        raw["backtest"]["rebalancing_strategies"] = ["invalid"]
        with pytest.raises(ConfigValidationError):
            validate_config(raw)


class TestSaveAndLoad:
    def test_round_trip(self, sample_config, tmp_path, monkeypatch):
        monkeypatch.setattr("mpt_portfolio.config.get_portfolios_dir", lambda: tmp_path)
        portfolio_dir = tmp_path / "test"
        portfolio_dir.mkdir()
        path = save_portfolio_config("test", sample_config)
        assert path.exists()
        with open(path) as f:
            raw = yaml.safe_load(f)
        assert raw["portfolio"]["name"] == "test_portfolio"
        assert raw["data"]["price_type"] == "adjusted"


class TestMonitoringConfig:
    def test_default_frequency_is_none(self):
        raw = load_default_config()
        config = validate_config(raw)
        assert config.monitoring.frequency == "none"

    def test_valid_frequencies(self):
        for freq in ("weekly", "monthly", "none"):
            raw = load_default_config()
            raw.setdefault("monitoring", {})["frequency"] = freq
            config = validate_config(raw)
            assert config.monitoring.frequency == freq

    def test_invalid_frequency_fails(self):
        raw = load_default_config()
        raw["monitoring"] = {"frequency": "daily"}
        with pytest.raises(ConfigValidationError, match="monitoring.frequency"):
            validate_config(raw)

    def test_round_trip(self, sample_config, tmp_path, monkeypatch):
        monkeypatch.setattr("mpt_portfolio.config.get_portfolios_dir", lambda: tmp_path)
        sample_config.monitoring.frequency = "weekly"
        portfolio_dir = tmp_path / "test_monitor"
        portfolio_dir.mkdir()
        path = save_portfolio_config("test_monitor", sample_config)
        with open(path) as f:
            raw = yaml.safe_load(f)
        assert raw["monitoring"]["frequency"] == "weekly"


class TestEmailConfig:
    def test_defaults_disabled(self):
        raw = load_default_config()
        config = validate_config(raw)
        assert config.email.enabled is False
        assert config.email.smtp_host is None
        assert config.email.recipients == []

    def test_explicit_email_config(self):
        raw = load_default_config()
        raw["email"] = {
            "enabled": True,
            "smtp_host": "smtp.example.com",
            "smtp_port": 587,
            "smtp_use_tls": True,
            "smtp_user": "[REDACTED_SECRET]",
            "smtp_password": "secret",
            "sender": "[REDACTED_SECRET]",
            "recipients": ["[REDACTED_SECRET]", "[REDACTED_SECRET]"],
        }
        config = validate_config(raw)
        assert config.email.enabled is True
        assert config.email.smtp_host == "smtp.example.com"
        assert config.email.recipients == ["[REDACTED_SECRET]", "[REDACTED_SECRET]"]

    def test_round_trip(self, sample_config, tmp_path, monkeypatch):
        monkeypatch.setattr("mpt_portfolio.config.get_portfolios_dir", lambda: tmp_path)
        sample_config.email = EmailConfig(
            enabled=True, smtp_host="smtp.test.com", smtp_port=465,
            smtp_use_tls=True, smtp_user="u", smtp_password="p",
            sender="[REDACTED_SECRET]", recipients=["[REDACTED_SECRET]"],
        )
        portfolio_dir = tmp_path / "test_email"
        portfolio_dir.mkdir()
        path = save_portfolio_config("test_email", sample_config)
        with open(path) as f:
            raw = yaml.safe_load(f)
        assert raw["email"]["enabled"] is True
        assert raw["email"]["smtp_host"] == "smtp.test.com"
        assert raw["email"]["recipients"] == ["[REDACTED_SECRET]"]

    def test_email_saved_when_disabled(self, sample_config, tmp_path, monkeypatch):
        monkeypatch.setattr("mpt_portfolio.config.get_portfolios_dir", lambda: tmp_path)
        portfolio_dir = tmp_path / "test_no_email"
        portfolio_dir.mkdir()
        path = save_portfolio_config("test_no_email", sample_config)
        with open(path) as f:
            raw = yaml.safe_load(f)
        assert raw["email"]["enabled"] is False
        assert raw["email"]["smtp_host"] is None


class TestNotificationEventsConfig:
    def test_defaults(self):
        raw = load_default_config()
        config = validate_config(raw)
        assert config.notifications.method == "file"
        assert config.notifications.portfolio_created is True
        assert config.notifications.backtest_complete is True
        assert config.notifications.rebalance_reminder is True
        assert config.notifications.performance_report is True

    def test_invalid_method_fails(self):
        raw = load_default_config()
        raw["notifications"] = {"method": "invalid"}
        with pytest.raises(ConfigValidationError, match="notifications.method"):
            validate_config(raw)

    def test_custom_toggles(self):
        raw = load_default_config()
        raw["notifications"] = {
            "method": "both",
            "portfolio_created": False,
            "performance_report": False,
        }
        config = validate_config(raw)
        assert config.notifications.method == "both"
        assert config.notifications.portfolio_created is False
        assert config.notifications.backtest_complete is True
        assert config.notifications.performance_report is False


class TestBackwardCompatMigration:
    def test_old_notification_email_migrated(self):
        raw = load_default_config()
        raw["rebalancing"] = {
            "strategy": "monthly",
            "notification": {
                "method": "both",
                "email": {
                    "smtp_server": "smtp.old.com",
                    "smtp_port": 587,
                    "username": "old_user",
                    "password": "old_pass",
                    "from_addr": "[REDACTED_SECRET]",
                    "to_addr": "[REDACTED_SECRET]",
                },
            },
        }
        config = validate_config(raw)
        assert config.email.enabled is True
        assert config.email.smtp_host == "smtp.old.com"
        assert config.email.smtp_user == "old_user"
        assert config.email.smtp_password == "old_pass"
        assert config.email.sender == "[REDACTED_SECRET]"
        assert config.email.recipients == ["[REDACTED_SECRET]"]
        assert config.notifications.method == "both"

    def test_old_format_does_not_override_new(self):
        raw = load_default_config()
        raw["email"] = {
            "enabled": True,
            "smtp_host": "smtp.new.com",
            "sender": "[REDACTED_SECRET]",
            "recipients": ["[REDACTED_SECRET]"],
        }
        raw["rebalancing"] = {
            "strategy": "monthly",
            "notification": {
                "method": "email",
                "email": {"smtp_server": "smtp.old.com"},
            },
        }
        config = validate_config(raw)
        assert config.email.smtp_host == "smtp.new.com"


class TestCreatePortfolio:
    def test_creates_directory(self, sample_config, tmp_path, monkeypatch):
        monkeypatch.setattr("mpt_portfolio.config.get_portfolios_dir", lambda: tmp_path)
        sample_config.portfolio.name = "new_port"
        path = create_portfolio_from_config(sample_config)
        assert path.exists()
        assert (path / "config.yaml").exists()
        assert (path / "reports").is_dir()

    def test_duplicate_fails(self, sample_config, tmp_path, monkeypatch):
        monkeypatch.setattr("mpt_portfolio.config.get_portfolios_dir", lambda: tmp_path)
        sample_config.portfolio.name = "dup"
        create_portfolio_from_config(sample_config)
        with pytest.raises(FileExistsError):
            create_portfolio_from_config(sample_config)


path: tests/test_charts.py
content: """Tests for charts.py — matplotlib chart generation."""

from datetime import datetime

import numpy as np
import pandas as pd
import pytest
from matplotlib.figure import Figure

from mpt_portfolio.charts import (
    chart_correlation_heatmap,
    chart_drawdown,
    chart_efficient_frontier,
    chart_equity_curves,
    chart_rolling_sharpe,
    chart_weights_pie,
    export_chart_base64,
    export_chart_png,
)


class TestCharts:
    def test_efficient_frontier(self):
        fig = chart_efficient_frontier(
            np.array([0.05, 0.08, 0.10]),
            np.array([0.10, 0.12, 0.18]),
            min_var={"return": 0.05, "volatility": 0.10},
            max_sharpe={"return": 0.10, "volatility": 0.15},
        )
        assert isinstance(fig, Figure)

    def test_efficient_frontier_with_assets(self):
        fig = chart_efficient_frontier(
            np.array([0.05, 0.08, 0.10]),
            np.array([0.10, 0.12, 0.18]),
            individual_assets={"A": (0.15, 0.08), "B": (0.20, 0.12)},
            risk_free_rate=0.04,
        )
        assert isinstance(fig, Figure)

    def test_correlation_heatmap(self):
        corr = pd.DataFrame(
            [[1.0, 0.5], [0.5, 1.0]], index=["A", "B"], columns=["A", "B"],
        )
        fig = chart_correlation_heatmap(corr)
        assert isinstance(fig, Figure)

    def test_weights_pie(self):
        w = pd.Series({"A": 0.4, "B": 0.35, "C": 0.25})
        fig = chart_weights_pie(w)
        assert isinstance(fig, Figure)

    def test_equity_curves(self):
        dates = pd.bdate_range("2023-01-03", periods=100)
        curves = {"Strategy1": pd.Series(np.linspace(100, 120, 100), index=dates)}
        fig = chart_equity_curves(curves)
        assert isinstance(fig, Figure)

    def test_drawdown(self):
        dates = pd.bdate_range("2023-01-03", periods=100)
        dd = {"S1": pd.Series(np.random.default_rng(42).uniform(0, 0.1, 100), index=dates)}
        fig = chart_drawdown(dd)
        assert isinstance(fig, Figure)

    def test_rolling_sharpe(self):
        dates = pd.bdate_range("2023-01-03", periods=100)
        rs = {"S1": pd.Series(np.random.default_rng(42).uniform(-1, 2, 100), index=dates)}
        fig = chart_rolling_sharpe(rs)
        assert isinstance(fig, Figure)

    def test_weights_pie_filters_small(self):
        w = pd.Series({"A": 0.50, "B": 0.49, "C": 0.004, "D": 0.006})
        fig = chart_weights_pie(w)
        assert isinstance(fig, Figure)

    def test_export_chart_base64(self):
        fig = chart_weights_pie(pd.Series({"A": 0.6, "B": 0.4}))
        html = export_chart_base64(fig)
        assert html.startswith('<img src="data:image/png;base64,')
        assert html.endswith('">')

    def test_equity_curves_with_benchmark_ticker(self):
        dates = pd.bdate_range("2023-01-03", periods=100)
        curves = {"Strategy1": pd.Series(np.linspace(100, 120, 100), index=dates)}
        benchmark = pd.Series(np.linspace(100, 115, 100), index=dates)
        fig = chart_equity_curves(curves, benchmark=benchmark, benchmark_ticker="QQQ")
        assert isinstance(fig, Figure)

    def test_equity_curves_with_live_start_date(self):
        dates = pd.bdate_range("2023-01-03", periods=100)
        curves = {"Strategy1": pd.Series(np.linspace(100, 120, 100), index=dates)}
        live_date = datetime(2023, 3, 1)
        fig = chart_equity_curves(curves, live_start_date=live_date)
        assert isinstance(fig, Figure)

    def test_equity_curves_backward_compatible(self):
        dates = pd.bdate_range("2023-01-03", periods=100)
        curves = {"S1": pd.Series(np.linspace(100, 120, 100), index=dates)}
        fig = chart_equity_curves(curves)
        assert isinstance(fig, Figure)

    def test_export_chart_png(self, tmp_path):
        fig = chart_weights_pie(pd.Series({"A": 0.6, "B": 0.4}))
        path = export_chart_png(fig, tmp_path / "test.png")
        assert path.exists()
        assert path.stat().st_size > 0


path: tests/test_optimization.py
content: """Tests for optimization.py — weight constraints, math correctness."""

import numpy as np
import pandas as pd
import pytest

from mpt_portfolio.config import ConstraintsConfig
from mpt_portfolio.optimization import (
    EfficientFrontierResult,
    _clamp_weights,
    efficient_frontier,
    maximize_sharpe,
    minimize_variance,
    optimize,
    portfolio_return,
    portfolio_sharpe,
    portfolio_volatility,
    risk_parity,
)
from mpt_portfolio.returns import annualize_returns, compute_log_returns
from mpt_portfolio.risk import ledoit_wolf_covariance


@pytest.fixture
def opt_inputs(sample_returns):
    mu = annualize_returns(sample_returns)
    cov = ledoit_wolf_covariance(sample_returns)
    return mu, cov


class TestPortfolioFunctions:
    def test_portfolio_return(self):
        w = np.array([0.6, 0.4])
        mu = np.array([0.10, 0.20])
        assert abs(portfolio_return(w, mu) - 0.14) < 1e-10

    def test_portfolio_volatility(self):
        w = np.array([1.0, 0.0])
        cov = np.array([[0.04, 0.01], [0.01, 0.09]])
        assert abs(portfolio_volatility(w, cov) - 0.20) < 1e-10

    def test_portfolio_sharpe(self):
        w = np.array([0.5, 0.5])
        mu = np.array([0.10, 0.10])
        cov = np.array([[0.04, 0.0], [0.0, 0.04]])
        rf = 0.02
        vol = portfolio_volatility(w, cov)
        expected_sharpe = (0.10 - 0.02) / vol
        assert abs(portfolio_sharpe(w, mu, cov, rf) - expected_sharpe) < 1e-10


class TestMinimizeVariance:
    def test_weights_sum_to_one(self, opt_inputs):
        mu, cov = opt_inputs
        result = minimize_variance(mu, cov, 0.04, ConstraintsConfig())
        assert abs(result.weights.sum() - 1.0) < 1e-6

    def test_long_only(self, opt_inputs):
        mu, cov = opt_inputs
        result = minimize_variance(mu, cov, 0.04, ConstraintsConfig(long_only=True))
        assert (result.weights >= -1e-6).all()

    def test_max_weight_respected(self, opt_inputs):
        mu, cov = opt_inputs
        result = minimize_variance(mu, cov, 0.04, ConstraintsConfig(max_weight=0.30))
        assert (result.weights <= 0.30 + 1e-10).all()

    def test_returns_result(self, opt_inputs):
        mu, cov = opt_inputs
        result = minimize_variance(mu, cov, 0.04, ConstraintsConfig())
        assert result.method == "min_variance"
        assert result.volatility > 0
        assert isinstance(result.weights, pd.Series)


class TestMaximizeSharpe:
    def test_weights_sum_to_one(self, opt_inputs):
        mu, cov = opt_inputs
        result = maximize_sharpe(mu, cov, 0.04, ConstraintsConfig())
        assert abs(result.weights.sum() - 1.0) < 1e-6

    def test_higher_sharpe_than_min_var(self, opt_inputs):
        mu, cov = opt_inputs
        constraints = ConstraintsConfig()
        mv = minimize_variance(mu, cov, 0.04, constraints)
        ms = maximize_sharpe(mu, cov, 0.04, constraints)
        assert ms.sharpe_ratio >= mv.sharpe_ratio - 0.01

    def test_max_weight_respected(self, opt_inputs):
        mu, cov = opt_inputs
        result = maximize_sharpe(mu, cov, 0.04, ConstraintsConfig(max_weight=0.25))
        assert (result.weights <= 0.25 + 1e-10).all()


class TestEfficientFrontier:
    def test_returns_correct_type(self, opt_inputs):
        mu, cov = opt_inputs
        result = efficient_frontier(mu, cov, 0.04, ConstraintsConfig(), n_points=20)
        assert isinstance(result, EfficientFrontierResult)

    def test_multiple_points(self, opt_inputs):
        mu, cov = opt_inputs
        result = efficient_frontier(mu, cov, 0.04, ConstraintsConfig(), n_points=20)
        assert len(result.frontier_returns) >= 5

    def test_increasing_returns(self, opt_inputs):
        mu, cov = opt_inputs
        result = efficient_frontier(mu, cov, 0.04, ConstraintsConfig(), n_points=20)
        diffs = np.diff(result.frontier_returns)
        assert (diffs >= -1e-6).all()

    def test_contains_special_portfolios(self, opt_inputs):
        mu, cov = opt_inputs
        result = efficient_frontier(mu, cov, 0.04, ConstraintsConfig(), n_points=20)
        assert result.min_variance_portfolio is not None
        assert result.max_sharpe_portfolio is not None


class TestRiskParity:
    def test_weights_sum_to_one(self, opt_inputs):
        mu, cov = opt_inputs
        result = risk_parity(cov, 0.04, mu)
        assert abs(result.weights.sum() - 1.0) < 1e-6

    def test_positive_weights(self, opt_inputs):
        mu, cov = opt_inputs
        result = risk_parity(cov, 0.04, mu)
        assert (result.weights >= -1e-6).all()

    def test_roughly_equal_risk_contribution(self, opt_inputs):
        mu, cov = opt_inputs
        result = risk_parity(cov, 0.04, mu)
        w = result.weights.values
        sigma = cov.values
        vol = np.sqrt(w @ sigma @ w)
        marginal = sigma @ w
        rc = w * marginal / vol
        target = vol / len(w)
        assert np.allclose(rc, target, atol=0.005)


class TestOptimizeDispatcher:
    def test_max_sharpe(self, opt_inputs):
        mu, cov = opt_inputs
        result = optimize(mu, cov, 0.04, "max_sharpe")
        assert result.method == "max_sharpe"

    def test_efficient_frontier(self, opt_inputs):
        mu, cov = opt_inputs
        result = optimize(mu, cov, 0.04, "efficient_frontier")
        assert isinstance(result, EfficientFrontierResult)

    def test_unknown_method(self, opt_inputs):
        mu, cov = opt_inputs
        with pytest.raises(ValueError):
            optimize(mu, cov, 0.04, "unknown")


class TestClampWeights:
    def test_clips_above_max(self):
        w = np.array([0.5, 0.3, 0.2])
        c = ConstraintsConfig(max_weight=0.40, long_only=True)
        result = _clamp_weights(w, c)
        assert (result <= 0.40 + 1e-12).all()
        assert abs(result.sum() - 1.0) < 1e-10

    def test_clips_negative_when_long_only(self):
        w = np.array([-0.1, 0.6, 0.5])
        c = ConstraintsConfig(max_weight=1.0, long_only=True)
        result = _clamp_weights(w, c)
        assert (result >= -1e-12).all()
        assert abs(result.sum() - 1.0) < 1e-10

    def test_preserves_valid_weights(self):
        w = np.array([0.3, 0.3, 0.4])
        c = ConstraintsConfig(max_weight=0.40, long_only=True)
        result = _clamp_weights(w, c)
        assert abs(result.sum() - 1.0) < 1e-10
        assert np.allclose(result, w, atol=1e-10)

    def test_renormalizes_to_one(self):
        w = np.array([0.6, 0.6, 0.1])
        c = ConstraintsConfig(max_weight=0.50, long_only=True)
        result = _clamp_weights(w, c)
        assert abs(result.sum() - 1.0) < 1e-10


path: docs/deployment-openclaw.md
content: # Deployment: OpenClaw Skill

The Modern Portfolio Theory optimizer can run as an OpenClaw skill, allowing users to invoke it conversationally via `/modern-portfolio-theory`.

## Prerequisites

- OpenClaw installed and running on the host machine
- Python 3.10+ available on the same machine (the skill executes Python locally)
- The project must be cloned and its dependencies installed on the same machine running OpenClaw

## 1. Install the Project on the Host

```bash
git clone https://github.com/user/modern-portfolio-theory.git
cd modern-portfolio-theory
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```

Verify the CLI works:

```bash
python3 -m mpt_portfolio list
```

## 2. Register the Skill

The project ships with a `SKILL.md` at the repository root. OpenClaw discovers skills by scanning for `SKILL.md` files in configured skill directories or workspaces.

Option A -- symlink into the OpenClaw skills directory:

```bash
ln -s /path/to/modern-portfolio-theory /path/to/openclaw/skills/modern-portfolio-theory
```

Option B -- add the project directory as a workspace in your OpenClaw configuration so it is scanned automatically.

Option C -- copy `SKILL.md` into an existing skills directory alongside the full project:

```bash
cp -r /path/to/modern-portfolio-theory /path/to/openclaw/skills/modern-portfolio-theory
```

The key requirement is that OpenClaw can find the `SKILL.md` file and that the full project (including `mpt_portfolio/`, `portfolios/`, and `scripts/`) is in the same directory tree.

## 3. Verify the Skill Loads

Restart or reload OpenClaw, then confirm the skill is recognized:

```
/skills
```

You should see `modern-portfolio-theory` in the skill list with its description and the chart emoji.

## 4. Invoke the Skill

Start a conversation and type:

```
/modern-portfolio-theory
```

The skill will walk you through portfolio setup, optimization, backtesting, and rebalancing interactively. All CLI commands are executed behind the scenes.

## 5. Portfolio State

Portfolio data is stored in the `portfolios/` directory within the project. Each portfolio gets its own subdirectory with configuration, cached data, and reports. These persist between sessions.

## Important Notes

- **Python must be on the host**: OpenClaw invokes `python3` directly. The skill will not work if Python is unavailable or if the dependencies are not installed.
- **File paths are local**: The skill reads and writes to the local filesystem. If OpenClaw runs in a container, the project directory must be mounted into that container with read-write access.
- **No network services**: The skill only needs outbound HTTPS to Yahoo Finance for market data. It does not open any ports or run a server.
- **Cron is separate**: Scheduled rebalancing via `scripts/cron_rebalance.sh` must be set up independently on the host using cron or systemd timers. The OpenClaw skill handles interactive use, not scheduling.


path: README.md
content: # Modern Portfolio Theory Portfolio Optimizer

A Python CLI tool for building, optimizing, backtesting, and managing investment
portfolios using Markowitz mean-variance optimization. Supports multiple
optimization strategies, walk-forward backtesting with configurable rebalancing,
benchmark comparison, and automated reporting with interactive charts.

## Overview

This tool implements core Modern Portfolio Theory techniques:

- **Optimization**: Maximum Sharpe ratio, minimum variance, risk parity, and
  full efficient frontier construction.
- **Robust estimation**: Ledoit-Wolf covariance shrinkage to reduce estimation
  error in the covariance matrix.
- **Walk-forward backtesting**: Compare monthly, quarterly, yearly, and
  dynamic (drift-threshold) rebalancing strategies against a benchmark.
- **Transaction cost modeling**: Configurable per-trade cost (default 0.1%)
  applied during backtesting and rebalance calculations.
- **Reporting**: HTML reports with embedded charts (efficient frontier,
  equity curves, drawdown, rolling Sharpe, correlation heatmap, weight
  allocation) and Rich-formatted terminal output.
- **Multi-portfolio management**: Each portfolio has its own config, state, and
  report directory under `portfolios/`.
- **Automated rebalancing**: Cron-compatible rebalance checks with file or
  email notifications.

Default universe: XLC, XLY, XLP, XLE, XLF, XLV, XLI, XLB, XLRE, XLK, XLU,
GLD, DBC, TLT. Default benchmark: SPY. Both are user-configurable.

## Quick Start

### Requirements

- Python 3.10 or later
- pip

### Install

```bash
git clone <repo-url> && cd modern-portfolio-theory
pip install -r requirements.txt
```

Or install as a package (provides the `mpt-portfolio` entry point):

```bash
pip install .
```

### Create your first portfolio

There are two ways to set up a portfolio:

**Option A: LLM-guided setup (via OpenClaw / Claude Code)**

If using this project as an OpenClaw skill, the LLM walks you through each
decision step by step, explains options in plain language, and writes the
config for you. Just invoke the skill and say "create a new portfolio".

**Option B: Edit config file directly (Python CLI)**

When running the CLI directly, create the portfolio with defaults, then edit
the YAML config file:

```bash
# 1. Create portfolio structure with defaults
python -m mpt_portfolio setup -p my-portfolio --no-interactive

# 2. Edit the config to customize assets, method, constraints, etc.
#    See config/default.yaml for all options with comments.
nano portfolios/my-portfolio/config.yaml

# 3. Run optimization (automatically runs backtest + generates full report)
python -m mpt_portfolio opt -p my-portfolio
```

You can also create from a pre-written config file:
```bash
python -m mpt_portfolio setup -p my-portfolio --config-file my-config.yaml --no-interactive
```

### Day-to-day usage

```bash
# View current portfolio status (value, return, drift)
python -m mpt_portfolio status -p my-portfolio

# Add or remove assets (takes effect at next rebalance)
python -m mpt_portfolio modify-assets -p my-portfolio --add NVDA,AAPL --remove XLB

# Check if rebalancing is needed (dry-run)
python -m mpt_portfolio rebalance -p my-portfolio

# Compare multiple portfolios side by side
python -m mpt_portfolio compare -p portfolio-a -p portfolio-b

# List all portfolios
python -m mpt_portfolio list
```

Reports are written to `portfolios/my-portfolio/reports/`.

## CLI Commands

All commands are invoked via `python -m mpt_portfolio <command>`.

| Command         | Description                                              | Key Options                                                     |
|-----------------|----------------------------------------------------------|-----------------------------------------------------------------|
| `setup`         | Create a new portfolio with configuration                | `-p NAME`, `--config-file PATH`, `--no-interactive`             |
| `opt`           | Run optimization + backtest + report                     | `-p NAME`, `--method {max_sharpe,min_variance,efficient_frontier,risk_parity}`, `--no-auto-report` |
| `backtest`      | Walk-forward backtest across rebalancing strategies      | `-p NAME`                                                       |
| `modify-assets` | Add or remove assets for next rebalance                  | `-p NAME`, `--add TICKERS`, `--remove TICKERS`                  |
| `status`        | Show current value, return, and weight drift             | `-p NAME`                                                       |
| `rebalance`     | Check drift and generate rebalance orders                | `-p NAME`, `--execute` (default is dry-run)                     |
| `report`        | Generate full performance report                         | `-p NAME`, `--format {html,terminal,both}`                      |
| `update-data`   | Refresh cached price data from yfinance                  | `-p NAME`                                                       |
| `compare`       | Compare multiple portfolios side by side                 | `-p NAME1 -p NAME2 [-p NAME3 ...]`                              |
| `delete`        | Delete a portfolio and all its data                      | `-p NAME`, `--force`                                            |
| `list`          | List all configured portfolios                           | --                                                              |

Global options: `--verbose` / `-v` for debug logging, `--version`.

## Configuration

Each portfolio is configured via a YAML file at `portfolios/<name>/config.yaml`.
A commented default template lives at `config/default.yaml`.

Key sections:

```yaml
portfolio:
  name: "my-portfolio"
  initial_investment: 100000
  assets: [XLC, XLY, XLP, XLE, XLF, XLV, XLI, XLB, XLRE, XLK, XLU, GLD, DBC, TLT]
  benchmark: SPY

data:
  lookback_years: 5
  price_type: adjusted          # adjusted | unadjusted

optimization:
  method: max_sharpe            # max_sharpe | min_variance | efficient_frontier | risk_parity
  risk_free_rate: auto          # "auto" fetches 3-month Treasury yield, or a float
  covariance: ledoit_wolf       # sample | ledoit_wolf
  constraints:
    long_only: true
    max_weight: 0.40

backtest:
  rebalancing_strategies: [monthly, quarterly, yearly, dynamic]
  dynamic_threshold: 0.05       # 5% weight drift triggers rebalance
  transaction_cost: 0.001       # 0.1% per trade

rebalancing:
  strategy: recommended         # recommended | monthly | quarterly | yearly | dynamic
```

Email notifications are opt-in. Add an `email` section to your portfolio
config to enable:

```yaml
email:
  enabled: true
  smtp_host: smtp.gmail.com
  smtp_port: 587
  smtp_use_tls: true
  smtp_user: ${SMTP_USER}
  smtp_password: ${SMTP_PASSWORD}
  sender: ${SMTP_SENDER}
  recipients:
    - [REDACTED_SECRET]

notifications:
  method: both                  # file | email | both
  portfolio_created: true
  backtest_complete: true
  rebalance_reminder: true
  performance_report: true
```

Environment variable substitution is supported for secrets via `${VAR}`
syntax.

See `config/default.yaml` for the full reference with comments.

## Cron-Based Rebalancing

To automate rebalance checks, add a cron entry that runs the rebalance
script:

```bash
crontab -e

# Example: check every Monday at 9:00 AM
0 9 * * 1 /path/to/modern-portfolio-theory/scripts/cron_rebalance.sh my-portfolio
```

Notifications are delivered via file, email, or both, as configured in
the `notifications` and `email` sections of your portfolio `config.yaml`.

## OpenClaw Skill

This project also works as an OpenClaw skill. When loaded as a skill, an LLM
agent can invoke the CLI commands and parse the structured `---JSON---` output
blocks. See `SKILL.md` for the full skill specification.

## Project Structure

```
modern-portfolio-theory/
  config/default.yaml       Default configuration template
  mpt_portfolio/            Core Python package
    cli.py                  Click-based CLI entry point
    optimization.py         Mean-variance optimization solvers
    backtest.py             Walk-forward backtesting engine
    data.py                 yfinance data fetching and caching
    risk.py                 Covariance estimation (sample, Ledoit-Wolf)
    returns.py              Return computation (log, expected)
    metrics.py              Performance metrics (Sharpe, Sortino, drawdown, etc.)
    reports.py              HTML and terminal report generation
    charts.py               Matplotlib chart builders
    rebalance.py            Drift detection and order generation
    tracker.py              Portfolio state persistence
    config.py               YAML config loading and validation
    notifications.py        Email / file notification dispatch
  portfolios/               Per-portfolio config, state, and reports
  scripts/                  Cron and utility scripts
  tests/                    Test suite (pytest)
```

## Dependencies

yfinance, numpy, pandas, scipy, matplotlib, pyyaml, rich, click

Dev: pytest, pytest-cov

## Further Reading

Detailed documentation is available in the `docs/` directory.

## License

MIT


path: scripts/cron_rebalance.sh
content: #!/bin/bash
# MPT Portfolio Rebalance Cron Script
#
# Usage:
#   ./cron_rebalance.sh <portfolio_name> [python_path]
#
# Example crontab entries:
#   # Monthly (1st trading day, 9am)
#   0 9 1-7 * 1-5 /path/to/cron_rebalance.sh my_portfolio
#
#   # Quarterly (1st of Jan/Apr/Jul/Oct, 9am)
#   0 9 1-7 1,4,7,10 1-5 /path/to/cron_rebalance.sh my_portfolio
#
#   # Weekly (Monday 9am)
#   0 9 * * 1 /path/to/cron_rebalance.sh my_portfolio
#
#   # Daily check (dynamic rebalancing, 9am on weekdays)
#   0 9 * * 1-5 /path/to/cron_rebalance.sh my_portfolio

set -euo pipefail

PORTFOLIO="${1:?Usage: $0 <portfolio_name> [python_path]}"
PYTHON="${2:-python3}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="$PROJECT_DIR/portfolios/$PORTFOLIO/reports"
LOG_FILE="$LOG_DIR/cron_$(date +%Y%m%d_%H%M%S).log"

cd "$PROJECT_DIR"

# Activate venv if present
if [ -f "$PROJECT_DIR/.venv/bin/activate" ]; then
    source "$PROJECT_DIR/.venv/bin/activate"
fi

mkdir -p "$LOG_DIR"

{
    echo "=== MPT Portfolio Rebalance Check ==="
    echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    echo "Portfolio: $PORTFOLIO"
    echo ""

    # Update data
    $PYTHON -m mpt_portfolio update-data -p "$PORTFOLIO" 2>&1 || true
    echo ""

    # Check rebalance (dry run)
    $PYTHON -m mpt_portfolio rebalance -p "$PORTFOLIO" 2>&1
    echo ""

    # Update status
    $PYTHON -m mpt_portfolio status -p "$PORTFOLIO" 2>&1
    echo ""

    echo "=== Complete ==="
} >> "$LOG_FILE" 2>&1

echo "Log saved: $LOG_FILE"


path: scripts/setup.py
content: #!/usr/bin/env python3
import sys
import subprocess
import os

def check_python_version():
    print("Checking Python version...")
    major, minor = sys.version_info[:2]
    if major < 3 or minor < 10:
        print(f"Error: Python 3.10+ required. Found {major}.{minor}")
        return False
    print(f"OK: Python {major}.{minor} found.")
    return True

def install_dependencies():
    print("Installing dependencies from requirements.txt...")
    req_file = os.path.join(os.path.dirname(__file__), "..", "requirements.txt")
    if not os.path.exists(req_file):
        print(f"Error: {req_file} not found.")
        return False
    
    try:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file])
        print("OK: Dependencies installed.")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error installing dependencies: {e}")
        return False

def setup_permissions():
    print("Setting script permissions...")
    scripts_dir = os.path.dirname(__file__)
    for filename in os.listdir(scripts_dir):
        if filename.endswith(".sh"):
            filepath = os.path.join(scripts_dir, filename)
            os.chmod(filepath, 0o755)
            print(f"OK: chmod +x {filename}")
    return True

if __name__ == "__main__":
    success = True
    if not check_python_version(): success = False
    if not install_dependencies(): success = False
    if not setup_permissions(): success = False
    
    if success:
        print("\nSetup complete! You can now use the Modern Portfolio Theory skill.")
    else:
        print("\nSetup failed. Please check the errors above.")
        sys.exit(1)


path: scripts/cron_performance.sh
content: #!/bin/bash
# MPT Portfolio Performance Monitoring Cron Script
#
# Usage:
#   ./cron_performance.sh <portfolio_name> [python_path]
#
# Example crontab entries:
#   # Weekly (Monday 9am)
#   0 9 * * 1 /path/to/cron_performance.sh my_portfolio
#
#   # Monthly (1st trading day, 9am)
#   0 9 1-7 * 1-5 /path/to/cron_performance.sh my_portfolio

set -euo pipefail

PORTFOLIO="${1:?Usage: $0 <portfolio_name> [python_path]}"
PYTHON="${2:-python3}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="$PROJECT_DIR/portfolios/$PORTFOLIO/reports"
LOG_FILE="$LOG_DIR/perf_$(date +%Y%m%d_%H%M%S).log"

cd "$PROJECT_DIR"

# Activate venv if present
if [ -f "$PROJECT_DIR/.venv/bin/activate" ]; then
    source "$PROJECT_DIR/.venv/bin/activate"
fi

mkdir -p "$LOG_DIR"

{
    echo "=== MPT Portfolio Performance Report ==="
    echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    echo "Portfolio: $PORTFOLIO"
    echo ""

    # Update data
    $PYTHON -m mpt_portfolio update-data -p "$PORTFOLIO" 2>&1 || true
    echo ""

    # Update status (records performance history snapshot)
    $PYTHON -m mpt_portfolio status -p "$PORTFOLIO" 2>&1 || true
    echo ""

    # Generate performance report
    $PYTHON -m mpt_portfolio performance -p "$PORTFOLIO" --format html 2>&1
    echo ""

    echo "=== Complete ==="
} >> "$LOG_FILE" 2>&1

echo "Log saved: $LOG_FILE"


path: version.txt
content: 1.0.0


path: mpt_portfolio/metrics.py
content: """Performance metrics — pure math, no I/O.

Metrics:
  - Total return:       (V_final / V_initial) - 1
  - CAGR:               (V_final / V_initial)^(252/n_days) - 1
  - Annualized vol:     std(r_daily) * sqrt(252)
  - Sharpe:             (CAGR - Rf) / vol
  - Sortino:            (CAGR - Rf) / downside_deviation
  - Max drawdown:       max((peak - trough) / peak)
  - Calmar:             CAGR / |max_drawdown|
  - Win rate:           fraction of positive-return days
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime

import numpy as np
import pandas as pd

from mpt_portfolio.utils import TRADING_DAYS_PER_YEAR


@dataclass
class PerformanceMetrics:
    total_return: float
    cagr: float
    annualized_volatility: float
    sharpe_ratio: float
    sortino_ratio: float
    max_drawdown: float
    max_drawdown_start: datetime | None
    max_drawdown_end: datetime | None
    calmar_ratio: float
    win_rate: float
    total_turnover: float
    total_transaction_costs: float
    best_day: float
    worst_day: float
    avg_daily_return: float


def compute_drawdown_series(portfolio_values: pd.Series) -> pd.Series:
    """Running drawdown: (cummax - value) / cummax."""
    cummax = portfolio_values.cummax()
    drawdown = (cummax - portfolio_values) / cummax
    return drawdown


def compute_metrics(
    portfolio_values: pd.Series,
    risk_free_rate: float,
    total_turnover: float = 0.0,
    total_transaction_costs: float = 0.0,
) -> PerformanceMetrics:
    """Compute all performance metrics from a portfolio value time series."""
    daily_returns = portfolio_values.pct_change().dropna()
    n_days = len(daily_returns)

    if n_days == 0:
        return PerformanceMetrics(
            total_return=0.0, cagr=0.0, annualized_volatility=0.0,
            sharpe_ratio=0.0, sortino_ratio=0.0, max_drawdown=0.0,
            max_drawdown_start=None, max_drawdown_end=None, calmar_ratio=0.0,
            win_rate=0.0, total_turnover=total_turnover,
            total_transaction_costs=total_transaction_costs,
            best_day=0.0, worst_day=0.0, avg_daily_return=0.0,
        )

    v_initial = portfolio_values.iloc[0]
    v_final = portfolio_values.iloc[-1]
    total_return = (v_final / v_initial) - 1.0

    years = n_days / TRADING_DAYS_PER_YEAR
    if years > 0 and v_final > 0 and v_initial > 0:
        cagr = (v_final / v_initial) ** (1.0 / years) - 1.0
    else:
        cagr = 0.0

    ann_vol = float(daily_returns.std() * np.sqrt(TRADING_DAYS_PER_YEAR))
    sharpe = (cagr - risk_free_rate) / ann_vol if ann_vol > 0 else 0.0

    # Sortino: only downside deviation
    daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR
    downside = daily_returns[daily_returns < daily_rf] - daily_rf
    downside_dev = float(np.sqrt((downside ** 2).mean()) * np.sqrt(TRADING_DAYS_PER_YEAR)) if len(downside) > 0 else 0.0
    sortino = (cagr - risk_free_rate) / downside_dev if downside_dev > 0 else 0.0

    # Max drawdown
    dd = compute_drawdown_series(portfolio_values)
    max_dd = float(dd.max())
    if max_dd > 0:
        dd_end_idx = dd.idxmax()
        dd_start_idx = portfolio_values.loc[:dd_end_idx].idxmax()
        dd_start = dd_start_idx if hasattr(dd_start_idx, "to_pydatetime") else None
        dd_end = dd_end_idx if hasattr(dd_end_idx, "to_pydatetime") else None
    else:
        dd_start = None
        dd_end = None

    calmar = cagr / abs(max_dd) if max_dd > 0 else 0.0
    win_rate = float((daily_returns > 0).sum() / n_days)

    return PerformanceMetrics(
        total_return=total_return,
        cagr=cagr,
        annualized_volatility=ann_vol,
        sharpe_ratio=sharpe,
        sortino_ratio=sortino,
        max_drawdown=max_dd,
        max_drawdown_start=dd_start,
        max_drawdown_end=dd_end,
        calmar_ratio=calmar,
        win_rate=win_rate,
        total_turnover=total_turnover,
        total_transaction_costs=total_transaction_costs,
        best_day=float(daily_returns.max()),
        worst_day=float(daily_returns.min()),
        avg_daily_return=float(daily_returns.mean()),
    )


def compute_rolling_sharpe(
    daily_returns: pd.Series,
    risk_free_rate: float,
    window: int = 63,
) -> pd.Series:
    """Rolling annualized Sharpe ratio."""
    daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR
    excess = daily_returns - daily_rf
    rolling_mean = excess.rolling(window).mean() * TRADING_DAYS_PER_YEAR
    rolling_std = daily_returns.rolling(window).std() * np.sqrt(TRADING_DAYS_PER_YEAR)
    rolling_std = rolling_std.replace(0, np.nan)
    return (rolling_mean / rolling_std).dropna()


def compute_rolling_returns(
    portfolio_values: pd.Series,
    window: int = 252,
) -> pd.Series:
    """Rolling annualized return over trailing window."""
    return (portfolio_values / portfolio_values.shift(window)) ** (TRADING_DAYS_PER_YEAR / window) - 1


def compare_metrics(
    metrics_dict: dict[str, PerformanceMetrics],
) -> pd.DataFrame:
    """Create a comparison table: strategies as rows, metrics as columns."""
    rows = {}
    for name, m in metrics_dict.items():
        rows[name] = {
            "Total Return": f"{m.total_return:.2%}",
            "CAGR": f"{m.cagr:.2%}",
            "Volatility": f"{m.annualized_volatility:.2%}",
            "Sharpe": f"{m.sharpe_ratio:.3f}",
            "Sortino": f"{m.sortino_ratio:.3f}",
            "Max Drawdown": f"{m.max_drawdown:.2%}",
            "Calmar": f"{m.calmar_ratio:.3f}",
            "Win Rate": f"{m.win_rate:.2%}",
            "Turnover": f"{m.total_turnover:.2f}",
            "Costs": f"${m.total_transaction_costs:.2f}",
        }
    return pd.DataFrame(rows).T


path: mpt_portfolio/config.py
content: from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

import yaml

from mpt_portfolio.utils import (
    DEFAULT_ASSETS,
    get_portfolios_dir,
    get_project_root,
    resolve_env_vars,
    validate_portfolio_name,
)


@dataclass
class PortfolioConfig:
    name: str = "default"
    initial_investment: float = 100_000
    assets: list[str] = field(default_factory=lambda: list(DEFAULT_ASSETS))
    benchmark: str = "SPY"


@dataclass
class DataConfig:
    lookback_years: int = 5
    frequency: str = "daily"
    price_type: str = "adjusted"


@dataclass
class ConstraintsConfig:
    long_only: bool = True
    max_weight: float = 0.40
    min_weight: float = 0.0


@dataclass
class OptimizationConfig:
    method: str = "max_sharpe"
    risk_free_rate: float | str = "auto"
    expected_returns: str = "mean_historical"
    covariance: str = "ledoit_wolf"
    constraints: ConstraintsConfig = field(default_factory=ConstraintsConfig)


@dataclass
class BacktestConfig:
    backtest_years: int = 5
    start_date: str | None = None
    end_date: str | None = None
    rebalancing_strategies: list[str] = field(
        default_factory=lambda: ["monthly", "quarterly", "yearly", "dynamic"]
    )
    dynamic_threshold: float = 0.05
    transaction_cost: float = 0.001
    include_buy_and_hold: bool = True


@dataclass
class EmailConfig:
    enabled: bool = False
    smtp_host: str | None = None
    smtp_port: int = 587
    smtp_use_tls: bool = True
    smtp_user: str | None = None
    smtp_password: str | None = None
    sender: str | None = None
    recipients: list[str] = field(default_factory=list)

    def resolve_env(self) -> EmailConfig:
        """Return a copy with ${VAR} patterns resolved from environment."""
        return EmailConfig(
            enabled=self.enabled,
            smtp_host=resolve_env_vars(self.smtp_host) if self.smtp_host else None,
            smtp_port=self.smtp_port,
            smtp_use_tls=self.smtp_use_tls,
            smtp_user=resolve_env_vars(self.smtp_user) if self.smtp_user else None,
            smtp_password=resolve_env_vars(self.smtp_password) if self.smtp_password else None,
            sender=resolve_env_vars(self.sender) if self.sender else None,
            recipients=[resolve_env_vars(r) for r in self.recipients] if self.recipients else [],
        )


@dataclass
class NotificationEventsConfig:
    method: str = "file"
    portfolio_created: bool = True
    backtest_complete: bool = True
    rebalance_reminder: bool = True
    performance_report: bool = True


@dataclass
class RebalancingConfig:
    strategy: str = "recommended"


@dataclass
class ReportsConfig:
    formats: list[str] = field(default_factory=lambda: ["html", "terminal"])
    charts: list[str] = field(
        default_factory=lambda: [
            "efficient_frontier",
            "correlation_heatmap",
            "weights",
            "equity_curves",
            "drawdown",
            "rolling_sharpe",
        ]
    )


@dataclass
class MonitoringConfig:
    frequency: str = "none"


@dataclass
class Config:
    portfolio: PortfolioConfig = field(default_factory=PortfolioConfig)
    data: DataConfig = field(default_factory=DataConfig)
    optimization: OptimizationConfig = field(default_factory=OptimizationConfig)
    backtest: BacktestConfig = field(default_factory=BacktestConfig)
    rebalancing: RebalancingConfig = field(default_factory=RebalancingConfig)
    reports: ReportsConfig = field(default_factory=ReportsConfig)
    monitoring: MonitoringConfig = field(default_factory=MonitoringConfig)
    email: EmailConfig = field(default_factory=EmailConfig)
    notifications: NotificationEventsConfig = field(default_factory=NotificationEventsConfig)


class ConfigValidationError(Exception):
    pass


def load_default_config() -> dict:
    path = get_project_root() / "config" / "default.yaml"
    with open(path) as f:
        return yaml.safe_load(f)


def _deep_merge(base: dict, override: dict) -> dict:
    """Recursively merge override into base. Override values win."""
    result = dict(base)
    for key, val in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(val, dict):
            result[key] = _deep_merge(result[key], val)
        else:
            result[key] = val
    return result


def _build_constraints(raw: dict) -> ConstraintsConfig:
    return ConstraintsConfig(
        long_only=raw.get("long_only", True),
        max_weight=raw.get("max_weight", 0.40),
        min_weight=raw.get("min_weight", 0.0),
    )


def _build_email(raw: dict | None) -> EmailConfig:
    if not raw:
        return EmailConfig()
    return EmailConfig(
        enabled=raw.get("enabled", False),
        smtp_host=raw.get("smtp_host"),
        smtp_port=raw.get("smtp_port", 587),
        smtp_use_tls=raw.get("smtp_use_tls", True),
        smtp_user=raw.get("smtp_user"),
        smtp_password=raw.get("smtp_password"),
        sender=raw.get("sender"),
        recipients=list(raw.get("recipients") or []),
    )


def _build_notification_events(raw: dict | None) -> NotificationEventsConfig:
    if not raw:
        return NotificationEventsConfig()
    return NotificationEventsConfig(
        method=raw.get("method", "file"),
        portfolio_created=raw.get("portfolio_created", True),
        backtest_complete=raw.get("backtest_complete", True),
        rebalance_reminder=raw.get("rebalance_reminder", True),
        performance_report=raw.get("performance_report", True),
    )


def validate_config(raw: dict) -> Config:
    """Validate raw dict and build Config. Raises ConfigValidationError on invalid input."""
    errors: list[str] = []

    p = raw.get("portfolio", {})
    if not p.get("assets"):
        errors.append("portfolio.assets must be a non-empty list of tickers")
    if p.get("initial_investment") is not None and p["initial_investment"] <= 0:
        errors.append("portfolio.initial_investment must be positive")

    d = raw.get("data", {})
    if d.get("lookback_years") is not None and d["lookback_years"] < 2:
        errors.append("data.lookback_years must be >= 2 (need sufficient history for stable covariance estimation)")
    if d.get("price_type") and d["price_type"] not in ("adjusted", "unadjusted"):
        errors.append("data.price_type must be 'adjusted' or 'unadjusted'")

    o = raw.get("optimization", {})
    valid_methods = {"max_sharpe", "min_variance", "efficient_frontier", "risk_parity"}
    if o.get("method") and o["method"] not in valid_methods:
        errors.append(f"optimization.method must be one of {valid_methods}")
    valid_returns = {"mean_historical", "exp_weighted", "black_litterman"}
    if o.get("expected_returns") and o["expected_returns"] not in valid_returns:
        errors.append(f"optimization.expected_returns must be one of {valid_returns}")
    valid_cov = {"sample", "ledoit_wolf"}
    if o.get("covariance") and o["covariance"] not in valid_cov:
        errors.append(f"optimization.covariance must be one of {valid_cov}")

    c = o.get("constraints", {})
    if c.get("max_weight") is not None and not (0 < c["max_weight"] <= 1):
        errors.append("optimization.constraints.max_weight must be in (0, 1]")
    if c.get("min_weight") is not None and c["min_weight"] < 0:
        errors.append("optimization.constraints.min_weight must be >= 0")

    b = raw.get("backtest", {})
    valid_strategies = {"monthly", "quarterly", "yearly", "dynamic"}
    for s in b.get("rebalancing_strategies", []):
        if s not in valid_strategies:
            errors.append(f"backtest.rebalancing_strategies contains invalid '{s}'")
    if b.get("dynamic_threshold") is not None and not (0 < b["dynamic_threshold"] < 1):
        errors.append("backtest.dynamic_threshold must be in (0, 1)")
    if b.get("transaction_cost") is not None and b["transaction_cost"] < 0:
        errors.append("backtest.transaction_cost must be >= 0")

    m = raw.get("monitoring", {})
    valid_frequencies = {"weekly", "monthly", "none"}
    if m.get("frequency") and m["frequency"] not in valid_frequencies:
        errors.append(f"monitoring.frequency must be one of {valid_frequencies}")

    n = raw.get("notifications", {})
    valid_notif_methods = {"file", "email", "both"}
    if n.get("method") and n["method"] not in valid_notif_methods:
        errors.append(f"notifications.method must be one of {valid_notif_methods}")

    # Backward compat: migrate old rebalancing.notification.email → top-level email
    rb = raw.get("rebalancing", {})
    old_notif = rb.get("notification", {})
    if old_notif:
        old_email = old_notif.get("email", {})
        existing_email = raw.get("email", {})
        email_unconfigured = not existing_email or (
            not existing_email.get("enabled") and not existing_email.get("smtp_host")
        )
        if old_email and email_unconfigured:
            migrated = {}
            field_map = {
                "smtp_server": "smtp_host", "smtp_host": "smtp_host",
                "smtp_port": "smtp_port", "smtp_use_tls": "smtp_use_tls",
                "username": "smtp_user", "smtp_user": "smtp_user",
                "password": "smtp_password", "smtp_password": "smtp_password",
                "from_addr": "sender", "sender": "sender",
                "to_addr": "recipients", "recipients": "recipients",
            }
            for old_key, new_key in field_map.items():
                if old_key in old_email and old_email[old_key] is not None:
                    val = old_email[old_key]
                    if new_key == "recipients" and isinstance(val, str):
                        val = [val]
                    migrated[new_key] = val
            if any(migrated.get(k) for k in ("smtp_host", "smtp_user", "sender")):
                migrated["enabled"] = True
            raw["email"] = migrated
        if "notifications" not in raw and old_notif.get("method"):
            raw["notifications"] = {"method": old_notif["method"]}

    if errors:
        raise ConfigValidationError("Configuration errors:\n  - " + "\n  - ".join(errors))

    return Config(
        portfolio=PortfolioConfig(
            name=p.get("name", "default"),
            initial_investment=float(p.get("initial_investment", 100_000)),
            assets=list(p.get("assets", DEFAULT_ASSETS)),
            benchmark=p.get("benchmark", "SPY"),
        ),
        data=DataConfig(
            lookback_years=int(d.get("lookback_years", 5)),
            frequency=d.get("frequency", "daily"),
            price_type=d.get("price_type", "adjusted"),
        ),
        optimization=OptimizationConfig(
            method=o.get("method", "max_sharpe"),
            risk_free_rate=o.get("risk_free_rate", "auto"),
            expected_returns=o.get("expected_returns", "mean_historical"),
            covariance=o.get("covariance", "ledoit_wolf"),
            constraints=_build_constraints(c),
        ),
        backtest=BacktestConfig(
            backtest_years=int(b.get("backtest_years", 5)),
            start_date=b.get("start_date"),
            end_date=b.get("end_date"),
            rebalancing_strategies=list(
                b.get("rebalancing_strategies", ["monthly", "quarterly", "yearly", "dynamic"])
            ),
            dynamic_threshold=float(b.get("dynamic_threshold", 0.05)),
            transaction_cost=float(b.get("transaction_cost", 0.001)),
            include_buy_and_hold=b.get("include_buy_and_hold", True),
        ),
        rebalancing=RebalancingConfig(
            strategy=raw.get("rebalancing", {}).get("strategy", "recommended"),
        ),
        reports=ReportsConfig(
            formats=list(raw.get("reports", {}).get("formats", ["html", "terminal"])),
            charts=list(raw.get("reports", {}).get("charts", [
                "efficient_frontier", "correlation_heatmap", "weights",
                "equity_curves", "drawdown", "rolling_sharpe",
            ])),
        ),
        monitoring=MonitoringConfig(
            frequency=m.get("frequency", "none"),
        ),
        email=_build_email(raw.get("email")),
        notifications=_build_notification_events(raw.get("notifications")),
    )


def load_portfolio_config(portfolio_name: str) -> Config:
    """Load portfolio config, merge with defaults, validate, return Config."""
    defaults = load_default_config()
    portfolio_dir = get_portfolios_dir() / portfolio_name
    config_path = portfolio_dir / "config.yaml"
    if config_path.exists():
        with open(config_path) as f:
            overrides = yaml.safe_load(f) or {}
        merged = _deep_merge(defaults, overrides)
    else:
        merged = defaults
    merged.setdefault("portfolio", {})["name"] = portfolio_name
    return validate_config(merged)


def save_portfolio_config(portfolio_name: str, config: Config) -> Path:
    """Serialize Config to YAML at portfolios/<name>/config.yaml."""
    portfolio_dir = get_portfolios_dir() / portfolio_name
    portfolio_dir.mkdir(parents=True, exist_ok=True)

    raw = {
        "portfolio": {
            "name": config.portfolio.name,
            "initial_investment": config.portfolio.initial_investment,
            "assets": config.portfolio.assets,
            "benchmark": config.portfolio.benchmark,
        },
        "data": {
            "lookback_years": config.data.lookback_years,
            "frequency": config.data.frequency,
            "price_type": config.data.price_type,
        },
        "optimization": {
            "method": config.optimization.method,
            "risk_free_rate": config.optimization.risk_free_rate,
            "expected_returns": config.optimization.expected_returns,
            "covariance": config.optimization.covariance,
            "constraints": {
                "long_only": config.optimization.constraints.long_only,
                "max_weight": config.optimization.constraints.max_weight,
                "min_weight": config.optimization.constraints.min_weight,
            },
        },
        "backtest": {
            "backtest_years": config.backtest.backtest_years,
            "start_date": config.backtest.start_date,
            "end_date": config.backtest.end_date,
            "rebalancing_strategies": config.backtest.rebalancing_strategies,
            "dynamic_threshold": config.backtest.dynamic_threshold,
            "transaction_cost": config.backtest.transaction_cost,
            "include_buy_and_hold": config.backtest.include_buy_and_hold,
        },
        "rebalancing": {
            "strategy": config.rebalancing.strategy,
        },
        "notifications": {
            "method": config.notifications.method,
            "portfolio_created": config.notifications.portfolio_created,
            "backtest_complete": config.notifications.backtest_complete,
            "rebalance_reminder": config.notifications.rebalance_reminder,
            "performance_report": config.notifications.performance_report,
        },
        "reports": {
            "formats": config.reports.formats,
            "charts": config.reports.charts,
        },
        "monitoring": {
            "frequency": config.monitoring.frequency,
        },
    }

    raw["email"] = {
        "enabled": config.email.enabled,
        "smtp_host": config.email.smtp_host,
        "smtp_port": config.email.smtp_port,
        "smtp_use_tls": config.email.smtp_use_tls,
        "smtp_user": config.email.smtp_user,
        "smtp_password": config.email.smtp_password,
        "sender": config.email.sender,
        "recipients": config.email.recipients,
    }

    path = portfolio_dir / "config.yaml"
    with open(path, "w") as f:
        yaml.dump(raw, f, default_flow_style=False, sort_keys=False)
    return path


def create_portfolio_from_config(config: Config) -> Path:
    """Create portfolio directory structure and save config."""
    name = validate_portfolio_name(config.portfolio.name)
    portfolio_dir = get_portfolios_dir() / name
    if portfolio_dir.exists():
        raise FileExistsError(f"Portfolio '{name}' already exists at {portfolio_dir}")
    portfolio_dir.mkdir(parents=True)
    (portfolio_dir / "reports").mkdir()
    save_portfolio_config(name, config)
    return portfolio_dir


path: mpt_portfolio/charts.py
content: """Chart generation — matplotlib only."""

from __future__ import annotations

import base64
import io
from datetime import datetime
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from matplotlib.ticker import PercentFormatter


def chart_efficient_frontier(
    frontier_returns: np.ndarray,
    frontier_volatilities: np.ndarray,
    min_var: dict | None = None,
    max_sharpe: dict | None = None,
    individual_assets: dict[str, tuple[float, float]] | None = None,
    risk_free_rate: float = 0.0,
) -> Figure:
    fig, ax = plt.subplots(figsize=(12, 7))

    ax.plot(frontier_volatilities, frontier_returns, color="royalblue", linewidth=2,
            label="Efficient Frontier")

    if min_var:
        ax.scatter(min_var["volatility"], min_var["return"], marker="*", s=300,
                   color="green", zorder=5, label="Min Variance")
        ax.annotate("Min Var", (min_var["volatility"], min_var["return"]),
                    textcoords="offset points", xytext=(8, 8), fontsize=9)

    if max_sharpe:
        ax.scatter(max_sharpe["volatility"], max_sharpe["return"], marker="*", s=300,
                   color="red", zorder=5, label="Max Sharpe")
        ax.annotate("Max Sharpe", (max_sharpe["volatility"], max_sharpe["return"]),
                    textcoords="offset points", xytext=(8, 8), fontsize=9)

    if min_var and max_sharpe and risk_free_rate > 0:
        cal_x = np.linspace(0, max(frontier_volatilities) * 1.2, 50)
        slope = (max_sharpe["return"] - risk_free_rate) / max_sharpe["volatility"]
        cal_y = risk_free_rate + slope * cal_x
        ax.plot(cal_x, cal_y, color="gray", linestyle="--", label="Capital Allocation Line")

    if individual_assets:
        vols = [v[0] for v in individual_assets.values()]
        rets = [v[1] for v in individual_assets.values()]
        ax.scatter(vols, rets, color="gray", s=40, zorder=4)
        for ticker, (v, r) in individual_assets.items():
            ax.annotate(ticker, (v, r), textcoords="offset points",
                        xytext=(0, 6), fontsize=8, ha="center")

    ax.set_title("Efficient Frontier")
    ax.set_xlabel("Annualized Volatility")
    ax.set_ylabel("Annualized Expected Return")
    ax.xaxis.set_major_formatter(PercentFormatter(1.0, decimals=1))
    ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=1))
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    return fig


def chart_correlation_heatmap(corr_matrix: pd.DataFrame) -> Figure:
    labels = corr_matrix.columns.tolist()
    z = corr_matrix.values
    n = len(labels)

    fig, ax = plt.subplots(figsize=(max(6, n * 0.8), max(5, n * 0.7)))
    im = ax.imshow(z, cmap="RdBu_r", vmin=-1, vmax=1, aspect="auto")

    ax.set_xticks(range(n))
    ax.set_yticks(range(n))
    ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=9)
    ax.set_yticklabels(labels, fontsize=9)

    for i in range(n):
        for j in range(n):
            color = "white" if abs(z[i][j]) > 0.6 else "black"
            ax.text(j, i, f"{z[i][j]:.2f}", ha="center", va="center",
                    fontsize=8, color=color)

    fig.colorbar(im, ax=ax, label="Correlation", shrink=0.8)
    ax.set_title("Asset Correlation Matrix")
    fig.tight_layout()
    return fig


def chart_weights_pie(weights: pd.Series, title: str = "Portfolio Weights") -> Figure:
    w = weights[weights.abs() > 0.005].sort_values(ascending=False)

    fig, ax = plt.subplots(figsize=(8, 8))
    wedges, texts, autotexts = ax.pie(
        w.values, labels=w.index, autopct="%1.1f%%",
        pctdistance=0.82, wedgeprops=dict(width=0.35, edgecolor="white"),
        textprops=dict(fontsize=10),
    )
    for t in autotexts:
        t.set_fontsize(8)
    ax.set_title(title, fontsize=14, pad=20)
    fig.tight_layout()
    return fig


def chart_equity_curves(
    curves: dict[str, pd.Series],
    benchmark: pd.Series | None = None,
    benchmark_ticker: str = "Benchmark",
    live_start_date: datetime | None = None,
) -> Figure:
    fig, ax = plt.subplots(figsize=(12, 7))

    for name, series in curves.items():
        normalized = series / series.iloc[0]
        ax.plot(normalized.index, normalized.values, linewidth=1.5, label=name)

    if benchmark is not None:
        normalized = benchmark / benchmark.iloc[0]
        ax.plot(normalized.index, normalized.values, color="black",
                linestyle="--", linewidth=1.5, label=f"Benchmark ({benchmark_ticker})")

    if live_start_date is not None:
        ax.axvline(x=live_start_date, color="gray", linestyle=":", linewidth=1.5, alpha=0.7)
        ymin, ymax = ax.get_ylim()
        ax.annotate("Live Start", xy=(live_start_date, ymax),
                    xytext=(10, -15), textcoords="offset points",
                    fontsize=9, color="gray", fontstyle="italic")

    title = "Portfolio Performance: Backtest vs Live" if live_start_date else "Portfolio Equity Curves"
    ax.set_title(title)
    ax.set_xlabel("Date")
    ax.set_ylabel("Growth of $1")
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.autofmt_xdate()
    fig.tight_layout()
    return fig


def chart_drawdown(drawdown_series: dict[str, pd.Series]) -> Figure:
    fig, ax = plt.subplots(figsize=(12, 5))

    for name, dd in drawdown_series.items():
        ax.fill_between(dd.index, 0, -dd.values, alpha=0.4, label=name)

    ax.set_title("Drawdown")
    ax.set_xlabel("Date")
    ax.set_ylabel("Drawdown")
    ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=1))
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.autofmt_xdate()
    fig.tight_layout()
    return fig


def chart_rolling_sharpe(
    rolling_sharpes: dict[str, pd.Series],
    window_label: str = "63-day",
) -> Figure:
    fig, ax = plt.subplots(figsize=(12, 5))

    for name, rs in rolling_sharpes.items():
        ax.plot(rs.index, rs.values, linewidth=1.2, label=name)

    ax.axhline(y=0, color="gray", linestyle="--", linewidth=0.8)
    ax.set_title(f"Rolling Sharpe Ratio ({window_label})")
    ax.set_xlabel("Date")
    ax.set_ylabel("Sharpe Ratio")
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.autofmt_xdate()
    fig.tight_layout()
    return fig


def chart_performance_dashboard(
    equity_curves: dict[str, pd.Series],
    drawdowns: dict[str, pd.Series],
    rolling_sharpes: dict[str, pd.Series],
    benchmark: pd.Series | None = None,
    benchmark_ticker: str = "Benchmark",
) -> Figure:
    fig, axes = plt.subplots(3, 1, figsize=(12, 14), sharex=True)

    for name, series in equity_curves.items():
        normalized = series / series.iloc[0]
        axes[0].plot(normalized.index, normalized.values, linewidth=1.2, label=name)
    if benchmark is not None:
        normalized = benchmark / benchmark.iloc[0]
        axes[0].plot(normalized.index, normalized.values, color="black",
                     linestyle="--", linewidth=1.2, label=f"Benchmark ({benchmark_ticker})")
    axes[0].set_title("Equity Curves")
    axes[0].set_ylabel("Growth of $1")
    axes[0].legend(fontsize=8)
    axes[0].grid(True, alpha=0.3)

    for name, dd in drawdowns.items():
        axes[1].fill_between(dd.index, 0, -dd.values, alpha=0.4, label=name)
    axes[1].set_title("Drawdown")
    axes[1].set_ylabel("Drawdown")
    axes[1].yaxis.set_major_formatter(PercentFormatter(1.0, decimals=1))
    axes[1].grid(True, alpha=0.3)

    for name, rs in rolling_sharpes.items():
        axes[2].plot(rs.index, rs.values, linewidth=1.0, label=name)
    axes[2].axhline(y=0, color="gray", linestyle="--", linewidth=0.8)
    axes[2].set_title("Rolling Sharpe Ratio")
    axes[2].set_ylabel("Sharpe Ratio")
    axes[2].set_xlabel("Date")
    axes[2].grid(True, alpha=0.3)

    fig.suptitle("Performance Dashboard", fontsize=16, y=0.98)
    fig.tight_layout(rect=[0, 0, 1, 0.96])
    return fig


def export_chart_png(fig: Figure, filepath: Path, width: int = 1200, height: int = 700) -> Path:
    """Export matplotlib figure to PNG."""
    dpi = 100
    fig.set_size_inches(width / dpi, height / dpi)
    fig.savefig(str(filepath), dpi=dpi, bbox_inches="tight")
    plt.close(fig)
    return filepath


def export_chart_base64(fig: Figure) -> str:
    """Export matplotlib figure as base64-encoded PNG for HTML embedding."""
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
    buf.seek(0)
    b64 = base64.b64encode(buf.read()).decode("utf-8")
    plt.close(fig)
    return f'<img src="data:image/png;base64,{b64}" style="max-width:100%;">'


path: mpt_portfolio/optimization.py
content: """Portfolio optimization — pure math, no I/O.

Optimization problems:
  1. Global Minimum Variance: min w^T Sigma w, s.t. sum(w)=1, bounds
  2. Max Sharpe Ratio:        max (w^T mu - Rf) / sqrt(w^T Sigma w)
  3. Efficient Frontier:      for range of target returns, solve min variance
  4. Risk Parity:             equalize RC_i = w_i * (Sigma w)_i / sigma_p
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd
from scipy.optimize import minimize

from mpt_portfolio.config import ConstraintsConfig


@dataclass
class OptimizationResult:
    weights: pd.Series
    expected_return: float
    volatility: float
    sharpe_ratio: float
    method: str


@dataclass
class EfficientFrontierResult:
    frontier_returns: np.ndarray
    frontier_volatilities: np.ndarray
    frontier_weights: list[pd.Series]
    min_variance_portfolio: OptimizationResult
    max_sharpe_portfolio: OptimizationResult


class OptimizationError(Exception):
    pass


def portfolio_return(weights: np.ndarray, expected_returns: np.ndarray) -> float:
    return float(weights @ expected_returns)


def portfolio_volatility(weights: np.ndarray, cov_matrix: np.ndarray) -> float:
    return float(np.sqrt(weights @ cov_matrix @ weights))


def portfolio_sharpe(
    weights: np.ndarray,
    expected_returns: np.ndarray,
    cov_matrix: np.ndarray,
    risk_free_rate: float,
) -> float:
    ret = portfolio_return(weights, expected_returns)
    vol = portfolio_volatility(weights, cov_matrix)
    if vol == 0:
        return 0.0
    return (ret - risk_free_rate) / vol


def _build_bounds(n: int, constraints: ConstraintsConfig) -> list[tuple[float, float]]:
    if constraints.long_only:
        lo = max(constraints.min_weight, 0.0)
    else:
        lo = constraints.min_weight
    return [(lo, constraints.max_weight) for _ in range(n)]


def _sum_to_one_constraint():
    return {"type": "eq", "fun": lambda w: np.sum(w) - 1.0}


def _clamp_weights(raw_weights: np.ndarray, constraints: ConstraintsConfig) -> np.ndarray:
    if constraints.long_only:
        lo = max(constraints.min_weight, 0.0)
    else:
        lo = constraints.min_weight
    hi = constraints.max_weight
    w = np.clip(raw_weights, lo, hi)
    total = w.sum()
    if total <= 0:
        return w
    w = w / total
    # Redistribute excess from capped assets to uncapped ones
    for _ in range(50):
        capped = w > hi
        if not capped.any():
            break
        excess = (w[capped] - hi).sum()
        w[capped] = hi
        free = ~capped
        if not free.any() or w[free].sum() == 0:
            break
        w[free] += excess * (w[free] / w[free].sum())
    return w


def minimize_variance(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    risk_free_rate: float,
    constraints: ConstraintsConfig,
) -> OptimizationResult:
    """Find the Global Minimum Variance Portfolio."""
    n = len(expected_returns)
    mu = expected_returns.values
    sigma = cov_matrix.values
    x0 = np.ones(n) / n
    bounds = _build_bounds(n, constraints)

    result = minimize(
        fun=lambda w: w @ sigma @ w,
        x0=x0,
        method="SLSQP",
        bounds=bounds,
        constraints=[_sum_to_one_constraint()],
        options={"ftol": 1e-12, "maxiter": 1000},
    )

    if not result.success:
        result = minimize(
            fun=lambda w: w @ sigma @ w,
            x0=x0,
            method="trust-constr",
            bounds=bounds,
            constraints=[_sum_to_one_constraint()],
        )
        if not result.success:
            raise OptimizationError(f"Minimum variance optimization failed: {result.message}")

    w = _clamp_weights(result.x, constraints)
    ret = portfolio_return(w, mu)
    vol = portfolio_volatility(w, sigma)
    sharpe = (ret - risk_free_rate) / vol if vol > 0 else 0.0
    weights = pd.Series(w, index=expected_returns.index)

    return OptimizationResult(
        weights=weights,
        expected_return=ret,
        volatility=vol,
        sharpe_ratio=sharpe,
        method="min_variance",
    )


def maximize_sharpe(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    risk_free_rate: float,
    constraints: ConstraintsConfig,
) -> OptimizationResult:
    """Find the Maximum Sharpe Ratio (Tangency) Portfolio."""
    n = len(expected_returns)
    mu = expected_returns.values
    sigma = cov_matrix.values
    x0 = np.ones(n) / n
    bounds = _build_bounds(n, constraints)

    def neg_sharpe(w):
        ret = w @ mu
        vol = np.sqrt(w @ sigma @ w)
        if vol < 1e-12:
            return 1e10
        return -(ret - risk_free_rate) / vol

    result = minimize(
        fun=neg_sharpe,
        x0=x0,
        method="SLSQP",
        bounds=bounds,
        constraints=[_sum_to_one_constraint()],
        options={"ftol": 1e-12, "maxiter": 1000},
    )

    if not result.success:
        result = minimize(
            fun=neg_sharpe,
            x0=x0,
            method="trust-constr",
            bounds=bounds,
            constraints=[_sum_to_one_constraint()],
        )
        if not result.success:
            raise OptimizationError(f"Max Sharpe optimization failed: {result.message}")

    w = _clamp_weights(result.x, constraints)
    ret = portfolio_return(w, mu)
    vol = portfolio_volatility(w, sigma)
    sharpe = (ret - risk_free_rate) / vol if vol > 0 else 0.0
    weights = pd.Series(w, index=expected_returns.index)

    return OptimizationResult(
        weights=weights,
        expected_return=ret,
        volatility=vol,
        sharpe_ratio=sharpe,
        method="max_sharpe",
    )


def _minimize_for_target_return(
    target_return: float,
    mu: np.ndarray,
    sigma: np.ndarray,
    bounds: list[tuple[float, float]],
    n: int,
    constraints: ConstraintsConfig | None = None,
) -> np.ndarray | None:
    """Solve: min w^T Sigma w, s.t. w^T mu = target, sum(w) = 1, bounds."""
    x0 = np.ones(n) / n
    cons = [
        _sum_to_one_constraint(),
        {"type": "eq", "fun": lambda w: w @ mu - target_return},
    ]
    result = minimize(
        fun=lambda w: w @ sigma @ w,
        x0=x0,
        method="SLSQP",
        bounds=bounds,
        constraints=cons,
        options={"ftol": 1e-12, "maxiter": 1000},
    )
    if result.success:
        if constraints is not None:
            return _clamp_weights(result.x, constraints)
        return result.x
    return None


def efficient_frontier(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    risk_free_rate: float,
    constraints: ConstraintsConfig,
    n_points: int = 100,
) -> EfficientFrontierResult:
    """Compute the efficient frontier with min-variance and max-Sharpe portfolios."""
    min_var = minimize_variance(expected_returns, cov_matrix, risk_free_rate, constraints)
    max_sharpe = maximize_sharpe(expected_returns, cov_matrix, risk_free_rate, constraints)

    n = len(expected_returns)
    mu = expected_returns.values
    sigma = cov_matrix.values
    bounds = _build_bounds(n, constraints)

    # Max feasible return: put max_weight in highest-return assets
    sorted_idx = np.argsort(mu)[::-1]
    max_weights = np.zeros(n)
    remaining = 1.0
    for i in sorted_idx:
        alloc = min(constraints.max_weight, remaining)
        max_weights[i] = alloc
        remaining -= alloc
        if remaining <= 1e-10:
            break
    max_return = portfolio_return(max_weights, mu)
    min_return = min_var.expected_return

    target_returns = np.linspace(min_return, max_return, n_points)
    frontier_ret = []
    frontier_vol = []
    frontier_w = []

    for target in target_returns:
        w = _minimize_for_target_return(target, mu, sigma, bounds, n, constraints)
        if w is not None:
            frontier_ret.append(portfolio_return(w, mu))
            frontier_vol.append(portfolio_volatility(w, sigma))
            frontier_w.append(pd.Series(w, index=expected_returns.index))

    return EfficientFrontierResult(
        frontier_returns=np.array(frontier_ret),
        frontier_volatilities=np.array(frontier_vol),
        frontier_weights=frontier_w,
        min_variance_portfolio=min_var,
        max_sharpe_portfolio=max_sharpe,
    )


def risk_parity(
    cov_matrix: pd.DataFrame,
    risk_free_rate: float,
    expected_returns: pd.Series | None = None,
    constraints: ConstraintsConfig | None = None,
) -> OptimizationResult:
    """Equal Risk Contribution portfolio.

    Each asset contributes equally to total portfolio risk:
    RC_i = w_i * (Sigma w)_i / sigma_p = sigma_p / n
    """
    sigma = cov_matrix.values
    n = sigma.shape[0]
    x0 = np.ones(n) / n

    def risk_contribution_objective(w):
        port_vol = np.sqrt(w @ sigma @ w)
        if port_vol < 1e-12:
            return 1e10
        marginal = sigma @ w
        rc = w * marginal / port_vol
        target_rc = port_vol / n
        return float(np.sum((rc - target_rc) ** 2))

    if constraints is not None:
        bounds = _build_bounds(n, constraints)
    else:
        bounds = [(0.0, 1.0) for _ in range(n)]
    result = minimize(
        fun=risk_contribution_objective,
        x0=x0,
        method="SLSQP",
        bounds=bounds,
        constraints=[_sum_to_one_constraint()],
        options={"ftol": 1e-15, "maxiter": 2000},
    )

    if not result.success:
        raise OptimizationError(f"Risk parity optimization failed: {result.message}")

    if constraints is not None:
        w = _clamp_weights(result.x, constraints)
    else:
        w = result.x
    weights = pd.Series(w, index=cov_matrix.index)

    if expected_returns is not None:
        mu = expected_returns.values
        ret = portfolio_return(w, mu)
    else:
        ret = 0.0

    vol = portfolio_volatility(w, sigma)
    sharpe = (ret - risk_free_rate) / vol if vol > 0 and expected_returns is not None else 0.0

    return OptimizationResult(
        weights=weights,
        expected_return=ret,
        volatility=vol,
        sharpe_ratio=sharpe,
        method="risk_parity",
    )


def optimize(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    risk_free_rate: float,
    method: str,
    constraints: ConstraintsConfig | None = None,
    n_points: int = 100,
) -> OptimizationResult | EfficientFrontierResult:
    """Top-level dispatcher."""
    if constraints is None:
        constraints = ConstraintsConfig()

    if method == "min_variance":
        return minimize_variance(expected_returns, cov_matrix, risk_free_rate, constraints)
    elif method == "max_sharpe":
        return maximize_sharpe(expected_returns, cov_matrix, risk_free_rate, constraints)
    elif method == "efficient_frontier":
        return efficient_frontier(
            expected_returns, cov_matrix, risk_free_rate, constraints, n_points
        )
    elif method == "risk_parity":
        return risk_parity(cov_matrix, risk_free_rate, expected_returns, constraints)
    else:
        raise ValueError(f"Unknown optimization method: {method}")


path: mpt_portfolio/__init__.py
content: __version__ = "0.1.0"


path: mpt_portfolio/returns.py
content: """Expected return estimation — pure math, no I/O.

Formulas:
  - Log returns:          r_t = ln(P_t / P_{t-1})
  - Annualized mean:      mu = mean(r_daily) * 252
  - Exp-weighted mean:    mu = ewm(r_daily, span).mean()[-1] * 252
  - Black-Litterman:      mu_BL = [(tau*Sigma)^-1 + P^T Omega^-1 P]^-1
                                  * [(tau*Sigma)^-1 pi + P^T Omega^-1 Q]
      where pi = delta * Sigma * w_mkt  (equilibrium excess returns)
"""

from __future__ import annotations

import numpy as np
import pandas as pd

from mpt_portfolio.utils import TRADING_DAYS_PER_YEAR


def compute_log_returns(prices: pd.DataFrame) -> pd.DataFrame:
    """Daily log returns: ln(P_t / P_{t-1}). First row is dropped."""
    return np.log(prices / prices.shift(1)).dropna()


def annualize_returns(
    daily_returns: pd.DataFrame,
    trading_days: int = TRADING_DAYS_PER_YEAR,
) -> pd.Series:
    """Annualized mean return per asset: mu_i = mean(r_i) * trading_days."""
    return daily_returns.mean() * trading_days


def exp_weighted_returns(
    daily_returns: pd.DataFrame,
    span: int = 60,
    trading_days: int = TRADING_DAYS_PER_YEAR,
) -> pd.Series:
    """Exponentially-weighted annualized mean return.

    Recent data gets higher weight, addressing the criticism that
    historical means are stale estimates of future returns.
    """
    ew = daily_returns.ewm(span=span).mean()
    return ew.iloc[-1] * trading_days


def equilibrium_returns(
    cov_matrix: pd.DataFrame,
    market_weights: pd.Series,
    risk_aversion: float = 2.5,
    risk_free_rate: float = 0.05,
) -> pd.Series:
    """Implied equilibrium excess returns (Black-Litterman prior).

    pi = delta * Sigma * w_mkt
    """
    pi = risk_aversion * cov_matrix.values @ market_weights.values
    return pd.Series(pi, index=cov_matrix.index) + risk_free_rate


def black_litterman_returns(
    cov_matrix: pd.DataFrame,
    market_weights: pd.Series,
    P: np.ndarray,
    Q: np.ndarray,
    omega: np.ndarray | None = None,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    risk_free_rate: float = 0.05,
) -> pd.Series:
    """Black-Litterman posterior expected returns.

    Args:
        cov_matrix: Annualized covariance matrix (n x n).
        market_weights: Market-cap weights for each asset.
        P: Pick matrix (k x n) where k = number of views.
        Q: View vector (k,) of expected excess returns for each view.
        omega: Uncertainty matrix (k x k). If None, uses tau * P @ Sigma @ P^T.
        tau: Scaling factor for covariance uncertainty (typically 0.025-0.05).
        risk_aversion: Market risk aversion coefficient.
        risk_free_rate: Annual risk-free rate.

    Returns:
        Posterior expected returns (annualized, n-vector).
    """
    sigma = cov_matrix.values
    n = sigma.shape[0]
    k = P.shape[0]
    Q = np.asarray(Q).flatten()

    pi = risk_aversion * sigma @ market_weights.values

    tau_sigma = tau * sigma
    tau_sigma_inv = np.linalg.inv(tau_sigma)

    if omega is None:
        omega = tau * P @ sigma @ P.T
    omega_inv = np.linalg.inv(omega)

    # Posterior precision and mean
    precision = tau_sigma_inv + P.T @ omega_inv @ P
    mean_part = tau_sigma_inv @ pi + P.T @ omega_inv @ Q
    mu_bl = np.linalg.solve(precision, mean_part)

    return pd.Series(mu_bl + risk_free_rate, index=cov_matrix.index)


def get_expected_returns(
    daily_returns: pd.DataFrame,
    method: str,
    cov_matrix: pd.DataFrame | None = None,
    **kwargs,
) -> pd.Series:
    """Dispatch to the configured expected returns method."""
    if method == "mean_historical":
        return annualize_returns(daily_returns, **{
            k: v for k, v in kwargs.items() if k == "trading_days"
        })
    elif method == "exp_weighted":
        return exp_weighted_returns(daily_returns, **{
            k: v for k, v in kwargs.items() if k in ("span", "trading_days")
        })
    elif method == "black_litterman":
        if cov_matrix is None:
            raise ValueError("black_litterman requires cov_matrix")
        required = {"market_weights", "P", "Q"}
        missing = required - set(kwargs.keys())
        if missing:
            raise ValueError(f"black_litterman requires kwargs: {missing}")
        return black_litterman_returns(
            cov_matrix=cov_matrix,
            market_weights=kwargs["market_weights"],
            P=kwargs["P"],
            Q=kwargs["Q"],
            omega=kwargs.get("omega"),
            tau=kwargs.get("tau", 0.05),
            risk_aversion=kwargs.get("risk_aversion", 2.5),
            risk_free_rate=kwargs.get("risk_free_rate", 0.05),
        )
    else:
        raise ValueError(f"Unknown expected returns method: {method}")


path: mpt_portfolio/utils.py
content: import os
import re
from datetime import datetime, timezone
from pathlib import Path

TRADING_DAYS_PER_YEAR: int = 252

DEFAULT_ASSETS: list[str] = [
    "XLC", "XLY", "XLP", "XLE", "XLF", "XLV", "XLI", "XLB",
    "XLRE", "XLK", "XLU", "GLD", "DBC", "TLT",
]


def get_project_root() -> Path:
    override = os.environ.get("MPT_PROJECT_ROOT")
    if override:
        return Path(override).resolve()
    # Walk up from this file looking for a known project marker rather than
    # assuming a fixed directory depth — the package may be installed or
    # mounted in varying structures (e.g. OpenClaw workspaces).
    _markers = ("config/default.yaml", "pyproject.toml")
    candidate = Path(__file__).resolve().parent.parent
    for _ in range(5):
        if any((candidate / m).exists() for m in _markers):
            return candidate
        candidate = candidate.parent
    return Path(__file__).resolve().parent.parent


def get_portfolios_dir() -> Path:
    d = get_project_root() / "portfolios"
    d.mkdir(exist_ok=True)
    return d


def get_portfolio_dir(name: str) -> Path:
    d = get_portfolios_dir() / name
    if not d.exists():
        raise FileNotFoundError(f"Portfolio '{name}' not found at {d}")
    return d


def get_portfolio_reports_dir(name: str) -> Path:
    d = get_portfolio_dir(name) / "reports"
    d.mkdir(exist_ok=True)
    return d


def validate_portfolio_name(name: str) -> str:
    if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", name):
        raise ValueError(
            f"Invalid portfolio name '{name}'. "
            "Use alphanumeric characters, hyphens, and underscores only. "
            "Must start with a letter or digit."
        )
    return name


def timestamp_str() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def resolve_env_vars(value: str) -> str:
    """Resolve ${VAR_NAME} patterns from environment variables."""
    if not isinstance(value, str):
        return value
    pattern = re.compile(r"\$\{([^}]+)\}")
    def replacer(match: re.Match) -> str:
        var_name = match.group(1)
        env_val = os.environ.get(var_name)
        if env_val is None:
            raise ValueError(f"Environment variable '{var_name}' not set")
        return env_val
    return pattern.sub(replacer, value)


path: mpt_portfolio/risk.py
content: """Covariance estimation — pure math, no I/O.

Formulas:
  - Sample covariance (annualized): Sigma = cov(r_daily) * 252
  - Ledoit-Wolf shrinkage:          Sigma_LW = alpha*F + (1-alpha)*S
      where F = shrinkage target, S = sample cov, alpha = optimal intensity
  - Correlation matrix:             Corr_ij = Sigma_ij / sqrt(Sigma_ii * Sigma_jj)
"""

from __future__ import annotations

import numpy as np
import pandas as pd

from mpt_portfolio.utils import TRADING_DAYS_PER_YEAR


def sample_covariance(
    daily_returns: pd.DataFrame,
    trading_days: int = TRADING_DAYS_PER_YEAR,
) -> pd.DataFrame:
    """Annualized sample covariance matrix."""
    return daily_returns.cov() * trading_days


def _ledoit_wolf_shrinkage(X: np.ndarray) -> tuple[np.ndarray, float]:
    """Ledoit-Wolf (2004) shrinkage toward scaled identity.

    Parameters: X — (n_samples, n_features) centered data matrix.
    Returns: (shrunk_covariance, shrinkage_intensity).
    """
    n, p = X.shape
    S = X.T @ X / n
    mu = np.trace(S) / p

    delta = np.sum((S - mu * np.eye(p)) ** 2) / p
    if delta == 0:
        return S, 0.0

    X2 = X ** 2
    phi = np.sum(X2.T @ X2 / n - S ** 2) / (n * p)

    shrinkage = max(0.0, min(1.0, phi / delta))
    shrunk = (1.0 - shrinkage) * S + shrinkage * mu * np.eye(p)
    return shrunk, shrinkage


def ledoit_wolf_covariance(
    daily_returns: pd.DataFrame,
    trading_days: int = TRADING_DAYS_PER_YEAR,
) -> pd.DataFrame:
    """Ledoit-Wolf shrinkage covariance estimator (annualized)."""
    X = daily_returns.values
    X_centered = X - X.mean(axis=0)
    shrunk_cov, _ = _ledoit_wolf_shrinkage(X_centered)
    cov = shrunk_cov * trading_days
    return pd.DataFrame(cov, index=daily_returns.columns, columns=daily_returns.columns)


def correlation_matrix(cov_matrix: pd.DataFrame) -> pd.DataFrame:
    """Convert covariance matrix to correlation matrix."""
    std = np.sqrt(np.diag(cov_matrix.values))
    outer = np.outer(std, std)
    outer[outer == 0] = 1.0
    corr = cov_matrix.values / outer
    np.fill_diagonal(corr, 1.0)
    return pd.DataFrame(corr, index=cov_matrix.index, columns=cov_matrix.columns)


def get_covariance(
    daily_returns: pd.DataFrame,
    method: str,
    trading_days: int = TRADING_DAYS_PER_YEAR,
) -> pd.DataFrame:
    """Dispatch to the configured covariance method."""
    if method == "sample":
        return sample_covariance(daily_returns, trading_days)
    elif method == "ledoit_wolf":
        return ledoit_wolf_covariance(daily_returns, trading_days)
    else:
        raise ValueError(f"Unknown covariance method: {method}")


path: mpt_portfolio/__main__.py
content: from mpt_portfolio.cli import main

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)