#!/usr/bin/env python3
"""Reproduce the Harbor BTC/USDT spot SMA baseline from the saved Binance CSV.

Standard-library only. No network access or trading actions are performed.
Download the companion CSV into this same folder, then run:
  python reproduce.py
"""

from __future__ import annotations

import argparse
import csv
import datetime as dt
import hashlib
import math
import statistics
import sys
from pathlib import Path

UTC = dt.timezone.utc
DAY_MS = 86_400_000
YEAR_DAYS = 365.2425
INITIAL_EQUITY = 1_000.0
EXPECTED_FIELDS = [
    "open_time", "open", "high", "low", "close", "volume", "close_time",
    "quote_volume", "number_of_trades", "taker_buy_base_volume",
    "taker_buy_quote_volume", "ignore",
]


def read_rows(path: Path) -> list[list[str]]:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.reader(handle)
        header = next(reader, None)
        if header != EXPECTED_FIELDS:
            raise ValueError(f"Unexpected CSV header: {header!r}")
        rows = list(reader)
    if any(len(row) != 12 for row in rows):
        raise ValueError("Every candle row must have the 12 Binance kline fields")
    return rows


def verify_rows(rows: list[list[str]]) -> tuple[list[dt.date], str, list[dt.date]]:
    if not rows:
        raise ValueError("Input CSV has no candles")
    open_times = [int(row[0]) for row in rows]
    if open_times != sorted(open_times) or len(open_times) != len(set(open_times)):
        raise ValueError("Candle open times must be unique and chronological")
    for row in rows:
        opened, closed = int(row[0]), int(row[6])
        if opened % DAY_MS or closed != opened + DAY_MS - 1:
            raise ValueError(f"Incomplete or off-grid candle found: {row[0]} / {row[6]}")
        if float(row[1]) <= 0 or float(row[4]) <= 0:
            raise ValueError(f"Nonpositive price in candle at {row[0]}")
    dates = [dt.datetime.fromtimestamp(value / 1000, UTC).date() for value in open_times]
    expected = set()
    current = dt.date(2017, 8, 17)
    last = dt.date(2026, 9, 28)
    while current <= last:
        expected.add(current)
        current += dt.timedelta(days=1)
    missing = sorted(expected - set(dates))
    if dates[0] != dt.date(2017, 8, 17) or dates[-1] != last:
        raise ValueError(f"Unexpected analyzed date range: {dates[0]} through {dates[-1]}")
    if missing != [dt.date(2018, 2, 8)]:
        raise ValueError(f"Unexpected missing dates: {missing}")
    canonical = ("\n".join("\t".join(row) for row in rows) + "\n").encode("utf-8")
    return dates, hashlib.sha256(canonical).hexdigest(), missing


def simulate(rows: list[list[str]], dates: list[dt.date], kind: str, fee: float) -> dict:
    n = len(rows)
    opens = [float(row[1]) for row in rows]
    closes = [float(row[4]) for row in rows]
    target = [0] * n
    for i in range(1, n):
        previous = i - 1
        if previous >= 99:
            sma20 = sum(closes[previous - 19 : previous + 1]) / 20.0
            sma100 = sum(closes[previous - 99 : previous + 1]) / 100.0
            target[i] = int(sma20 > sma100)

    equity = INITIAL_EQUITY
    previous_position = 0
    returns: list[float] = []
    path: list[float] = []
    positions: list[int] = []
    notionals: list[float] = []
    orders: list[int] = []
    fees: list[float] = []

    for i in range(n):
        if kind == "cash":
            position, notional, fee_paid, new_equity = 0, 0.0, 0.0, equity
        elif kind == "buyhold":
            position = 1
            at_open = equity if i == 0 else equity * opens[i] / closes[i - 1]
            notional = at_open if i == 0 else 0.0
            fee_paid = notional * fee
            new_equity = (at_open - fee_paid) * closes[i] / opens[i]
            if i == n - 1:
                notional += new_equity
                fee_paid += new_equity * fee
                new_equity *= 1.0 - fee
        else:
            position = target[i]
            at_open = equity if i == 0 else equity * (
                1.0 + previous_position * (opens[i] / closes[i - 1] - 1.0)
            )
            notional = at_open * abs(position - previous_position)
            fee_paid = notional * fee
            new_equity = (at_open - fee_paid) * (
                1.0 + position * (closes[i] / opens[i] - 1.0)
            )
            if i == n - 1 and position == 1:
                notional += new_equity
                fee_paid += new_equity * fee
                new_equity *= 1.0 - fee

        returns.append(new_equity / equity - 1.0)
        path.append(new_equity)
        positions.append(position)
        notionals.append(notional)
        orders.append(int(notional > 0) if kind != "buyhold" else int(i == 0 or i == n - 1))
        fees.append(fee_paid)
        equity = new_equity
        previous_position = position

    return {
        "returns": returns,
        "path": path,
        "positions": positions,
        "notionals": notionals,
        "orders": orders,
        "fees": fees,
        "ending_equity": equity,
    }


def summarize(result: dict, indices: list[int], start_equity: float,
              start_date: dt.datetime, end_date: dt.datetime) -> dict:
    daily_returns = [result["returns"][i] for i in indices]
    equity = start_equity
    curve = [equity]
    for daily_return in daily_returns:
        equity *= 1.0 + daily_return
        curve.append(equity)

    years = max((end_date - start_date).total_seconds() / (YEAR_DAYS * 86_400), 1 / YEAR_DAYS)
    daily_sd = statistics.stdev(daily_returns) if len(daily_returns) > 1 else 0.0
    peak = curve[0]
    max_drawdown = 0.0
    for value in curve[1:]:
        peak = max(peak, value)
        max_drawdown = min(max_drawdown, value / peak - 1.0)

    gross_notional = sum(result["notionals"][i] for i in indices)
    average_equity = statistics.fmean(result["path"][i] for i in indices)
    return {
        "ending_equity": equity,
        "cagr": (equity / start_equity) ** (1.0 / years) - 1.0,
        "volatility": daily_sd * math.sqrt(365.0),
        "sharpe": statistics.mean(daily_returns) / daily_sd * math.sqrt(365.0) if daily_sd else None,
        "max_drawdown": max_drawdown,
        "exposure": sum(result["positions"][i] for i in indices) / len(indices),
        "gross_notional": gross_notional,
        "turnover_pct_avg_equity": 100.0 * gross_notional / average_equity if average_equity else 0.0,
        "orders": sum(result["orders"][i] for i in indices),
        "fees": sum(result["fees"][i] for i in indices),
    }


def fmt_pct(value: float | None) -> str:
    return "n/a" if value is None else f"{value * 100:.2f}%"


def fmt_num(value: float | None) -> str:
    return "n/a" if value is None else f"{value:.3f}"


def fmt_usd(value: float) -> str:
    return "$" + format(value, ",.2f")


def main() -> None:
    default_data = Path(__file__).with_name("btcusdt-1d-2017-08-17_2026-09-28.csv")
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("data", nargs="?", type=Path, default=default_data, help="saved Binance CSV")
    args = parser.parse_args()

    rows = read_rows(args.data)
    dates, digest, missing = verify_rows(rows)
    csv_digest = hashlib.sha256(args.data.read_bytes()).hexdigest()
    fee_scenarios = [("Base 25 bps/side", 0.0025), ("Stress 50 bps/side", 0.005)]
    results = {}
    for label, fee in fee_scenarios:
        for name, kind in [("SMA20/100", "sma"), ("Buy & hold", "buyhold"), ("Cash", "cash")]:
            results[(label, name)] = simulate(rows, dates, kind, fee)

    all_indices = list(range(len(rows)))
    first_day, last_day = dates[0], dates[-1]
    first_time = dt.datetime.combine(first_day, dt.time(0), UTC)
    last_time = dt.datetime.combine(last_day, dt.time(23, 59, 59, 999000), UTC)
    print(f"Python: {sys.version.split()[0]} (standard library only)")
    print(f"Input: {args.data.resolve()}")
    print(f"Rows: {len(rows)}; span: {first_day}..{last_day}; missing date: {', '.join(map(str, missing))}")
    print(f"Saved CSV byte SHA-256: {csv_digest}")
    print(f"Canonical TSV-row SHA-256: {digest}")
    print("\nFull period")
    print("Costs | Portfolio | CAGR | Volatility | Sharpe | Max DD | BTC exposure | Gross notional | Turnover/avg equity | Orders | Fees | End equity")
    for fee_label, _ in fee_scenarios:
        for name in ["SMA20/100", "Buy & hold", "Cash"]:
            result = results[(fee_label, name)]
            summary = summarize(result, all_indices, INITIAL_EQUITY, first_time, last_time)
            print(
                f"{fee_label} | {name} | {fmt_pct(summary['cagr'])} | {fmt_pct(summary['volatility'])} | "
                f"{fmt_num(summary['sharpe'])} | {fmt_pct(summary['max_drawdown'])} | "
                f"{fmt_pct(summary['exposure'])} | {fmt_usd(summary['gross_notional'])} | "
                f"{summary['turnover_pct_avg_equity']:.1f}% | {summary['orders']} | "
                f"{fmt_usd(summary['fees'])} | {fmt_usd(result['ending_equity'])}"
            )

    periods = [
        ("Development 2017-2020", dt.date(2017, 1, 1), dt.date(2020, 12, 31)),
        ("Validation 2021-2022", dt.date(2021, 1, 1), dt.date(2022, 12, 31)),
        ("Holdout 2023-latest", dt.date(2023, 1, 1), last_day),
    ]
    print("\nTime splits; base costs plus SMA stress sensitivity")
    for label, start_date, end_date in periods:
        indices = [i for i, value in enumerate(dates) if start_date <= value <= end_date]
        if not indices:
            continue
        actual_start, actual_end = dates[indices[0]], dates[indices[-1]]
        start_dt = dt.datetime.combine(actual_start, dt.time(0), UTC)
        end_dt = dt.datetime.combine(actual_end, dt.time(23, 59, 59, 999000), UTC)
        scenario_names = [
            ("Base 25 bps/side", ["SMA20/100", "Buy & hold", "Cash"]),
            ("Stress 50 bps/side", ["SMA20/100"]),
        ]
        for fee_label, names in scenario_names:
            for name in names:
                result = results[(fee_label, name)]
                opening_equity = INITIAL_EQUITY if indices[0] == 0 else result["path"][indices[0] - 1]
                summary = summarize(result, indices, opening_equity, start_dt, end_dt)
                print(
                    f"{label} [{actual_start}..{actual_end}] | {name}, {fee_label} | "
                    f"CAGR {fmt_pct(summary['cagr'])} | vol {fmt_pct(summary['volatility'])} | "
                    f"Sharpe {fmt_num(summary['sharpe'])} | MDD {fmt_pct(summary['max_drawdown'])} | "
                    f"exposure {fmt_pct(summary['exposure'])} | turnover {fmt_usd(summary['gross_notional'])}/"
                    f"{summary['turnover_pct_avg_equity']:.1f}% | orders {summary['orders']} | "
                    f"fees {fmt_usd(summary['fees'])} | equity {fmt_usd(opening_equity)} -> "
                    f"{fmt_usd(summary['ending_equity'])}"
                )


if __name__ == "__main__":
    main()

