"""Paper-trading service: take, mark-to-market, and close simulated trades."""

from __future__ import annotations

import bisect
from datetime import date, datetime, timezone

from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.services import benchmark_service, settings_store
from app.services.outcome_service import (
    OUTCOME_AMBIGUOUS,
    OUTCOME_STOP_HIT,
    OUTCOME_TARGET_HIT,
    Bar,
    evaluate_setup_against_bars,
)

# Exit policy for OPEN paper trades (auto-close). Production defaults to the
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
# 30-trading-day hold. The older percent trail and target/stop modes remain
# selectable for comparison. Stored in SystemSetting so it's tunable and visible.
KEY_EXIT_MODE = "paper_exit_mode"
KEY_TRAILING_PCT = "paper_trailing_pct"
KEY_ATR_MULTIPLIER = "paper_atr_multiplier"
KEY_HOLD_DAYS = "paper_hold_days"
DEFAULT_EXIT_MODE = "atr_trailing"
DEFAULT_TRAILING_PCT = 12.0
DEFAULT_ATR_MULTIPLIER = 3.0
DEFAULT_HOLD_DAYS = 30

_VALID_EXIT_MODES = ("time", "trailing", "atr_trailing", "target")


async def get_exit_policy(db: AsyncSession) -> dict:
    """Active auto-exit policy:
    {'mode': 'time'|'trailing'|'atr_trailing'|'target', ...}."""
    mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
    if mode not in _VALID_EXIT_MODES:
        mode = DEFAULT_EXIT_MODE
    raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
    try:
        pct = float(raw)
    except (TypeError, ValueError):
        pct = DEFAULT_TRAILING_PCT
    pct = max(0.5, min(90.0, pct))
    raw_atr = await settings_store.get_value(db, KEY_ATR_MULTIPLIER, str(DEFAULT_ATR_MULTIPLIER))
    try:
        atr_multiplier = float(raw_atr)
    except (TypeError, ValueError):
        atr_multiplier = DEFAULT_ATR_MULTIPLIER
    atr_multiplier = max(0.5, min(10.0, atr_multiplier))
    raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
    try:
        hold_days = int(float(raw_days))
    except (TypeError, ValueError):
        hold_days = DEFAULT_HOLD_DAYS
    hold_days = max(2, min(250, hold_days))
    return {
        "mode": mode,
        "trailing_pct": pct,
        "atr_multiplier": atr_multiplier,
        "hold_days": hold_days,
    }


async def set_exit_policy(
    db: AsyncSession,
    *,
    mode: str | None = None,
    trailing_pct: float | None = None,
    atr_multiplier: float | None = None,
    hold_days: int | None = None,
) -> dict:
    """Persist the auto-exit policy (admin). Validates inputs."""
    if mode is not None:
        mode = mode.strip().lower()
        if mode not in _VALID_EXIT_MODES:
            raise ValidationError("mode must be 'time', 'trailing', 'atr_trailing' or 'target'")
        await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
    if trailing_pct is not None:
        if not 0.5 <= float(trailing_pct) <= 90.0:
            raise ValidationError("trailing_pct must be between 0.5 and 90")
        await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
    if atr_multiplier is not None:
        if not 0.5 <= float(atr_multiplier) <= 10.0:
            raise ValidationError("atr_multiplier must be between 0.5 and 10")
        await settings_store.upsert_setting(db, KEY_ATR_MULTIPLIER, str(float(atr_multiplier)))
    if hold_days is not None:
        if not 2 <= int(hold_days) <= 250:
            raise ValidationError("hold_days must be between 2 and 250")
        await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days)))
    await db.commit()
    return await get_exit_policy(db)


async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
    normalised = symbol.strip().upper()
    result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
    ticker = result.scalar_one_or_none()
    if ticker is None:
        raise NotFoundError(f"Ticker not found: {normalised}")
    return ticker


async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
    """Latest stored close per ticker."""
    if not ticker_ids:
        return {}
    latest = (
        select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("md"))
        .where(OHLCVRecord.ticker_id.in_(ticker_ids))
        .group_by(OHLCVRecord.ticker_id)
        .subquery()
    )
    stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
        latest,
        and_(
            OHLCVRecord.ticker_id == latest.c.ticker_id,
            OHLCVRecord.date == latest.c.md,
        ),
    )
    result = await db.execute(stmt)
    return {tid: float(close) for tid, close in result.all()}


async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> float | None:
    """Highest high strictly after ``since`` — the running peak for a trailing stop."""
    result = await db.execute(
        select(func.max(OHLCVRecord.high)).where(
            OHLCVRecord.ticker_id == ticker_id, OHLCVRecord.date > since
        )
    )
    v = result.scalar()
    return float(v) if v is not None else None


def _time_close(
    direction: str, init_stop: float, hold_days: int, rows: list[tuple]
) -> tuple[float, date, str] | None:
    """Walk post-entry ``rows`` of (date, open, high, low, close); close at the
    initial stop if hit (a gap through it fills at the open, matching the
    backtest's fill model), else at the ``hold_days``-th bar's close ('time').
    None while neither has happened."""
    long = direction == "long"
    for i, (d, open_, high, low, close) in enumerate(rows):
        if (low <= init_stop) if long else (high >= init_stop):
            fill = min(init_stop, open_) if long else max(init_stop, open_)
            return float(fill), d, "stop"
        if i + 1 >= hold_days:
            return float(close), d, "time"
    return None


def _trailing_close(
    direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
) -> tuple[float, date, str] | None:
    """Walk post-entry bars; return (price, date, reason) when the trailing or initial
    stop is hit, else None. The stop only ratchets up: max(init_stop, peak*(1-trail))
    for a long. reason = 'trailing' once it's above the initial stop, else 'stop'."""
    long = direction == "long"
    peak = entry
    for b in bars:
        if long:
            level = max(init_stop, peak * (1 - trail_frac))
            if b.low <= level:
                return level, b.date, ("trailing" if level > init_stop else "stop")
            if b.high > peak:
                peak = b.high
        else:
            level = min(init_stop, peak * (1 + trail_frac))
            if b.high >= level:
                return level, b.date, ("trailing" if level < init_stop else "stop")
            if b.low < peak:
                peak = b.low
    return None


def _atr_series_from_rows(rows: list[tuple], period: int = 14) -> list[float | None]:
    """ATR at each index i, equal to ``compute_atr(rows[: i + 1])["atr"]`` but
    computed in a single O(n) Wilder pass instead of re-smoothing the whole
    prefix per bar. None where there are fewer than ``period + 1`` bars or the
    rounded ATR is non-positive. ``period`` mirrors ``compute_atr``'s default;
    keep them in sync.

    Exactness: ``compute_atr`` keeps its running ATR unrounded through the
    recurrence and rounds only at return, so storing ``round(running, 4)`` at
    each index reproduces its per-prefix value bit-for-bit.
    """
    n = len(rows)
    out: list[float | None] = [None] * n
    if n < period + 1:
        return out
    tr = [0.0] * n
    for i in range(1, n):
        high, low, prev_close = float(rows[i][2]), float(rows[i][3]), float(rows[i - 1][4])
        tr[i] = max(high - low, abs(high - prev_close), abs(low - prev_close))
    running = sum(tr[1 : period + 1]) / period
    rounded = round(running, 4)
    out[period] = rounded if rounded > 0 else None
    for j in range(period + 1, n):
        running = (running * (period - 1) + tr[j]) / period
        rounded = round(running, 4)
        out[j] = rounded if rounded > 0 else None
    return out


def _atr_trailing_level(
    direction: str,
    entry: float,
    init_stop: float,
    atr_multiplier: float,
    rows: list[tuple],
    opened_on: date,
) -> float:
    """Current ATR trailing stop level after all available post-entry closes."""
    long = direction == "long"
    stop = float(init_stop)
    anchor = float(entry)
    atr_by_idx = _atr_series_from_rows(rows)
    for idx, (d, _, _, _, close) in enumerate(rows):
        if d <= opened_on:
            continue
        close = float(close)
        atr = atr_by_idx[idx]
        if long:
            anchor = max(anchor, close)
            if atr is not None:
                next_stop = anchor - atr_multiplier * atr
                if next_stop < close:
                    stop = max(stop, next_stop)
        else:
            anchor = min(anchor, close)
            if atr is not None:
                next_stop = anchor + atr_multiplier * atr
                if next_stop > close:
                    stop = min(stop, next_stop)
    return stop


def _atr_trailing_close(
    direction: str,
    entry: float,
    init_stop: float,
    atr_multiplier: float,
    hold_days: int,
    rows: list[tuple],
    opened_on: date,
) -> tuple[float, date, str] | None:
    """Initial stop + ATR trailing stop + max hold, matching the portfolio sim.

    Stop checks happen before the same day's trailing update, so a newly ratcheted
    stop becomes active on the next bar. Gaps through the stop fill at the open.
    """
    long = direction == "long"
    stop = float(init_stop)
    anchor = float(entry)
    bars_held = 0
    atr_by_idx = _atr_series_from_rows(rows)
    for idx, (d, open_, high, low, close) in enumerate(rows):
        if d <= opened_on:
            continue
        open_ = float(open_)
        high = float(high)
        low = float(low)
        close = float(close)
        bars_held += 1

        if long:
            if low <= stop:
                reason = "trailing" if stop > init_stop + 1e-9 else "stop"
                return min(stop, open_), d, reason
        else:
            if high >= stop:
                reason = "trailing" if stop < init_stop - 1e-9 else "stop"
                return max(stop, open_), d, reason

        if bars_held >= hold_days:
            return close, d, "time"

        atr = atr_by_idx[idx]
        if long:
            anchor = max(anchor, close)
            if atr is not None:
                next_stop = anchor - atr_multiplier * atr
                if next_stop < close:
                    stop = max(stop, next_stop)
        else:
            anchor = min(anchor, close)
            if atr is not None:
                next_stop = anchor + atr_multiplier * atr
                if next_stop > close:
                    stop = min(stop, next_stop)

    return None


async def create_trade(
    db: AsyncSession,
    user_id: int,
    *,
    symbol: str,
    direction: str,
    entry_price: float,
    shares: float,
    stop_loss: float,
    target: float,
) -> PaperTrade:
    direction = direction.strip().lower()
    if direction not in ("long", "short"):
        raise ValidationError("direction must be 'long' or 'short'")
    if shares <= 0 or entry_price <= 0:
        raise ValidationError("shares and entry_price must be positive")

    ticker = await _get_ticker(db, symbol)
    trade = PaperTrade(
        user_id=user_id,
        ticker_id=ticker.id,
        direction=direction,
        entry_price=entry_price,
        shares=shares,
        stop_loss=stop_loss,
        target=target,
        status="open",
        opened_at=datetime.now(timezone.utc),
    )
    db.add(trade)
    await db.commit()
    await db.refresh(trade)
    return trade


def _to_dict(
    trade: PaperTrade,
    symbol: str,
    current_price: float | None,
    benchmark_closes: dict[date, float] | None = None,
    trailing: tuple[float, float | None] | None = None,
) -> dict:
    # For open trades, mark to market; for closed, the realized exit price.
    ref = current_price if trade.status == "open" else trade.close_price

    # Alpha = trade return − benchmark (SPY) return over the same holding period.
    benchmark_return = None
    alpha_pct = None
    alpha_usd = None
    if ref is not None and trade.entry_price and benchmark_closes:
        sign = 1.0 if trade.direction == "long" else -1.0
        trade_return = (ref - trade.entry_price) / trade.entry_price * 100.0 * sign
        as_of = (
            trade.closed_at.date()
            if trade.status == "closed" and trade.closed_at is not None
            else date.today()
        )
        benchmark_return = benchmark_service.benchmark_return_pct(
            benchmark_closes, trade.opened_at.date(), as_of
        )
        if benchmark_return is not None:
            alpha_pct = trade_return - benchmark_return
            alpha_usd = alpha_pct / 100.0 * trade.entry_price * trade.shares

    return {
        "id": trade.id,
        "symbol": symbol,
        "direction": trade.direction,
        "entry_price": trade.entry_price,
        "shares": trade.shares,
        "stop_loss": trade.stop_loss,
        "target": trade.target,
        "status": trade.status,
        "opened_at": trade.opened_at,
        "close_price": trade.close_price,
        "closed_at": trade.closed_at,
        "current_price": ref,
        "benchmark_return_pct": benchmark_return,
        "alpha_pct": alpha_pct,
        "alpha_usd": alpha_usd,
        "close_reason": trade.close_reason,
        "trailing_stop": trailing[0] if trailing else None,
        "trailing_distance_pct": trailing[1] if trailing else None,
    }


async def list_trades(
    db: AsyncSession,
    user_id: int | None = None,
    status: str | None = None,
) -> list[dict]:
    stmt = (
        select(PaperTrade, Ticker.symbol)
        .join(Ticker, PaperTrade.ticker_id == Ticker.id)
    )
    if user_id is not None:  # None → all users (single-user app; used by the digest)
        stmt = stmt.where(PaperTrade.user_id == user_id)
    if status is not None:
        stmt = stmt.where(PaperTrade.status == status)
    stmt = stmt.order_by(PaperTrade.opened_at.desc())

    rows = (await db.execute(stmt)).all()
    open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
    prices = await _latest_closes(db, open_ids)

    # Benchmark closes for alpha — populated by the daily/benchmark job. Empty until
    # that runs once, in which case alpha is simply left unset (a read path never
    # makes a provider call).
    benchmark_closes = await benchmark_service.load_benchmark_closes(db)

    # Current trailing-stop level + distance for open trades (when a trailing
    # policy is active).
    policy = await get_exit_policy(db)
    trailing_info: dict[int, tuple[float, float | None]] = {}
    if policy["mode"] == "trailing":
        trail_frac = policy["trailing_pct"] / 100.0
        for t, _ in rows:
            if t.status != "open":
                continue
            max_high = await _max_high_after(db, t.ticker_id, t.opened_at.date())
            peak = max(t.entry_price, max_high) if max_high is not None else t.entry_price
            long = t.direction == "long"
            level = (
                max(t.stop_loss, peak * (1 - trail_frac))
                if long
                else min(t.stop_loss, peak * (1 + trail_frac))
            )
            cur = prices.get(t.ticker_id)
            dist = None
            if cur:
                dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
            trailing_info[t.id] = (level, dist)
    elif policy["mode"] == "atr_trailing":
        atr_multiplier = float(policy["atr_multiplier"])
        for t, _ in rows:
            if t.status != "open":
                continue
            bars_result = await db.execute(
                select(
                    OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
                    OHLCVRecord.low, OHLCVRecord.close,
                )
                .where(OHLCVRecord.ticker_id == t.ticker_id)
                .order_by(OHLCVRecord.date.asc())
            )
            level = _atr_trailing_level(
                t.direction,
                t.entry_price,
                t.stop_loss,
                atr_multiplier,
                bars_result.all(),
                t.opened_at.date(),
            )
            cur = prices.get(t.ticker_id)
            dist = None
            if cur:
                long = t.direction == "long"
                dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
            trailing_info[t.id] = (level, dist)

    return [
        _to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id))
        for t, sym in rows
    ]


async def close_trade(
    db: AsyncSession,
    user_id: int,
    trade_id: int,
    close_price: float | None = None,
) -> PaperTrade:
    result = await db.execute(
        select(PaperTrade).where(
            PaperTrade.id == trade_id,
            PaperTrade.user_id == user_id,
        )
    )
    trade = result.scalar_one_or_none()
    if trade is None:
        raise NotFoundError(f"Paper trade not found: {trade_id}")
    if trade.status == "closed":
        raise ValidationError("Trade is already closed")

    if close_price is None:
        prices = await _latest_closes(db, {trade.ticker_id})
        close_price = prices.get(trade.ticker_id)
        if close_price is None:
            raise ValidationError("No current price available to close at; supply close_price")

    trade.status = "closed"
    trade.close_price = float(close_price)
    trade.close_reason = "manual"
    trade.closed_at = datetime.now(timezone.utc)
    await db.commit()
    await db.refresh(trade)
    return trade


async def resolve_open_trades(db: AsyncSession) -> int:
    """Auto-close open trades per the active exit policy, from the daily bars.

    Walks the bars after each trade's open. 'atr_trailing' closes at the initial
    stop, a 3x-ATR-style trailing stop, or the hold_days-th close; 'time' closes
    at the initial stop or the hold_days-th close; 'trailing' uses the legacy
    percent trail; 'target' uses the setup's target or stop. Trades that have hit
    nothing stay open. Returns the count closed.
    """
    result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
    open_trades = list(result.scalars().all())
    if not open_trades:
        return 0

    policy = await get_exit_policy(db)
    mode = policy["mode"]
    trail_frac = policy["trailing_pct"] / 100.0
    atr_multiplier = float(policy["atr_multiplier"])
    hold_days = policy["hold_days"]

    closed = 0
    for trade in open_trades:
        bars_result = await db.execute(
            select(
                OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
                OHLCVRecord.low, OHLCVRecord.close,
            )
            .where(
                OHLCVRecord.ticker_id == trade.ticker_id,
                OHLCVRecord.date > trade.opened_at.date(),
            )
            .order_by(OHLCVRecord.date.asc())
        )
        post_rows = bars_result.all()
        bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in post_rows]
        if not bars:
            continue

        if mode == "time":
            hit = _time_close(trade.direction, trade.stop_loss, hold_days, post_rows)
            if hit is None:
                continue  # neither the stop nor the hold horizon reached yet
            close_price, close_date, reason = hit
        elif mode == "trailing":
            hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
            if hit is None:
                continue  # neither the trailing nor the initial stop reached yet
            close_price, close_date, reason = hit
        elif mode == "atr_trailing":
            all_bars_result = await db.execute(
                select(
                    OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
                    OHLCVRecord.low, OHLCVRecord.close,
                )
                .where(OHLCVRecord.ticker_id == trade.ticker_id)
                .order_by(OHLCVRecord.date.asc())
            )
            hit = _atr_trailing_close(
                trade.direction,
                trade.entry_price,
                trade.stop_loss,
                atr_multiplier,
                hold_days,
                all_bars_result.all(),
                trade.opened_at.date(),
            )
            if hit is None:
                continue
            close_price, close_date, reason = hit
        else:
            # max_bars beyond the data so a still-open trade returns undecided (not "expired").
            outcome, outcome_date = evaluate_setup_against_bars(
                trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
            )
            if outcome == OUTCOME_TARGET_HIT:
                close_price, close_date, reason = trade.target, outcome_date, "target"
            elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
                close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
            else:
                continue

        trade.status = "closed"
        trade.close_price = float(close_price)
        trade.close_reason = reason
        trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
        closed += 1

    if closed:
        await db.commit()
    return closed


# ---------------------------------------------------------------------------
# Equity curve — the paper book's cumulative P&L vs the same dollars in SPY.


def _value_on_or_before(
    dates_sorted: list[date], closes: dict[date, float], target: date
) -> float | None:
    """Close on the nearest trading day at or before ``target`` (None if before history)."""
    idx = bisect.bisect_right(dates_sorted, target) - 1
    return closes[dates_sorted[idx]] if idx >= 0 else None


def build_equity_curve(
    trades: list,
    ticker_closes: dict[int, dict[date, float]],
    benchmark_closes: dict[date, float],
) -> list[dict]:
    """Daily cumulative P&L of the paper book vs a benchmark counterfactual.

    For every benchmark trading day since the first trade opened:

      book_pnl       = Σ realized P&L of trades closed by then
                       + Σ mark-to-market P&L of trades still open (ticker close
                         on/before that day)
      benchmark_pnl  = Σ per trade: the SAME cost basis (entry x shares) riding
                       the benchmark over the SAME window (open → close/now).
                       Long-benchmark regardless of trade direction — the
                       question is "what if this money had just sat in SPY".

    Pure function so the math is unit-testable; trades are duck-typed
    (ticker_id, direction, entry_price, shares, status, opened_at, closed_at,
    close_price). Trades opened before the stored benchmark history contribute
    to book_pnl but not to benchmark_pnl (no baseline close to measure from).
    """
    if not trades or not benchmark_closes:
        return []
    first = min(t.opened_at.date() for t in trades)
    bench_dates = sorted(benchmark_closes)
    days = [d for d in bench_dates if d >= first]
    if not days:
        return []
    ticker_dates_sorted = {tid: sorted(c) for tid, c in ticker_closes.items()}

    out: list[dict] = []
    for d in days:
        book = 0.0
        bench = 0.0
        any_priced = False
        for t in trades:
            opened = t.opened_at.date()
            if opened > d:
                continue
            closed_on = (
                t.closed_at.date()
                if (t.status == "closed" and t.closed_at is not None)
                else None
            )
            window_end = min(d, closed_on) if closed_on is not None else d

            if closed_on is not None and closed_on <= d and t.close_price is not None:
                ref = float(t.close_price)
            else:
                closes = ticker_closes.get(t.ticker_id) or {}
                ref_val = _value_on_or_before(
                    ticker_dates_sorted.get(t.ticker_id) or [], closes, d
                )
                if ref_val is None:
                    continue
                ref = ref_val
            per_share = (
                ref - t.entry_price if t.direction == "long" else t.entry_price - ref
            )
            book += per_share * t.shares
            any_priced = True

            s0 = _value_on_or_before(bench_dates, benchmark_closes, opened)
            s1 = _value_on_or_before(bench_dates, benchmark_closes, window_end)
            if s0 and s1:
                bench += (t.entry_price * t.shares) * (s1 - s0) / s0
        if any_priced:
            out.append(
                {
                    "date": d.isoformat(),
                    "book_pnl": round(book, 2),
                    "benchmark_pnl": round(bench, 2),
                }
            )
    return out


async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
    """Equity-curve series for a user's paper book (empty without benchmark data)."""
    trades = (
        (await db.execute(select(PaperTrade).where(PaperTrade.user_id == user_id)))
        .scalars()
        .all()
    )
    if not trades:
        return []
    benchmark_closes = await benchmark_service.load_benchmark_closes(db)
    if not benchmark_closes:
        return []
    first = min(t.opened_at.date() for t in trades)
    ticker_ids = {t.ticker_id for t in trades}
    rows = await db.execute(
        select(OHLCVRecord.ticker_id, OHLCVRecord.date, OHLCVRecord.close).where(
            OHLCVRecord.ticker_id.in_(ticker_ids),
            OHLCVRecord.date >= first,
        )
    )
    ticker_closes: dict[int, dict[date, float]] = {}
    for tid, day, close in rows.all():
        ticker_closes.setdefault(tid, {})[day] = float(close)
    return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
