"""Calendar accounting for existing close-to-close signals; no execution changes.

Fees retain the historical additive-return convention. Open positions are marked
to market, never forcibly liquidated. Date-only end bounds include the whole day.
"""
from __future__ import annotations

import numpy as np
import pandas as pd

COMMISSION_RATE = 0.005


def net_returns(signals: pd.DataFrame) -> pd.Series:
    """Return intervals ending at each row, computed before selecting a window."""
    returns = signals.position.shift(1, fill_value=0) * signals.close.pct_change(fill_method=None).fillna(0)
    return returns - COMMISSION_RATE * (
        signals.execute_entry.shift(1, fill_value=False).astype(float)
        + signals.execute_exit.fillna(False).astype(float)
    )


def window_metrics(signals: pd.DataFrame, start=None, end=None) -> dict:
    """Measure full-history signals within optional inclusive calendar bounds.

    Timestamped rows represent return intervals ending at that timestamp. The
    first row uses the median bar duration. Untimestamped legacy inputs retain
    a daily-bar fallback. Sharpe/Sortino use observed annual bar frequency.
    """
    if signals.empty:
        raise ValueError("Cannot measure empty signals")
    returns = net_returns(signals)
    held = signals.position.shift(1, fill_value=0)
    mask = pd.Series(True, index=signals.index)
    if 'time' in signals:
        times = pd.to_datetime(signals.time, utc=True)
        intervals = times.diff().dt.total_seconds()
        if times.isna().any() or (intervals.iloc[1:] <= 0).any():
            raise ValueError("Signal timestamps must be finite and strictly increasing")
        typical = intervals.median() if len(times) > 1 else 86400.
        intervals = intervals.fillna(typical)
        if start is not None:
            mask &= times >= pd.to_datetime(start, utc=True)
        if end is not None:
            bound = pd.to_datetime(end, utc=True)
            if isinstance(end, str) and len(end) == 10:
                mask &= times < bound + pd.Timedelta(days=1)
            else:
                mask &= times <= bound
    else:
        if start is not None or end is not None:
            raise ValueError("Window bounds require timestamps")
        intervals = pd.Series(86400., index=signals.index)
    frame = signals.loc[mask]
    if frame.empty:
        raise ValueError("No bars in requested accounting window")
    values = returns.loc[mask].to_numpy(dtype=float)
    if not np.isfinite(values).all():
        raise ValueError("Non-finite strategy returns")
    equity = np.r_[1., np.cumprod(1 + values)]
    dd = float(np.min(equity / np.maximum.accumulate(equity) - 1))
    years = float(intervals.loc[mask].sum() / (365.25 * 86400))
    cagr = float(np.expm1(np.log(equity[-1]) / years)) if equity[-1] > 0 else -1.
    frequency = len(values) / years
    std = float(np.std(values, ddof=1)) if len(values) > 1 else 0.
    downside = float(np.sqrt(np.mean(np.minimum(values, 0) ** 2)))
    return {
        'pnl_pct': float((equity[-1] - 1) * 100), 'dd_pct': dd * 100,
        'calmar': cagr / abs(dd) if dd < 0 else None, 'cagr_pct': cagr * 100,
        'entries': int(frame.execute_entry.sum()), 'exits': int(frame.execute_exit.sum()),
        'exposure_pct': float(np.average(held.loc[mask], weights=intervals.loc[mask]) * 100),
        'years': years, 'carry_in': bool(held.loc[mask].iloc[0]),
        'open_at_end': bool(frame.position.iloc[-1]),
        'sharpe': float(values.mean() / std * np.sqrt(frequency)) if std > 0 else None,
        'sortino': float(values.mean() / downside * np.sqrt(frequency)) if downside > 1e-9 else None,
    }
