"""Market-data traits and in-memory research helpers.

The records in this module normalize typed identifiers and string descriptors
while keeping market quote fields as raw decimals unless a field explicitly
represents a futures price, clean price, or other price-level output.
"""

from __future__ import annotations

from dataclasses import dataclass, field, replace
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Protocol, runtime_checkable

from fuggers_py.bonds.indices import IndexFixingStore
from fuggers_py.core.types import Currency, Date

from .ids import CurrencyPair, CurveId, EtfId, InstrumentId, VolSurfaceId, YearMonth
from .pricing_specs import QuoteSide


def _to_decimal(value: object | None) -> Decimal | None:
    """Coerce a nullable market-data scalar to ``Decimal``."""
    if value is None or isinstance(value, Decimal):
        return value
    return Decimal(str(value))


def _coerce_decimal_fields(instance: object, *names: str) -> None:
    """Normalize named attributes on a dataclass-like instance to decimals."""
    for name in names:
        value = getattr(instance, name)
        coerced = _to_decimal(value)
        if coerced is not None:
            object.__setattr__(instance, name, coerced)


def _apply_two_sided_quote_defaults(instance: object, *, side: QuoteSide, value_field: str) -> None:
    """Populate bid/ask/mid defaults for two-sided quote records."""
    side_field = {
        QuoteSide.BID: "bid",
        QuoteSide.ASK: "ask",
        QuoteSide.MID: "mid",
    }[side]
    value = getattr(instance, value_field)
    if getattr(instance, side_field) is None and value is not None:
        object.__setattr__(instance, side_field, value)
    bid = getattr(instance, "bid")
    ask = getattr(instance, "ask")
    if getattr(instance, "mid") is None and bid is not None and ask is not None:
        object.__setattr__(instance, "mid", (bid + ask) / Decimal(2))


class SourceType(str, Enum):
    """Origin classification for a quote or surface."""

    LIVE = "LIVE"
    CLOSE = "CLOSE"
    REFERENCE = "REFERENCE"
    MODEL = "MODEL"
    MANUAL = "MANUAL"


class CurveInstrumentType(str, Enum):
    """Instrument types accepted by curve construction inputs."""

    CASH = "CASH"
    FUTURE = "FUTURE"
    FRA = "FRA"
    OIS = "OIS"
    IRS = "IRS"
    BOND = "BOND"
    BASIS_SWAP = "BASIS_SWAP"
    BOND_FUTURE = "BOND_FUTURE"
    CDS = "CDS"
    FX_FORWARD = "FX_FORWARD"
    REPO = "REPO"
    SWAP = "SWAP"


class VolSurfaceType(str, Enum):
    """Supported volatility-surface categories."""

    SWAPTION = "SWAPTION"
    CAP_FLOOR = "CAP_FLOOR"
    FX_OPTION = "FX_OPTION"
    BOND_OPTION = "BOND_OPTION"
    ETF_OPTION = "ETF_OPTION"


class VolQuoteType(str, Enum):
    """Quoted volatility convention for a surface point."""

    LOGNORMAL = "LOGNORMAL"
    NORMAL = "NORMAL"
    PRICE = "PRICE"
    SPREAD = "SPREAD"


class InflationInterpolation(str, Enum):
    """Interpolation convention for inflation fixings."""

    MONTHLY = "MONTHLY"
    LINEAR = "LINEAR"
    FLAT = "FLAT"


@dataclass(frozen=True, slots=True)
class RawQuote:
    """Generic quote record with normalized identifiers and raw decimals.

    ``value`` stores the side-specific quote. Bid/ask/mid views can be derived
    from the stored fields, and missing side fields are populated from the
    active side when possible.
    """

    instrument_id: InstrumentId
    value: Decimal
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    source_type: SourceType | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None
    last: Decimal | None = None
    bid_size: Decimal | None = None
    ask_size: Decimal | None = None
    last_size: Decimal | None = None
    yield_to_maturity: Decimal | None = None
    yield_to_worst: Decimal | None = None
    venue: str | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(
            self,
            "value",
            "bid",
            "ask",
            "mid",
            "last",
            "bid_size",
            "ask_size",
            "last_size",
            "yield_to_maturity",
            "yield_to_worst",
        )
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        if self.venue is not None:
            object.__setattr__(self, "venue", self.venue.strip())
        side_field = {
            QuoteSide.BID: "bid",
            QuoteSide.ASK: "ask",
            QuoteSide.MID: "mid",
        }[self.side]
        if getattr(self, side_field) is None:
            object.__setattr__(self, side_field, self.value)
        if self.mid is None and self.bid is not None and self.ask is not None:
            object.__setattr__(self, "mid", (self.bid + self.ask) / Decimal(2))

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the best available quote for the requested side."""
        if side is QuoteSide.BID:
            if self.bid is not None:
                return self.bid
            return self.value if self.side is QuoteSide.BID else None
        if side is QuoteSide.ASK:
            if self.ask is not None:
                return self.ask
            return self.value if self.side is QuoteSide.ASK else None
        if self.mid is not None:
            return self.mid
        return self.value if self.side is QuoteSide.MID else None

    def for_side(self, side: QuoteSide) -> "RawQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, value=quoted_value, side=side)


@dataclass(frozen=True, order=True, slots=True)
class CurvePoint:
    """Single curve point with tenor and value expressed as decimals."""

    tenor: Decimal
    value: Decimal

    def __post_init__(self) -> None:
        object.__setattr__(self, "tenor", _to_decimal(self.tenor))
        object.__setattr__(self, "value", _to_decimal(self.value))


@dataclass(frozen=True, slots=True)
class CurveInput:
    """Calibration input for a single curve instrument."""

    instrument_type: CurveInstrumentType
    tenor: Decimal | None = None
    rate: Decimal | None = None
    price: Decimal | None = None
    weight: Decimal | None = None
    instrument_id: InstrumentId | None = None
    label: str | None = None
    quote: RawQuote | RepoQuote | SwapQuote | BasisSwapQuote | BondFutureQuote | FxForwardQuote | CdsQuote | HaircutQuote | None = None
    source: str | None = None

    def __post_init__(self) -> None:
        _coerce_decimal_fields(self, "tenor", "rate", "price", "weight")
        if self.instrument_id is not None:
            object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        if self.label is not None:
            object.__setattr__(self, "label", self.label.strip())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())


@dataclass(frozen=True, slots=True)
class CurveInputs:
    """Ordered curve calibration bundle for a curve identifier.

    ``from_points`` sorts pillars by tenor, and ``__post_init__`` also orders
    attached calibration instruments to keep in-memory snapshots deterministic.
    """

    curve_id: CurveId
    reference_date: Date
    points: tuple[CurvePoint, ...]
    interpolation: str = "linear"
    curve_kind: str = "zero"
    instruments: tuple[CurveInput, ...] = ()
    source: str | None = None

    def __post_init__(self) -> None:
        ordered_points = tuple(sorted(self.points, key=lambda point: (point.tenor, point.value)))
        ordered_inputs = tuple(
            sorted(
                self.instruments,
                key=lambda item: (
                    Decimal("-1") if item.tenor is None else item.tenor,
                    "" if item.label is None else item.label,
                ),
            )
        )
        object.__setattr__(self, "curve_id", CurveId.parse(self.curve_id))
        object.__setattr__(self, "points", ordered_points)
        object.__setattr__(self, "instruments", ordered_inputs)
        object.__setattr__(self, "interpolation", self.interpolation.strip().lower())
        object.__setattr__(self, "curve_kind", self.curve_kind.strip().lower())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())

    @classmethod
    def from_points(
        cls,
        curve_id: CurveId | str,
        reference_date: Date,
        points: list[CurvePoint] | tuple[CurvePoint, ...],
        *,
        interpolation: str = "linear",
        curve_kind: str = "zero",
        instruments: list[CurveInput] | tuple[CurveInput, ...] | None = None,
        source: str | None = None,
    ) -> "CurveInputs":
        """Build a normalized curve-input bundle from a point sequence."""
        resolved_id = curve_id if isinstance(curve_id, CurveId) else CurveId.parse(curve_id)
        return cls(
            curve_id=resolved_id,
            reference_date=reference_date,
            points=tuple(points),
            interpolation=interpolation,
            curve_kind=curve_kind,
            instruments=tuple(instruments or ()),
            source=source,
        )

    def tenors(self) -> tuple[Decimal, ...]:
        """Return curve tenors in sorted order."""
        return tuple(point.tenor for point in self.points)

    def curve_data(self) -> "CurveData":
        """Convert the inputs bundle into a read-only curve snapshot record."""
        return CurveData(
            curve_id=self.curve_id,
            reference_date=self.reference_date,
            points=self.points,
            interpolation=self.interpolation,
            curve_kind=self.curve_kind,
            instruments=self.instruments,
            source=self.source,
        )


@dataclass(frozen=True, slots=True)
class BondQuote:
    """Bond quote record with clean/dirty price and yield fields."""

    instrument_id: InstrumentId
    clean_price: Decimal | None = None
    dirty_price: Decimal | None = None
    accrued_interest: Decimal | None = None
    yield_to_maturity: Decimal | None = None
    yield_to_worst: Decimal | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    source: str | None = None
    currency: Currency | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(
            self,
            "clean_price",
            "dirty_price",
            "accrued_interest",
            "yield_to_maturity",
            "yield_to_worst",
        )
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())


@dataclass(frozen=True, slots=True)
class RepoQuote:
    """Repo quote record with rate and haircut expressed as raw decimals."""

    instrument_id: InstrumentId
    rate: Decimal | None = None
    haircut: Decimal | None = None
    start_date: Date | None = None
    end_date: Date | None = None
    term: str | None = None
    collateral_type: str | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(self, "rate", "haircut", "bid", "ask", "mid")
        if self.term is not None:
            object.__setattr__(self, "term", self.term.strip().upper())
        if self.collateral_type is not None:
            object.__setattr__(self, "collateral_type", self.collateral_type.strip())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        _apply_two_sided_quote_defaults(self, side=self.side, value_field="rate")

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific repo rate when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "RepoQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, rate=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class SwapQuote:
    """Swap quote record with rate expressed as a raw decimal."""

    instrument_id: InstrumentId
    rate: Decimal | None = None
    tenor: str | None = None
    floating_index: str | None = None
    fixed_frequency: str | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(self, "rate", "bid", "ask", "mid")
        if self.tenor is not None:
            object.__setattr__(self, "tenor", self.tenor.strip().upper())
        if self.floating_index is not None:
            object.__setattr__(self, "floating_index", self.floating_index.strip().upper())
        if self.fixed_frequency is not None:
            object.__setattr__(self, "fixed_frequency", self.fixed_frequency.strip().upper())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        _apply_two_sided_quote_defaults(self, side=self.side, value_field="rate")

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific swap rate when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "SwapQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, rate=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class BasisSwapQuote:
    """Basis swap quote record with basis expressed as a raw decimal."""

    instrument_id: InstrumentId
    basis: Decimal | None = None
    tenor: str | None = None
    pay_index: str | None = None
    receive_index: str | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(self, "basis", "bid", "ask", "mid")
        if self.tenor is not None:
            object.__setattr__(self, "tenor", self.tenor.strip().upper())
        if self.pay_index is not None:
            object.__setattr__(self, "pay_index", self.pay_index.strip().upper())
        if self.receive_index is not None:
            object.__setattr__(self, "receive_index", self.receive_index.strip().upper())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        _apply_two_sided_quote_defaults(self, side=self.side, value_field="basis")

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific basis when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "BasisSwapQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, basis=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class BondFutureQuote:
    """Bond future quote record.

    ``price`` is the futures price level, not a rate or spread. The record
    stores the quoted level alongside optional conversion-factor and repo
    analytics.
    """

    instrument_id: InstrumentId
    price: Decimal | None = None
    delivery_month: YearMonth | None = None
    conversion_factor: Decimal | None = None
    implied_repo_rate: Decimal | None = None
    cheapest_to_deliver: InstrumentId | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        if self.delivery_month is not None:
            object.__setattr__(self, "delivery_month", YearMonth.parse(self.delivery_month))
        if self.cheapest_to_deliver is not None:
            object.__setattr__(self, "cheapest_to_deliver", InstrumentId.parse(self.cheapest_to_deliver))
        _coerce_decimal_fields(self, "price", "conversion_factor", "implied_repo_rate", "bid", "ask", "mid")
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        _apply_two_sided_quote_defaults(self, side=self.side, value_field="price")

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific futures price when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "BondFutureQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, price=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class FxForwardQuote:
    """FX forward quote record.

    ``points`` stores additive forward points relative to spot; ``forward_rate``
    is populated from spot plus points when the direct rate is absent.
    """

    currency_pair: CurrencyPair
    forward_rate: Decimal | None = None
    points: Decimal | None = None
    spot_rate: Decimal | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "currency_pair", CurrencyPair.parse(self.currency_pair))
        _coerce_decimal_fields(self, "forward_rate", "points", "spot_rate", "bid", "ask", "mid")
        if self.forward_rate is None and self.spot_rate is not None and self.points is not None:
            object.__setattr__(self, "forward_rate", self.spot_rate + self.points)
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        _apply_two_sided_quote_defaults(self, side=self.side, value_field="forward_rate")

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific forward rate when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "FxForwardQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, forward_rate=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class CdsQuote:
    """CDS quote record with raw decimal spread and recovery fields."""

    instrument_id: InstrumentId
    par_spread: Decimal | None = None
    upfront: Decimal | None = None
    recovery_rate: Decimal | None = None
    tenor: str | None = None
    reference_entity: str | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(self, "par_spread", "upfront", "recovery_rate", "bid", "ask", "mid")
        if self.tenor is not None:
            object.__setattr__(self, "tenor", self.tenor.strip().upper())
        if self.reference_entity is not None:
            object.__setattr__(self, "reference_entity", self.reference_entity.strip())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        if self.mid is None and self.bid is not None and self.ask is not None:
            object.__setattr__(self, "mid", (self.bid + self.ask) / Decimal(2))
        elif self.mid is None:
            object.__setattr__(self, "mid", self.par_spread if self.par_spread is not None else self.upfront)
        if self.bid is None and self.side is QuoteSide.BID:
            object.__setattr__(self, "bid", self.mid)
        if self.ask is None and self.side is QuoteSide.ASK:
            object.__setattr__(self, "ask", self.mid)

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific CDS spread when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "CdsQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, par_spread=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class HaircutQuote:
    """Haircut quote record with haircut expressed as a raw decimal."""

    instrument_id: InstrumentId
    haircut: Decimal | None = None
    collateral_type: str | None = None
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    currency: Currency | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        _coerce_decimal_fields(self, "haircut", "bid", "ask", "mid")
        if self.collateral_type is not None:
            object.__setattr__(self, "collateral_type", self.collateral_type.strip())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        _apply_two_sided_quote_defaults(self, side=self.side, value_field="haircut")

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific haircut when present."""
        if side is QuoteSide.BID:
            return self.bid
        if side is QuoteSide.ASK:
            return self.ask
        return self.mid

    def for_side(self, side: QuoteSide) -> "HaircutQuote" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, haircut=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class CurveData:
    """Read-only curve snapshot record.

    This is the stored curve representation; :class:`CurveInputs` is the
    calibration-oriented shape that can be turned back into ``CurveData``.
    """

    curve_id: CurveId
    reference_date: Date
    points: tuple[CurvePoint, ...]
    interpolation: str = "linear"
    curve_kind: str = "zero"
    instruments: tuple[CurveInput, ...] = ()
    source: str | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "curve_id", CurveId.parse(self.curve_id))
        object.__setattr__(self, "points", tuple(sorted(self.points, key=lambda point: (point.tenor, point.value))))
        object.__setattr__(self, "instruments", tuple(self.instruments))
        object.__setattr__(self, "interpolation", self.interpolation.strip().lower())
        object.__setattr__(self, "curve_kind", self.curve_kind.strip().lower())
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())

    def as_curve_inputs(self) -> CurveInputs:
        """Return the equivalent calibration input bundle."""
        return CurveInputs(
            curve_id=self.curve_id,
            reference_date=self.reference_date,
            points=self.points,
            interpolation=self.interpolation,
            curve_kind=self.curve_kind,
            instruments=self.instruments,
            source=self.source,
        )


@dataclass(frozen=True, slots=True)
class IndexFixing:
    """Index fixing record with normalized index name."""

    index_name: str
    fixing_date: Date
    value: Decimal

    def __post_init__(self) -> None:
        object.__setattr__(self, "index_name", self.index_name.strip().upper())
        object.__setattr__(self, "value", _to_decimal(self.value))


@dataclass(frozen=True, slots=True)
class VolPoint:
    """Volatility surface point with canonical expiry and optional strike."""

    expiry: YearMonth
    volatility: Decimal
    strike: Decimal | None = None
    tenor: YearMonth | None = None
    delta: Decimal | None = None
    quote_type: VolQuoteType = VolQuoteType.LOGNORMAL

    def __post_init__(self) -> None:
        object.__setattr__(self, "expiry", YearMonth.parse(self.expiry))
        if self.tenor is not None:
            object.__setattr__(self, "tenor", YearMonth.parse(self.tenor))
        _coerce_decimal_fields(self, "volatility", "strike", "delta")


@dataclass(frozen=True, slots=True)
class VolatilitySurface:
    """Volatility surface snapshot keyed by normalized surface identifier."""

    surface_id: VolSurfaceId
    surface_type: VolSurfaceType
    as_of: Date | None = None
    timestamp: datetime | None = None
    points: tuple[VolPoint, ...] = ()
    source: str | None = None
    source_type: SourceType | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "surface_id", VolSurfaceId.parse(self.surface_id))
        object.__setattr__(
            self,
            "points",
            tuple(sorted(self.points, key=lambda point: (point.expiry.year, point.expiry.month, point.volatility))),
        )
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())


@dataclass(frozen=True, slots=True)
class FxRate:
    """FX rate record with bid/ask/mid views and a raw decimal rate."""

    currency_pair: CurrencyPair
    rate: Decimal
    side: QuoteSide = QuoteSide.MID
    as_of: Date | None = None
    timestamp: datetime | None = None
    source: str | None = None
    source_type: SourceType | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "currency_pair", CurrencyPair.parse(self.currency_pair))
        _coerce_decimal_fields(self, "rate", "bid", "ask", "mid")
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        side_field = {
            QuoteSide.BID: "bid",
            QuoteSide.ASK: "ask",
            QuoteSide.MID: "mid",
        }[self.side]
        if getattr(self, side_field) is None:
            object.__setattr__(self, side_field, self.rate)
        if self.mid is None and self.bid is not None and self.ask is not None:
            object.__setattr__(self, "mid", (self.bid + self.ask) / Decimal(2))

    def quoted_value(self, side: QuoteSide = QuoteSide.MID) -> Decimal | None:
        """Return the side-specific FX rate when present."""
        if side is QuoteSide.BID:
            if self.bid is not None:
                return self.bid
            return self.rate if self.side is QuoteSide.BID else None
        if side is QuoteSide.ASK:
            if self.ask is not None:
                return self.ask
            return self.rate if self.side is QuoteSide.ASK else None
        if self.mid is not None:
            return self.mid
        return self.rate if self.side is QuoteSide.MID else None

    def for_side(self, side: QuoteSide) -> "FxRate" | None:
        """Return a copy normalized to a different quote side."""
        quoted_value = self.quoted_value(side)
        if quoted_value is None:
            return None
        return replace(self, rate=quoted_value, side=side)


@dataclass(frozen=True, slots=True)
class InflationFixing:
    """Inflation fixing record keyed by observation month."""

    index_name: str
    observation_month: YearMonth
    value: Decimal
    publication_date: Date | None = None
    interpolation: InflationInterpolation = InflationInterpolation.MONTHLY
    source: str | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "index_name", self.index_name.strip().upper())
        object.__setattr__(self, "observation_month", YearMonth.parse(self.observation_month))
        object.__setattr__(self, "value", _to_decimal(self.value))
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())


@dataclass(frozen=True, slots=True)
class EtfHolding:
    """ETF holding record with either quantity or weight populated."""

    instrument_id: InstrumentId
    quantity: Decimal | None = None
    weight: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        quantity = _to_decimal(self.quantity)
        weight = _to_decimal(self.weight)
        if quantity is None and weight is None:
            raise ValueError("EtfHolding requires either quantity or weight.")
        object.__setattr__(self, "quantity", quantity)
        object.__setattr__(self, "weight", weight)


@dataclass(frozen=True, slots=True)
class EtfQuote:
    """ETF quote record with market price, NAV, and indicative NAV fields."""

    etf_id: EtfId
    market_price: Decimal | None = None
    nav: Decimal | None = None
    i_nav: Decimal | None = None
    shares_outstanding: Decimal | None = None
    as_of: Date | None = None
    timestamp: datetime | None = None
    source: str | None = None
    bid: Decimal | None = None
    ask: Decimal | None = None
    mid: Decimal | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "etf_id", EtfId.parse(self.etf_id))
        _coerce_decimal_fields(self, "market_price", "nav", "i_nav", "shares_outstanding", "bid", "ask", "mid")
        if self.source is not None:
            object.__setattr__(self, "source", self.source.strip())
        if self.mid is None and self.market_price is not None:
            object.__setattr__(self, "mid", self.market_price)
        if self.mid is None and self.bid is not None and self.ask is not None:
            object.__setattr__(self, "mid", (self.bid + self.ask) / Decimal(2))


@dataclass(frozen=True, slots=True)
class MarketDataSnapshot:
    """Immutable bundle of market-data records for snapshot-backed workflows."""

    as_of: Date | None = None
    quotes: tuple[RawQuote, ...] = ()
    repo_quotes: tuple[RepoQuote, ...] = ()
    swap_quotes: tuple[SwapQuote, ...] = ()
    basis_swap_quotes: tuple[BasisSwapQuote, ...] = ()
    bond_future_quotes: tuple[BondFutureQuote, ...] = ()
    fx_forward_quotes: tuple[FxForwardQuote, ...] = ()
    cds_quotes: tuple[CdsQuote, ...] = ()
    haircut_quotes: tuple[HaircutQuote, ...] = ()
    curves: tuple[CurveInputs, ...] = ()
    fixings: tuple[IndexFixing, ...] = ()
    etf_holdings: tuple[EtfHolding, ...] = ()
    volatility_surfaces: tuple[VolatilitySurface, ...] = ()
    fx_rates: tuple[FxRate, ...] = ()
    inflation_fixings: tuple[InflationFixing, ...] = ()
    etf_quotes: tuple[EtfQuote, ...] = ()

    def quote_source(self) -> "InMemoryQuoteSource":
        """Build an in-memory quote source from the stored quotes."""
        return InMemoryQuoteSource(self.quotes)

    def curve_source(self) -> "InMemoryCurveSource":
        """Build an in-memory curve source from the stored curves."""
        return InMemoryCurveSource(self.curves)

    def fixing_source(self) -> "InMemoryFixingSource":
        """Build an in-memory fixing source from the stored fixings."""
        return InMemoryFixingSource(self.fixings)

    def volatility_source(self) -> "InMemoryVolatilitySource":
        """Build an in-memory volatility source from the stored surfaces."""
        return InMemoryVolatilitySource(self.volatility_surfaces)

    def fx_rate_source(self) -> "InMemoryFxRateSource":
        """Build an in-memory FX-rate source from the stored rates."""
        return InMemoryFxRateSource(self.fx_rates)

    def inflation_fixing_source(self) -> "InMemoryInflationFixingSource":
        """Build an in-memory inflation-fixing source from the stored fixings."""
        return InMemoryInflationFixingSource(self.inflation_fixings)

    def etf_quote_source(self) -> "InMemoryEtfQuoteSource":
        """Build an in-memory ETF quote source from the stored quotes."""
        return InMemoryEtfQuoteSource(self.etf_quotes)

    def provider(self) -> "MarketDataProvider":
        """Build a composite provider over the snapshot's in-memory sources."""
        return MarketDataProvider.from_snapshot(self)


@runtime_checkable
class QuoteSource(Protocol):
    """Protocol for quote retrieval by instrument and quote side."""

    def get_quote(self, instrument_id: InstrumentId | str, side: QuoteSide = QuoteSide.MID) -> RawQuote | None:
        ...


@runtime_checkable
class CurveInputSource(Protocol):
    """Protocol for retrieving curve calibration inputs."""

    def get_curve_inputs(self, curve_id: CurveId | str) -> CurveInputs | None:
        ...


@runtime_checkable
class IndexFixingSource(Protocol):
    """Protocol for retrieving index fixings."""

    def get_fixing(self, index_name: str, fixing_date: Date) -> IndexFixing | None:
        ...


@runtime_checkable
class ReferenceCurveSource(Protocol):
    """Protocol for retrieving reference curves."""

    def get_reference_curve(self, curve_id: CurveId | str) -> CurveInputs | None:
        ...


@runtime_checkable
class VolatilitySource(Protocol):
    """Protocol for retrieving volatility surfaces."""

    def get_volatility_surface(self, surface_id: VolSurfaceId | str) -> VolatilitySurface | None:
        ...


@runtime_checkable
class FxRateSource(Protocol):
    """Protocol for retrieving FX rates by currency pair and quote side."""

    def get_fx_rate(self, currency_pair: CurrencyPair | str, side: QuoteSide = QuoteSide.MID) -> FxRate | None:
        ...


@runtime_checkable
class InflationFixingSource(Protocol):
    """Protocol for retrieving inflation fixings by index and month."""

    def get_inflation_fixing(self, index_name: str, observation_month: YearMonth | str) -> InflationFixing | None:
        ...


@runtime_checkable
class EtfQuoteSource(Protocol):
    """Protocol for retrieving ETF quotes."""

    def get_etf_quote(self, etf_id: EtfId | str) -> EtfQuote | None:
        ...


@runtime_checkable
class PricingDataProvider(QuoteSource, CurveInputSource, IndexFixingSource, Protocol):
    """Combined market-data provider used by pricing workflows."""

    pass


CurveSource = CurveInputSource
FixingSource = IndexFixingSource


@dataclass(slots=True)
class InMemoryQuoteSource:
    """Deterministic in-memory quote source.

    Quote lookup is normalized by instrument identifier and quote side. When a
    specific side is missing, mid-side lookups may fall back to the stored mid
    quote; other missing source lookups return ``None``.
    """

    quotes: dict[tuple[InstrumentId, QuoteSide], RawQuote] = field(default_factory=dict)

    def __init__(self, quotes: tuple[RawQuote, ...] | list[RawQuote] | None = None) -> None:
        self.quotes = {}
        for quote in quotes or ():
            self.add_quote(quote)

    def add_quote(self, quote: RawQuote) -> "InMemoryQuoteSource":
        """Store a quote and any side views that can be derived from it."""
        for side in (QuoteSide.BID, QuoteSide.ASK, QuoteSide.MID):
            candidate = quote.for_side(side)
            if candidate is not None:
                self.quotes[(quote.instrument_id, side)] = candidate
        self.quotes[(quote.instrument_id, quote.side)] = quote
        return self

    def get_quote(self, instrument_id: InstrumentId | str, side: QuoteSide = QuoteSide.MID) -> RawQuote | None:
        """Return a normalized quote for the requested instrument and side."""
        resolved = instrument_id if isinstance(instrument_id, InstrumentId) else InstrumentId.parse(instrument_id)
        direct = self.quotes.get((resolved, side))
        if direct is not None:
            return direct
        if side is not QuoteSide.MID:
            return self.quotes.get((resolved, QuoteSide.MID))
        return None


@dataclass(slots=True)
class InMemoryCurveSource:
    """Deterministic in-memory curve source."""

    curves: dict[CurveId, CurveInputs] = field(default_factory=dict)

    def __init__(self, curves: tuple[CurveInputs, ...] | list[CurveInputs] | None = None) -> None:
        self.curves = {}
        for curve in curves or ():
            self.add_curve_inputs(curve)

    def add_curve_inputs(self, curve_inputs: CurveInputs | CurveData) -> "InMemoryCurveSource":
        """Store normalized curve inputs, accepting either snapshot shape."""
        resolved = curve_inputs.as_curve_inputs() if isinstance(curve_inputs, CurveData) else curve_inputs
        self.curves[resolved.curve_id] = resolved
        return self

    def get_curve_inputs(self, curve_id: CurveId | str) -> CurveInputs | None:
        """Return curve inputs for the requested curve identifier."""
        resolved = curve_id if isinstance(curve_id, CurveId) else CurveId.parse(curve_id)
        return self.curves.get(resolved)

    def get_reference_curve(self, curve_id: CurveId | str) -> CurveInputs | None:
        """Alias for :meth:`get_curve_inputs`."""
        return self.get_curve_inputs(curve_id)


@dataclass(slots=True)
class InMemoryFixingSource:
    """Deterministic in-memory index fixing source."""

    fixings: dict[tuple[str, Date], IndexFixing] = field(default_factory=dict)

    def __init__(self, fixings: tuple[IndexFixing, ...] | list[IndexFixing] | None = None) -> None:
        self.fixings = {}
        for fixing in fixings or ():
            self.add_fixing(fixing)

    def add_fixing(self, fixing: IndexFixing) -> "InMemoryFixingSource":
        """Store a fixing keyed by index name and fixing date."""
        self.fixings[(fixing.index_name, fixing.fixing_date)] = fixing
        return self

    def get_fixing(self, index_name: str, fixing_date: Date) -> IndexFixing | None:
        """Return a fixing for the requested index and date."""
        return self.fixings.get((index_name.strip().upper(), fixing_date))

    def get_rate(self, index_name: str, fixing_date: Date) -> Decimal | None:
        """Return only the fixing value for the requested index and date."""
        fixing = self.get_fixing(index_name, fixing_date)
        return None if fixing is None else fixing.value

    def to_fixing_store(self) -> IndexFixingStore:
        """Export the in-memory fixings to a bond-layer fixing store."""
        store = IndexFixingStore()
        for fixing in self.fixings.values():
            store.add_fixing(fixing.index_name, fixing.fixing_date, fixing.value)
        return store


@dataclass(slots=True)
class InMemoryVolatilitySource:
    """Deterministic in-memory volatility surface source."""

    surfaces: dict[VolSurfaceId, VolatilitySurface] = field(default_factory=dict)

    def __init__(self, surfaces: tuple[VolatilitySurface, ...] | list[VolatilitySurface] | None = None) -> None:
        self.surfaces = {}
        for surface in surfaces or ():
            self.add_surface(surface)

    def add_surface(self, surface: VolatilitySurface) -> "InMemoryVolatilitySource":
        """Store a volatility surface keyed by its normalized identifier."""
        self.surfaces[surface.surface_id] = surface
        return self

    def get_volatility_surface(self, surface_id: VolSurfaceId | str) -> VolatilitySurface | None:
        """Return the requested volatility surface when present."""
        resolved = surface_id if isinstance(surface_id, VolSurfaceId) else VolSurfaceId.parse(surface_id)
        return self.surfaces.get(resolved)


@dataclass(slots=True)
class InMemoryFxRateSource:
    """Deterministic in-memory FX-rate source."""

    fx_rates: dict[tuple[CurrencyPair, QuoteSide], FxRate] = field(default_factory=dict)

    def __init__(self, fx_rates: tuple[FxRate, ...] | list[FxRate] | None = None) -> None:
        self.fx_rates = {}
        for fx_rate in fx_rates or ():
            self.add_fx_rate(fx_rate)

    def add_fx_rate(self, fx_rate: FxRate) -> "InMemoryFxRateSource":
        """Store an FX rate and any derivable side views."""
        for side in (QuoteSide.BID, QuoteSide.ASK, QuoteSide.MID):
            candidate = fx_rate.for_side(side)
            if candidate is not None:
                self.fx_rates[(fx_rate.currency_pair, side)] = candidate
        self.fx_rates[(fx_rate.currency_pair, fx_rate.side)] = fx_rate
        return self

    def get_fx_rate(self, currency_pair: CurrencyPair | str, side: QuoteSide = QuoteSide.MID) -> FxRate | None:
        """Return a normalized FX rate for the requested pair and side."""
        resolved = currency_pair if isinstance(currency_pair, CurrencyPair) else CurrencyPair.parse(currency_pair)
        direct = self.fx_rates.get((resolved, side))
        if direct is not None:
            return direct
        if side is not QuoteSide.MID:
            return self.fx_rates.get((resolved, QuoteSide.MID))
        return None


@dataclass(slots=True)
class InMemoryInflationFixingSource:
    """Deterministic in-memory inflation fixing source."""

    fixings: dict[tuple[str, YearMonth], InflationFixing] = field(default_factory=dict)

    def __init__(self, fixings: tuple[InflationFixing, ...] | list[InflationFixing] | None = None) -> None:
        self.fixings = {}
        for fixing in fixings or ():
            self.add_inflation_fixing(fixing)

    def add_inflation_fixing(self, fixing: InflationFixing) -> "InMemoryInflationFixingSource":
        """Store an inflation fixing keyed by index and observation month."""
        self.fixings[(fixing.index_name, fixing.observation_month)] = fixing
        return self

    def get_inflation_fixing(self, index_name: str, observation_month: YearMonth | str) -> InflationFixing | None:
        """Return the requested inflation fixing when present."""
        return self.fixings.get((index_name.strip().upper(), YearMonth.parse(observation_month)))


@dataclass(slots=True)
class InMemoryEtfQuoteSource:
    """Deterministic in-memory ETF quote source."""

    etf_quotes: dict[EtfId, EtfQuote] = field(default_factory=dict)

    def __init__(self, etf_quotes: tuple[EtfQuote, ...] | list[EtfQuote] | None = None) -> None:
        self.etf_quotes = {}
        for quote in etf_quotes or ():
            self.add_etf_quote(quote)

    def add_etf_quote(self, quote: EtfQuote) -> "InMemoryEtfQuoteSource":
        """Store an ETF quote keyed by normalized ETF identifier."""
        self.etf_quotes[quote.etf_id] = quote
        return self

    def get_etf_quote(self, etf_id: EtfId | str) -> EtfQuote | None:
        """Return the requested ETF quote when present."""
        resolved = etf_id if isinstance(etf_id, EtfId) else EtfId.parse(etf_id)
        return self.etf_quotes.get(resolved)


@dataclass(slots=True)
class MarketDataProvider:
    """Composite market-data provider built from optional source objects.

    Source members may be omitted. Missing sources return ``None`` instead of
    raising, which keeps snapshot and file-backed providers predictable.
    """

    quote_source: QuoteSource | None = None
    curve_input_source: CurveInputSource | None = None
    index_fixing_source: IndexFixingSource | None = None
    volatility_source: VolatilitySource | None = None
    fx_rate_source: FxRateSource | None = None
    inflation_fixing_source: InflationFixingSource | None = None
    etf_quote_source: EtfQuoteSource | None = None

    @classmethod
    def from_snapshot(cls, snapshot: MarketDataSnapshot) -> "MarketDataProvider":
        """Build a provider over the in-memory sources derived from a snapshot."""
        return cls(
            quote_source=snapshot.quote_source(),
            curve_input_source=snapshot.curve_source(),
            index_fixing_source=snapshot.fixing_source(),
            volatility_source=snapshot.volatility_source(),
            fx_rate_source=snapshot.fx_rate_source(),
            inflation_fixing_source=snapshot.inflation_fixing_source(),
            etf_quote_source=snapshot.etf_quote_source(),
        )

    def get_quote(self, instrument_id: InstrumentId | str, side: QuoteSide = QuoteSide.MID) -> RawQuote | None:
        """Return a quote from the configured source, if any."""
        if self.quote_source is None:
            return None
        return self.quote_source.get_quote(instrument_id, side)

    def get_curve_inputs(self, curve_id: CurveId | str) -> CurveInputs | None:
        """Return curve inputs from the configured source, if any."""
        if self.curve_input_source is None:
            return None
        return self.curve_input_source.get_curve_inputs(curve_id)

    def get_fixing(self, index_name: str, fixing_date: Date) -> IndexFixing | None:
        """Return a fixing from the configured source, if any."""
        if self.index_fixing_source is None:
            return None
        return self.index_fixing_source.get_fixing(index_name, fixing_date)

    def get_volatility_surface(self, surface_id: VolSurfaceId | str) -> VolatilitySurface | None:
        """Return a volatility surface from the configured source, if any."""
        if self.volatility_source is None:
            return None
        return self.volatility_source.get_volatility_surface(surface_id)

    def get_fx_rate(self, currency_pair: CurrencyPair | str, side: QuoteSide = QuoteSide.MID) -> FxRate | None:
        """Return an FX rate from the configured source, if any."""
        if self.fx_rate_source is None:
            return None
        return self.fx_rate_source.get_fx_rate(currency_pair, side)

    def get_inflation_fixing(self, index_name: str, observation_month: YearMonth | str) -> InflationFixing | None:
        """Return an inflation fixing from the configured source, if any."""
        if self.inflation_fixing_source is None:
            return None
        return self.inflation_fixing_source.get_inflation_fixing(index_name, observation_month)

    def get_etf_quote(self, etf_id: EtfId | str) -> EtfQuote | None:
        """Return an ETF quote from the configured source, if any."""
        if self.etf_quote_source is None:
            return None
        return self.etf_quote_source.get_etf_quote(etf_id)


CurveInputSet = CurveInputs


__all__ = [
    "BasisSwapQuote",
    "BondQuote",
    "BondFutureQuote",
    "CdsQuote",
    "CurveData",
    "CurveInput",
    "CurveInputSource",
    "CurveInputSet",
    "CurveInputs",
    "CurveInstrumentType",
    "CurvePoint",
    "CurveSource",
    "EtfHolding",
    "EtfQuote",
    "EtfQuoteSource",
    "FixingSource",
    "FxForwardQuote",
    "FxRate",
    "FxRateSource",
    "HaircutQuote",
    "IndexFixing",
    "IndexFixingSource",
    "InflationFixing",
    "InflationFixingSource",
    "InflationInterpolation",
    "InMemoryCurveSource",
    "InMemoryEtfQuoteSource",
    "InMemoryFixingSource",
    "InMemoryFxRateSource",
    "InMemoryInflationFixingSource",
    "InMemoryQuoteSource",
    "InMemoryVolatilitySource",
    "MarketDataProvider",
    "MarketDataSnapshot",
    "PricingDataProvider",
    "QuoteSource",
    "RawQuote",
    "RepoQuote",
    "ReferenceCurveSource",
    "SourceType",
    "SwapQuote",
    "VolPoint",
    "VolQuoteType",
    "VolSurfaceType",
    "VolatilitySource",
    "VolatilitySurface",
]
