#!/usr/bin/env python3
"""Educational portfolio arithmetic, using disclosed, invented daily returns.

Python 3.10+; standard library only. This does not place orders, fetch market
data, optimize a portfolio, or estimate the probability of a future loss.
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import json
import math
from datetime import date, timedelta
from pathlib import Path
from statistics import mean


BOT_NAMES = ("bot_a", "bot_b", "bot_c")
INPUT_COLUMNS = ("date",) + tuple(f"{bot}_net_return" for bot in BOT_NAMES)


def read_returns(path: Path) -> list[tuple[date, tuple[float, ...]]]:
    """Reject missing data; consecutive dates are specific to this 24/7 fixture."""
    observations = []
    previous = None
    with path.open(newline="", encoding="utf-8-sig") as stream:
        reader = csv.DictReader(stream)
        if tuple(reader.fieldnames or ()) != INPUT_COLUMNS:
            raise ValueError(f"Expected exactly these CSV columns: {INPUT_COLUMNS}")
        for line_number, row in enumerate(reader, start=2):
            if None in row or any(row[column] is None or row[column].strip() == "" for column in INPUT_COLUMNS):
                raise ValueError(f"Missing or extra CSV value on line {line_number}; missing is not zero")
            observation_date = date.fromisoformat(row["date"])
            if previous is not None and observation_date != previous + timedelta(days=1):
                raise ValueError("Dates must be unique, increasing, and consecutive in this calendar-day fixture")
            returns = tuple(float(row[f"{bot}_net_return"]) for bot in BOT_NAMES)
            if any(not math.isfinite(value) or value < -1 for value in returns):
                raise ValueError("Returns must be finite and at least -1 for fully funded, unleveraged sleeves")
            observations.append((observation_date, returns))
            previous = observation_date
    if len(observations) < 2:
        raise ValueError("At least two synchronous observations are required")
    return observations


def sample_covariance(columns: list[list[float]]) -> list[list[float]]:
    if not columns or len(columns[0]) < 2 or any(len(column) != len(columns[0]) for column in columns):
        raise ValueError("Equal-length, synchronous columns with at least two observations are required")
    if any(not math.isfinite(value) for column in columns for value in column):
        raise ValueError("Statistics require finite observations")
    averages = [mean(column) for column in columns]
    count = len(columns[0])
    return [[sum((x - averages[i]) * (y - averages[j]) for x, y in zip(columns[i], columns[j])) / (count - 1)
             for j in range(len(columns))] for i in range(len(columns))]


def correlation_from_covariance(covariance: list[list[float]]) -> list[list[float | None]]:
    # Correlation with a constant return series is undefined, not zero.
    return [[covariance[i][j] / math.sqrt(covariance[i][i] * covariance[j][j])
             if covariance[i][i] > 0 and covariance[j][j] > 0 else None
             for j in range(len(covariance))] for i in range(len(covariance))]


def covariance_from_volatility(volatilities: list[float], common_correlation: float) -> list[list[float]]:
    count = len(volatilities)
    if count < 2 or not (-1 / (count - 1) <= common_correlation <= 1):
        raise ValueError("Common correlation does not form a positive semidefinite equicorrelation matrix")
    if any(not math.isfinite(volatility) or volatility <= 0 for volatility in volatilities):
        raise ValueError("Volatilities must be positive and finite")
    return [[volatilities[i] * volatilities[j] * (1 if i == j else common_correlation)
             for j in range(count)] for i in range(count)]


def check_weights(weights: list[float]) -> None:
    if not weights or any(not math.isfinite(weight) or weight < 0 for weight in weights) or sum(weights) > 1 + 1e-12:
        raise ValueError("Nonnegative weights totaling at most one are required; the rest is zero-return cash")


def portfolio_risk(weights: list[float], covariance: list[list[float]]) -> dict:
    """Euler decomposition of standard deviation at stated weights (no optimizer)."""
    check_weights(weights)
    if len(covariance) != len(weights) or any(len(row) != len(weights) for row in covariance):
        raise ValueError("Weights and covariance dimensions differ")
    covariance_times_weights = [sum(row[j] * weights[j] for j in range(len(weights))) for row in covariance]
    variance = sum(weight * value for weight, value in zip(weights, covariance_times_weights))
    if not math.isfinite(variance) or variance <= 0:
        raise ValueError("Portfolio variance must be positive and finite")
    volatility = math.sqrt(variance)
    contributions = [weight * value / volatility for weight, value in zip(weights, covariance_times_weights)]
    weighted_standalone_volatility = sum(weights[i] * math.sqrt(covariance[i][i]) for i in range(len(weights)))
    return {
        "weights": weights,
        "portfolio_daily_volatility": volatility,
        "weighted_standalone_daily_volatility": weighted_standalone_volatility,
        "diversification_ratio": weighted_standalone_volatility / volatility,
        "component_daily_volatility": contributions,
        "fraction_of_total_volatility": [value / volatility for value in contributions],
        "component_sum": sum(contributions),
    }


def joint_stress(capital: float, weights: list[float], sleeve_returns: list[float]) -> dict:
    check_weights(weights)
    if not math.isfinite(capital) or capital <= 0 or len(weights) != len(sleeve_returns):
        raise ValueError("Positive finite capital and matching return/weight dimensions required")
    if any(not math.isfinite(value) or value < -1 for value in sleeve_returns):
        raise ValueError("Stress returns must be finite and at least -1")
    contribution_returns = [weight * value for weight, value in zip(weights, sleeve_returns)]
    portfolio_return = sum(contribution_returns)
    return {
        "capital": capital,
        "weights": weights,
        "cash_weight": 1 - sum(weights),
        "assumed_sleeve_returns": sleeve_returns,
        "portfolio_return": portfolio_return,
        "portfolio_pnl": capital * portfolio_return,
        "ending_equity": capital * (1 + portfolio_return),
        "sleeve_pnl": [capital * value for value in contribution_returns],
    }


def equity_path(observations: list[tuple[date, tuple[float, ...]]], capital: float, initial_weights: list[float]) -> tuple[list[dict], dict]:
    """One initial allocation; three sleeve NAVs compound without transfers."""
    check_weights(initial_weights)
    if len(initial_weights) != len(BOT_NAMES) or not math.isfinite(capital) or capital <= 0:
        raise ValueError("Three initial weights and positive finite capital are required")
    sleeve_equity = [capital * weight for weight in initial_weights]
    sleeve_initial = sleeve_equity.copy()
    sleeve_peaks = sleeve_equity.copy()
    sleeve_max_drawdowns = [0.0] * len(initial_weights)
    cash = capital * (1 - sum(initial_weights))
    previous_equity = capital
    portfolio_peak = capital
    maximum_drawdown = 0.0
    output = []
    for observation_date, returns in observations:
        if len(returns) != len(initial_weights) or any(not math.isfinite(value) or value < -1 for value in returns):
            raise ValueError("Each day needs three finite, unleveraged sleeve returns")
        opening_weights = [value / previous_equity if previous_equity else 0 for value in sleeve_equity]
        row = {"date": observation_date.isoformat()}
        for i, bot in enumerate(BOT_NAMES):
            sleeve_equity[i] *= 1 + returns[i]
            sleeve_peaks[i] = max(sleeve_peaks[i], sleeve_equity[i])
            drawdown = 1 - sleeve_equity[i] / sleeve_peaks[i] if sleeve_peaks[i] else 0
            sleeve_max_drawdowns[i] = max(sleeve_max_drawdowns[i], drawdown)
            row[f"{bot}_net_return"] = returns[i]
            row[f"{bot}_opening_weight"] = opening_weights[i]
            row[f"{bot}_equity"] = sleeve_equity[i]
            row[f"{bot}_drawdown"] = drawdown
        total_equity = sum(sleeve_equity) + cash
        portfolio_peak = max(portfolio_peak, total_equity)
        portfolio_drawdown = 1 - total_equity / portfolio_peak
        maximum_drawdown = max(maximum_drawdown, portfolio_drawdown)
        portfolio_return = total_equity / previous_equity - 1 if previous_equity else 0
        row.update({"cash_equity": cash, "portfolio_net_return": portfolio_return,
                    "portfolio_equity": total_equity, "portfolio_drawdown": portfolio_drawdown})
        output.append(row)
        previous_equity = total_equity
    final_equity = sum(sleeve_equity) + cash
    summary = {
        "initial_capital": capital,
        "initial_weights": initial_weights,
        "accounting": "initial allocation; no transfers or rebalancing; weights drift",
        "final_equity": final_equity,
        "net_pnl": final_equity - capital,
        "net_return": final_equity / capital - 1,
        "daily_close_max_drawdown": maximum_drawdown,
        "sleeve_final_equity": sleeve_equity,
        "sleeve_net_returns": [final / initial - 1 if initial else None for final, initial in zip(sleeve_equity, sleeve_initial)],
        "sleeve_daily_close_max_drawdowns": sleeve_max_drawdowns,
        "initial_weighted_average_of_sleeve_max_drawdowns": sum(weight * value for weight, value in zip(initial_weights, sleeve_max_drawdowns)),
        "final_weights": [value / final_equity if final_equity else 0 for value in sleeve_equity],
        "cash_equity": cash,
    }
    return output, summary


def write_csv(path: Path, records: list[dict]) -> None:
    with path.open("w", newline="", encoding="utf-8") as stream:
        writer = csv.DictWriter(stream, fieldnames=list(records[0]))
        writer.writeheader()
        writer.writerows(records)


def run(input_path: Path, output_dir: Path) -> dict:
    protocol_path = Path(__file__).resolve().parent / "protocol.json"
    protocol = json.loads(protocol_path.read_text(encoding="utf-8"))
    observations = read_returns(input_path)
    columns = [[values[index] for _, values in observations] for index in range(3)]
    covariance = sample_covariance(columns)
    correlation = correlation_from_covariance(covariance)
    equity, equity_summary = equity_path(observations, protocol["capital"], protocol["initial_weights"])
    equal_volatility = []
    for rho in protocol["equal_volatility_example"]["common_pair_correlations"]:
        risk = portfolio_risk([1 / 3] * 3, covariance_from_volatility([0.01] * 3, rho))
        equal_volatility.append({"common_pair_correlation": rho, **risk})
    risk_input = protocol["risk_contribution_example"]
    unequal_risk = portfolio_risk(risk_input["weights"], covariance_from_volatility(risk_input["daily_volatilities"], risk_input["common_pair_correlation"]))
    unequal_risk["assumed_daily_volatilities"] = risk_input["daily_volatilities"]
    unequal_risk["assumed_common_pair_correlation"] = risk_input["common_pair_correlation"]
    two_bot_input = protocol["two_bot_analytical_check"]
    two_bot_risk = portfolio_risk(two_bot_input["weights"], covariance_from_volatility(two_bot_input["daily_volatilities"], two_bot_input["common_pair_correlation"]))
    two_bot_risk["assumed_daily_volatilities"] = two_bot_input["daily_volatilities"]
    two_bot_risk["assumed_common_pair_correlation"] = two_bot_input["common_pair_correlation"]
    assumed_stress = protocol["stress_example"]["assumed_sleeve_returns"]
    stresses = {label: joint_stress(protocol["capital"], weights, assumed_stress)
                for label, weights in protocol["stress_example"]["allocations"].items()}
    all_negative = [{"date": observation_date.isoformat(), "sleeve_returns": list(values)}
                    for observation_date, values in observations if all(value < 0 for value in values)]
    result = {
        "notice": "Every input return is invented. This is educational arithmetic, not market evidence, a backtest, or a forecast.",
        "input_sha256": hashlib.sha256(input_path.read_bytes()).hexdigest(),
        "protocol_sha256": hashlib.sha256(protocol_path.read_bytes()).hexdigest(),
        "observations": len(observations),
        "start": observations[0][0].isoformat(),
        "end": observations[-1][0].isoformat(),
        "sample_means": [mean(column) for column in columns],
        "sample_daily_volatilities": [math.sqrt(covariance[i][i]) for i in range(3)],
        "sample_covariance": covariance,
        "sample_pearson_correlation": correlation,
        "equal_volatility_examples": equal_volatility,
        "unequal_volatility_risk_contributions": unequal_risk,
        "two_bot_analytical_check": two_bot_risk,
        "assumed_joint_stress": stresses,
        "constructed_fixture_equity": equity_summary,
        "constructed_fixture_all_negative_days": all_negative,
        "sample_covariance_risk_at_initial_weights": portfolio_risk(protocol["initial_weights"], covariance),
        "limitations": protocol["excluded"],
    }
    output_dir.mkdir(parents=True, exist_ok=True)
    (output_dir / "results.json").write_text(json.dumps(result, indent=2, allow_nan=False) + "\n", encoding="utf-8")
    write_csv(output_dir / "equity.csv", equity)
    write_csv(output_dir / "correlation.csv", [{"bot": bot, **{other: correlation[i][j] for j, other in enumerate(BOT_NAMES)}} for i, bot in enumerate(BOT_NAMES)])
    write_csv(output_dir / "covariance.csv", [{"bot": bot, **{other: covariance[i][j] for j, other in enumerate(BOT_NAMES)}} for i, bot in enumerate(BOT_NAMES)])
    write_csv(output_dir / "risk-contributions.csv", [{"bot": bot, "capital_weight": risk_input["weights"][i],
                 "assumed_daily_volatility": risk_input["daily_volatilities"][i],
                 "contribution_to_daily_portfolio_volatility": unequal_risk["component_daily_volatility"][i],
                 "fraction_of_total_volatility": unequal_risk["fraction_of_total_volatility"][i]} for i, bot in enumerate(BOT_NAMES)])
    return result


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, default=Path(__file__).resolve().parent / "sleeve-returns.csv")
    parser.add_argument("--output-dir", type=Path, default=Path(__file__).resolve().parent / "results")
    arguments = parser.parse_args()
    results = run(arguments.input, arguments.output_dir)
    print(f"Calculated {results['observations']} constructed daily observations. No orders or network requests were made.")
