Files
signal-platform/app/services/paper_trade_service.py
T
dennisthiessenandClaude Fable 5 06fdd92faa
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m9s
Deploy / deploy (push) Successful in 36s
Overview: focus|radar pairing, selectable radar, performance chart
Layout regrouped by relationship, not size: the setup-in-focus card
and the radar sit side by side (they are one decision surface), the
four account ribbons move directly above the open positions they
describe, and a new performance chart closes the page.

- Radar rows are selectable: clicking one swaps the focus card to that
  setup - including below-gate rows, whose card shows a muted
  "rank N / below gate" badge and the disqualify reason in the footer,
  with a "back to top pick" reset. The row currently in focus is
  highlighted; ticker links still deep-link without selecting.
- Performance chart (the mockup's missing piece): new
  GET /paper-trades/equity-curve computes, per benchmark trading day
  since the first paper trade, the book's cumulative P&L (realized +
  mark-to-market from stored OHLCV) vs the same cost basis riding SPY
  over each trade's window (benchmark_prices). Pure curve math in
  paper_trade_service with unit tests; hidden until there are 2+
  points of data. Frontend renders both lines with crosshair readout,
  zero baseline, and direct end labels.

Backend unit suite: 501 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:09:31 +02:00

708 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)