Skill: ndtchan/portfolio-analytics (version 1.0.0)

--- name: portfolio-analytics description: Computes portfolio risk and exposure metrics before sizing and rebalance decisions. compatibility: Requires local holdings and price-history CSVs and optional benchmark/sector CSVs. --- # Portfolio Analytics ## Purpose Compute quantitative portfolio risk analytics before sizing and rebalance decisions. ## Scope - Holdings and weights. - Historical price/return series. - Optional benchmark. - Optional sector map. - Optional liquidity fields. ## Non-goals - No automatic rebalance. - No absolute trade instructions. - No live data fetching. ## Input contract Required inputs: - `HOLDINGS_CSV`: rows with portfolio holdings and position weights or fields sufficient to compute weights. - `PRICE_CSV`: historical prices or returns for held symbols. Optional inputs: - `BENCHMARK_CSV`: benchmark prices or returns for beta and relative-risk context. - `SECTOR_MAP_CSV`: symbol-to-sector mapping for sector exposure. - Liquidity fields: average daily value, average volume, free float, or similar liquidity context when available. ## Execution workflow 1. Validate input files, required columns, date coverage, and symbol coverage. 2. Run `scripts/analyze_portfolio.py` with explicit holdings, price history, and optional benchmark or sector inputs. 3. Inspect metrics for risk level, concentration, correlation, benchmark sensitivity, and data limitations. 4. Prepare a handoff bundle for downstream sizing, rebalance, or risk-management review. ## Required output format 1. `Portfolio Metrics` - Total portfolio value or normalized weight base, number of holdings, volatility, annualized return when supported, max drawdown, and risk-adjusted metrics when supported. 2. `Benchmark Metrics` - Benchmark volatility, benchmark drawdown, portfolio beta, tracking error, and relative return when benchmark data exists. 3. `Correlation Summary` - Average pairwise correlation, highest correlated pairs, and diversification observations. 4. `Concentration Risk` - Top positions, top-position weight, top-five weight, Herfindahl-Hirschman Index, and concentration warnings. 5. `Sector Exposure` - Sector weights and sector concentration when sector mapping exists. 6. `Top Risk Contributors` - Holdings with the largest estimated contribution to portfolio volatility or drawdown risk. 7. `Confidence and Data Gaps` - Confidence level, missing inputs, stale data, short history, incomplete holdings, missing sector data, missing liquidity data, or missing benchmark data. 8. `Handoff Bundle` - Include the exact marker fields listed in `Handoff bundle`. ## Shared confidence rubric - `High`: holdings are complete, weights reconcile, price history is long enough for the stated horizon, benchmark is available when beta or relative risk is discussed, and sector/liquidity coverage is broad. - `Medium`: holdings and prices are usable, but one major input is partial, such as benchmark availability, sector mapping, liquidity coverage, or price-history length. - `Low`: holdings are incomplete, weights do not reconcile, price history is short, benchmark is missing for beta-sensitive claims, or sector/liquidity coverage is sparse. ## Guardrails - Do not infer precise beta without benchmark data. - Downgrade confidence for short price history or thin symbol coverage. - Treat analytics as context for sizing, rebalance, and risk-management decisions, not as commands. ## Handoff bundle Include these exact marker fields: - `as_of_date` - `holdings` - `weights` - `portfolio_metrics` - `benchmark_metrics` - `correlation_summary` - `concentration_risk` - `sector_exposure` - `top_risk_contributors` - `confidence` - `data_gaps` ## Trigger examples - "Analyze this portfolio's volatility, drawdown, and concentration before I resize positions." - "Compute portfolio beta and sector exposure using these holdings and benchmark files." - "Review my current holdings for correlation, top risk contributors, and data gaps."

path: agents/openai.yaml
content: interface:
  display_name: "Portfolio Analytics"
  short_description: "Compute portfolio risk and exposure metrics."
  default_prompt: |
    Use $portfolio-analytics to compute risk metrics for the supplied holdings and price history.
    Report volatility, drawdown, beta when benchmark data exists, concentration, correlation, confidence, and data gaps.
    Treat analytics as context for risk management, not as automatic rebalance instructions.

policy:
  allow_implicit_invocation: true

dependencies:
  tools: []


path: scripts/analyze_portfolio.py
content: #!/usr/bin/env python3
"""Analyze portfolio risk from local CSV inputs."""

from __future__ import annotations

import argparse
import csv
import json
import math
import sys
from itertools import combinations
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from risk_metrics import (  # noqa: E402
    beta,
    concentration,
    correlation,
    max_drawdown,
    returns_from_prices,
    volatility,
    weighted_portfolio_returns,
)


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    try:
        weights = read_holdings(args.holdings_file)
        price_data = read_prices(args.prices_file)
        aligned_dates, aligned_prices = align_holding_prices(price_data, weights)

        asset_returns = {
            ticker: returns_from_prices(prices)
            for ticker, prices in aligned_prices.items()
        }
        portfolio_returns = weighted_portfolio_returns(asset_returns, weights)

        benchmark_summary = None
        if args.benchmark_file:
            benchmark_dates, benchmark_prices = read_benchmark(args.benchmark_file)
            benchmark_returns = align_benchmark_returns(
                aligned_dates, benchmark_dates, benchmark_prices
            )
            benchmark_summary = {
                "returns": round_list(benchmark_returns),
                "beta": round_or_none(beta(portfolio_returns, benchmark_returns)),
                "correlation": round_or_none(
                    correlation(portfolio_returns, benchmark_returns)
                ),
            }

        summary = {
            "inputs": {
                "holdings_file": str(args.holdings_file),
                "prices_file": str(args.prices_file),
                "benchmark_file": str(args.benchmark_file) if args.benchmark_file else None,
                "periods_per_year": args.periods_per_year,
            },
            "dates": {
                "price_dates": aligned_dates,
                "return_dates": aligned_dates[1:],
            },
            "weights": weights,
            "asset_returns": {
                ticker: round_list(returns)
                for ticker, returns in sorted(asset_returns.items())
            },
            "portfolio_returns": round_list(portfolio_returns),
            "metrics": {
                "volatility": round_or_none(
                    volatility(portfolio_returns, args.periods_per_year)
                ),
                "max_drawdown": round(max_drawdown(portfolio_returns), 10),
                "concentration": round_mapping(concentration(weights)),
                "pairwise_correlations": compact_pairwise_correlations(asset_returns),
            },
            "benchmark": benchmark_summary,
        }

        args.outdir.mkdir(parents=True, exist_ok=True)
        output_file = args.outdir / "portfolio_analytics_summary.json"
        summary_json = json.dumps(summary, allow_nan=False, indent=2, sort_keys=True)
        output_file.write_text(summary_json + "\n")
        print(summary_json)
    except (OSError, ValueError) as error:
        parser.exit(2, f"error: {error}\n")

    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Compute portfolio risk analytics from holdings and price CSVs."
    )
    parser.add_argument("--holdings-file", required=True, type=Path)
    parser.add_argument("--prices-file", required=True, type=Path)
    parser.add_argument("--benchmark-file", type=Path)
    parser.add_argument("--outdir", required=True, type=Path)
    parser.add_argument("--periods-per-year", type=parse_periods_per_year, default=12)
    return parser


def read_holdings(path: Path) -> dict[str, float]:
    rows = read_dict_rows(path, {"ticker", "weight"})
    weights: dict[str, float] = {}
    for row in rows:
        ticker = require_text(row, "ticker", path)
        if ticker in weights:
            raise ValueError(f"duplicate holding ticker: {ticker}")
        weight = parse_float(row.get("weight", ""), f"{path}: weight for {ticker}")
        if weight < 0:
            raise ValueError(f"holding weight must be nonnegative: {ticker}")
        weights[ticker] = weight

    if not weights:
        raise ValueError("holdings file has no rows")

    total_weight = sum(weights.values())
    if abs(total_weight - 1.0) > 1e-6:
        raise ValueError(f"holding weights must sum to 1.0; got {total_weight:.10f}")

    return weights


def read_prices(path: Path) -> dict[str, dict[str, float]]:
    rows = read_dict_rows(path, {"date", "ticker", "close"})
    prices: dict[str, dict[str, float]] = {}
    for row in rows:
        ticker = require_text(row, "ticker", path)
        date = require_text(row, "date", path)
        close = parse_float(row.get("close", ""), f"{path}: close for {ticker} on {date}")
        if close <= 0:
            raise ValueError(f"close price must be positive: {ticker} {date}")
        prices.setdefault(ticker, {})
        if date in prices[ticker]:
            raise ValueError(f"duplicate price row: {ticker} {date}")
        prices[ticker][date] = close

    if not prices:
        raise ValueError("prices file has no rows")
    return prices


def read_benchmark(path: Path) -> tuple[list[str], list[float]]:
    rows = read_dict_rows(path, {"date", "close"})
    values: dict[str, float] = {}
    for row in rows:
        date = require_text(row, "date", path)
        close = parse_float(row.get("close", ""), f"{path}: close on {date}")
        if close <= 0:
            raise ValueError(f"benchmark close must be positive: {date}")
        if date in values:
            raise ValueError(f"duplicate benchmark date: {date}")
        values[date] = close

    if not values:
        raise ValueError("benchmark file has no rows")

    dates = sorted(values)
    return dates, [values[date] for date in dates]


def align_holding_prices(
    price_data: dict[str, dict[str, float]], weights: dict[str, float]
) -> tuple[list[str], dict[str, list[float]]]:
    missing_tickers = sorted(ticker for ticker in weights if ticker not in price_data)
    if missing_tickers:
        raise ValueError(f"missing price data for holdings: {', '.join(missing_tickers)}")

    first_ticker = next(iter(weights))
    aligned_dates = sorted(price_data[first_ticker])
    if len(aligned_dates) < 2:
        raise ValueError("at least two aligned price dates are required")

    for ticker in weights:
        ticker_dates = sorted(price_data[ticker])
        if ticker_dates != aligned_dates:
            missing = sorted(set(aligned_dates) - set(ticker_dates))
            extra = sorted(set(ticker_dates) - set(aligned_dates))
            details = []
            if missing:
                details.append(f"missing dates: {', '.join(missing)}")
            if extra:
                details.append(f"extra dates: {', '.join(extra)}")
            suffix = f" ({'; '.join(details)})" if details else ""
            raise ValueError(f"price dates are not aligned for {ticker}{suffix}")

    return aligned_dates, {
        ticker: [price_data[ticker][date] for date in aligned_dates]
        for ticker in weights
    }


def align_benchmark_returns(
    portfolio_price_dates: list[str], benchmark_dates: list[str], benchmark_prices: list[float]
) -> list[float]:
    if benchmark_dates != portfolio_price_dates:
        missing = sorted(set(portfolio_price_dates) - set(benchmark_dates))
        extra = sorted(set(benchmark_dates) - set(portfolio_price_dates))
        details = []
        if missing:
            details.append(f"missing dates: {', '.join(missing)}")
        if extra:
            details.append(f"extra dates: {', '.join(extra)}")
        suffix = f" ({'; '.join(details)})" if details else ""
        raise ValueError(f"benchmark dates are not aligned with portfolio prices{suffix}")
    return returns_from_prices(benchmark_prices)


def compact_pairwise_correlations(
    asset_returns: dict[str, list[float]]
) -> dict[str, float | None]:
    pairs: dict[str, float | None] = {}
    for left, right in combinations(sorted(asset_returns), 2):
        pairs[f"{left}:{right}"] = round_or_none(
            correlation(asset_returns[left], asset_returns[right])
        )
    return pairs


def read_dict_rows(path: Path, required_columns: set[str]) -> list[dict[str, str]]:
    with path.open(newline="") as handle:
        reader = csv.DictReader(handle)
        fieldnames = set(reader.fieldnames or [])
        missing = sorted(required_columns - fieldnames)
        if missing:
            raise ValueError(f"{path} missing columns: {', '.join(missing)}")
        return list(reader)


def require_text(row: dict[str, str], column: str, path: Path) -> str:
    value = (row.get(column) or "").strip()
    if not value:
        raise ValueError(f"{path} has blank {column}")
    return value


def parse_float(raw_value: str, context: str) -> float:
    try:
        value = float(raw_value)
    except ValueError as error:
        raise ValueError(f"invalid number for {context}: {raw_value!r}") from error
    if not math.isfinite(value):
        raise ValueError(f"non-finite number for {context}: {raw_value!r}")
    return value


def parse_periods_per_year(raw_value: str) -> int:
    try:
        value = int(raw_value)
    except ValueError as error:
        raise argparse.ArgumentTypeError(
            f"periods-per-year must be a positive integer: {raw_value!r}"
        ) from error
    if not math.isfinite(value) or value <= 0:
        raise argparse.ArgumentTypeError(
            f"periods-per-year must be a positive integer: {raw_value!r}"
        )
    return value


def round_list(values: list[float]) -> list[float]:
    return [round(value, 10) for value in values]


def round_mapping(values: dict[str, float]) -> dict[str, float]:
    return {key: round(value, 10) for key, value in values.items()}


def round_or_none(value: float | None) -> float | None:
    if value is None:
        return None
    return round(value, 10)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/risk_metrics.py
content: """Pure portfolio risk metric helpers."""

from __future__ import annotations

import math


def returns_from_prices(prices: list[float]) -> list[float]:
    """Return simple period returns from an ordered price series."""
    returns: list[float] = []
    for previous, current in zip(prices, prices[1:]):
        if previous == 0:
            raise ValueError("price series contains zero base price")
        returns.append((current / previous) - 1.0)
    return returns


def weighted_portfolio_returns(
    asset_returns: dict[str, list[float]], weights: dict[str, float]
) -> list[float]:
    """Combine aligned asset return series using portfolio weights."""
    if not weights:
        return []

    missing_tickers = [ticker for ticker in weights if ticker not in asset_returns]
    if missing_tickers:
        raise ValueError(
            "missing asset returns for weights: " + ", ".join(sorted(missing_tickers))
        )

    lengths = {len(asset_returns[ticker]) for ticker in weights}
    if len(lengths) != 1:
        raise ValueError("asset return series are not aligned")

    period_count = lengths.pop()
    portfolio_returns: list[float] = []
    for index in range(period_count):
        portfolio_returns.append(
            sum(asset_returns[ticker][index] * weight for ticker, weight in weights.items())
        )
    return portfolio_returns


def volatility(returns: list[float], periods_per_year: int) -> float | None:
    """Return annualized sample volatility."""
    if len(returns) < 2 or periods_per_year <= 0:
        return None

    mean = sum(returns) / len(returns)
    variance = sum((value - mean) ** 2 for value in returns) / (len(returns) - 1)
    return math.sqrt(variance) * math.sqrt(periods_per_year)


def max_drawdown(returns: list[float]) -> float:
    """Return the worst peak-to-trough drawdown as a negative decimal."""
    wealth = 1.0
    peak = 1.0
    worst_drawdown = 0.0

    for value in returns:
        wealth *= 1.0 + value
        if wealth > peak:
            peak = wealth
        drawdown = (wealth / peak) - 1.0
        if drawdown < worst_drawdown:
            worst_drawdown = drawdown

    return worst_drawdown


def beta(portfolio_returns: list[float], benchmark_returns: list[float]) -> float | None:
    """Return beta versus benchmark returns."""
    if len(portfolio_returns) != len(benchmark_returns):
        return None

    paired = _paired_values(portfolio_returns, benchmark_returns)
    if len(paired) < 2:
        return None

    portfolio, benchmark = zip(*paired)
    benchmark_mean = sum(benchmark) / len(benchmark)
    portfolio_mean = sum(portfolio) / len(portfolio)
    benchmark_variance = sum((value - benchmark_mean) ** 2 for value in benchmark)
    if benchmark_variance == 0:
        return None

    covariance = sum(
        (portfolio_value - portfolio_mean) * (benchmark_value - benchmark_mean)
        for portfolio_value, benchmark_value in paired
    )
    return covariance / benchmark_variance


def correlation(x: list[float], y: list[float]) -> float | None:
    """Return Pearson correlation for aligned series."""
    if len(x) != len(y):
        return None

    paired = _paired_values(x, y)
    if len(paired) < 2:
        return None

    x_values, y_values = zip(*paired)
    x_mean = sum(x_values) / len(x_values)
    y_mean = sum(y_values) / len(y_values)
    x_squares = sum((value - x_mean) ** 2 for value in x_values)
    y_squares = sum((value - y_mean) ** 2 for value in y_values)
    denominator = math.sqrt(x_squares * y_squares)
    if denominator == 0:
        return None

    numerator = sum(
        (x_value - x_mean) * (y_value - y_mean) for x_value, y_value in paired
    )
    return numerator / denominator


def concentration(weights: dict[str, float]) -> dict[str, float]:
    """Return largest holding weight and Herfindahl-Hirschman index."""
    if not weights:
        return {"largest_weight": 0.0, "herfindahl_index": 0.0}

    return {
        "largest_weight": max(weights.values()),
        "herfindahl_index": sum(weight**2 for weight in weights.values()),
    }


def _paired_values(x: list[float], y: list[float]) -> list[tuple[float, float]]:
    return list(zip(x, y))


path: tests/fixtures/benchmark.csv
content: date,close
2024-01-31,1000
2024-02-29,1020
2024-03-31,1010
2024-04-30,1040


path: tests/fixtures/holdings.csv
content: ticker,weight
AAA,0.50
BBB,0.30
CCC,0.20


path: tests/fixtures/prices.csv
content: date,ticker,close
2024-01-31,AAA,100
2024-02-29,AAA,105
2024-03-31,AAA,103
2024-04-30,AAA,110
2024-01-31,BBB,100
2024-02-29,BBB,98
2024-03-31,BBB,101
2024-04-30,BBB,99
2024-01-31,CCC,100
2024-02-29,CCC,102
2024-03-31,CCC,106
2024-04-30,CCC,108


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