"""Domain models — the contracts that flow between layers.

Split by purpose:
  * pydantic BaseModel for serializable contracts (DB / API / cross-boundary): Market, StrategySpec,
    BacktestStats, Verdict, ResearchRun. Validated, immutable where it matters.
  * frozen dataclass for in-memory numeric value objects holding numpy arrays (PriceSeries,
    BacktestResult) — these never cross a serialization boundary as-is.
"""
from __future__ import annotations

import datetime as dt
from dataclasses import dataclass, field
from enum import Enum

import numpy as np
from pydantic import BaseModel, ConfigDict, Field


class AssetClass(str, Enum):
    EQUITY = "equity"
    ETF = "etf"
    FUTURE = "future"
    FX = "fx"
    CRYPTO = "crypto"
    RATE = "rate"
    COMMODITY = "commodity"


class Market(BaseModel):
    """A tradeable instrument identity."""
    model_config = ConfigDict(frozen=True)
    symbol: str
    asset_class: AssetClass
    venue: str = "SMART"
    currency: str = "USD"

    def __str__(self) -> str:
        return f"{self.symbol}.{self.asset_class.value}"


@dataclass(frozen=True)
class PriceSeries:
    """Adjusted-close series for one market, dates ascending. In-memory numeric value object.
    `high`/`low` default to `close` when not supplied — a bar with no intraday-range info is a flat
    bar. Real data adapters fill them; synthetic/test series fall back to close."""
    market: Market
    dates: tuple[str, ...]
    close: np.ndarray
    high: np.ndarray = field(default=None)  # type: ignore[arg-type]  # defaulted to close in __post_init__
    low: np.ndarray = field(default=None)   # type: ignore[arg-type]  # defaulted to close in __post_init__

    def __post_init__(self) -> None:
        if len(self.dates) != len(self.close):
            raise ValueError("dates and close length mismatch")
        if self.high is None:
            object.__setattr__(self, "high", self.close.copy())
        if self.low is None:
            object.__setattr__(self, "low", self.close.copy())
        if len(self.high) != len(self.close) or len(self.low) != len(self.close):
            raise ValueError("high/low length must match close")

    def __len__(self) -> int:
        return len(self.close)

    def returns(self) -> np.ndarray:
        r = np.zeros(len(self.close))
        if len(self.close) > 1:
            r[1:] = self.close[1:] / self.close[:-1] - 1.0
        return r


class StrategySpec(BaseModel):
    """The recipe for a strategy: its kind + parameters. Hashable identity for the research matrix."""
    model_config = ConfigDict(frozen=True)
    kind: str
    params: dict[str, float] = Field(default_factory=dict)

    def key(self) -> str:
        ps = ",".join(f"{k}={v:g}" for k, v in sorted(self.params.items()))
        return f"{self.kind}({ps})"


class BacktestStats(BaseModel):
    cagr: float
    ann_vol: float
    sharpe: float
    max_drawdown: float
    n_obs: int


@dataclass(frozen=True)
class BacktestResult:
    """Full backtest output — net daily returns + scalar stats."""
    spec: StrategySpec
    market: Market
    returns: np.ndarray
    stats: BacktestStats


class Verdict(BaseModel):
    """The gauntlet's ruling on a candidate, accounting for the full search size."""
    passed: bool
    dsr: float
    is_sharpe: float
    oos_sharpe: float
    n_trials: int
    reasons: list[str] = Field(default_factory=list)
    pvalue: float = 1.0   # one-sided p-value (1 − DSR) — lets a search FDR-correct across candidates

    def summary(self) -> str:
        flag = "PASS" if self.passed else "REJECT"
        return f"{flag} | DSR {self.dsr:.3f} | IS {self.is_sharpe:+.2f} | OOS {self.oos_sharpe:+.2f} | trials {self.n_trials}"


class ResearchRun(BaseModel):
    """A persisted record of one (market × strategy) evaluation through the pipeline."""
    run_id: str
    market: Market
    spec: StrategySpec
    stats: BacktestStats
    verdict: Verdict
    n_trials: int
    created_at: dt.datetime


def run_id(market: Market, spec: StrategySpec) -> str:
    """Deterministic identity for a (market × strategy) candidate — re-running merges rather than dupes."""
    import hashlib

    h = hashlib.sha1(f"{market}|{spec.key()}".encode()).hexdigest()[:8]
    return f"{market.symbol}-{spec.kind}-{h}"
