"""Live performance from the Alpaca account: time-weighted return (deposits / withdrawals removed), drawdown, and the S&P 500 (SPY) over the same days. Each point is tagged: "alpaca" (a daily close from the account's own history), "rebuilt" (a trading day Alpaca's paper history has not posted yet — the account now, rolled back through every later fill and deposit, valued at that day's closing prices) or "live" (today's equity from the account).""" from __future__ import annotations from datetime import date, datetime, timedelta, timezone from typing import Optional from zoneinfo import ZoneInfo from . import config from .broker import Alpaca def twr(dates: list[str], equity: list[float], flows: dict[str, float]) -> list[float]: """Growth of $1 (time-weighted): each day's return excludes that day's net deposit.""" out, g = [], 1.0 for i, (d, e) in enumerate(zip(dates, equity)): if i > 0 and equity[i - 1] > 0: g *= (e - flows.get(d, 0.0)) / equity[i - 1] out.append(g) return out def bench_value(equity: list[float], spy_px: list, dates: list[str], flows: dict[str, float]) -> list: """Dollar value of the S&P 500 holding the same money: starts at the account's first value and receives the same deposits / withdrawals on the same days.""" out, v, prev = [], None, None for e, px, d in zip(equity, spy_px, dates): if v is None: v = e elif px and prev: v = v * px / prev + flows.get(d, 0.0) out.append(round(v, 2) if v is not None else None) prev = px or prev return out def rebuild(days: list[str], cash_now: float, qty_now: dict[str, float], fills: list[dict], flows: dict[str, float], closes: dict[str, dict[str, float]]) -> dict[str, float]: """Close-of-day equity for days missing from Alpaca's history. Holdings and cash on day d are today's minus every fill and deposit dated after d; they are valued at d's closing prices. A day with a held stock lacking a close is skipped.""" out = {} for d in days: qty, cash = dict(qty_now), cash_now - sum(v for k, v in flows.items() if k > d) for f in fills: if f["date"] > d: sgn = 1 if f["side"] == "buy" else -1 qty[f["symbol"]] = qty.get(f["symbol"], 0.0) - sgn * f["qty"] cash += sgn * f["qty"] * f["price"] held = {s: q for s, q in qty.items() if abs(q) > 1e-9} if all(d in closes.get(s, {}) for s in held): out[d] = round(cash + sum(q * closes[s][d] for s, q in held.items()), 2) return out def _fills(a: Alpaca, since: str, ny: ZoneInfo) -> list[dict]: after = datetime.fromisoformat(since).replace(tzinfo=ny) - timedelta(days=7) out = [] for o in a.orders(after=after, status="all"): q, px, at = float(o.get("filled_qty") or 0), o.get("filled_avg_price"), o.get("filled_at") if q > 0 and px and at: when = datetime.fromisoformat(at.replace("Z", "+00:00")).astimezone(ny).date().isoformat() out.append({"date": when, "symbol": o["symbol"], "side": o["side"], "qty": q, "price": float(px)}) return out def max_drawdown(g: list[float]) -> float: peak, worst = 0.0, 0.0 for x in g: peak = max(peak, x) worst = min(worst, x / peak - 1 if peak else 0.0) return worst def series(a: Alpaca, inception: Optional[str] = None) -> dict: ny = ZoneInfo("America/New_York") h = a.portfolio_history() hist = {} for t, e in zip(h.get("timestamp") or [], h.get("equity") or []): if e is not None and float(e) > 0: hist[datetime.fromtimestamp(t, ny).date().isoformat()] = float(e) start = inception or config.INCEPTION if not start: first = [o for o in a.orders(status="all") if str(o.get("client_order_id", "")).startswith(config.TAG + "-")] start = min((o["created_at"][:10] for o in first), default=None) now = datetime.now(ny) today = now.date().isoformat() pts = sorted((d, e, "alpaca") for d, e in hist.items() if d < today) # today comes from the live account if start: # base = the last close before the first trade (all cash) before = [p for p in pts if p[0] < start] pts = before[-1:] + [p for p in pts if p[0] >= start] acct = a.account() flows = {} for f in a.cash_flows(): flows[f["date"]] = flows.get(f["date"], 0.0) + f["amount"] first_day = pts[0][0] if pts else (start or today) bars = {b["t"]: b["c"] for b in a.daily_bars("SPY", date.fromisoformat(first_day) - timedelta(days=7))} have = {p[0] for p in pts} gaps = [d for d in sorted(bars) if pts and pts[0][0] < d < today and d not in have] if gaps: # Alpaca's paper history can lag a day or more behind pos, fills = a.positions(), _fills(a, gaps[0], ny) syms = set(pos) | {f["symbol"] for f in fills if f["date"] > gaps[0]} closes = {s: {b["t"]: b["c"] for b in a.daily_bars(s, date.fromisoformat(gaps[0]), adjustment="raw")} for s in syms} got = rebuild(gaps, acct["cash"], {s: p["qty"] for s, p in pos.items()}, fills, flows, closes) pts = sorted(pts + [(d, e, "rebuilt") for d, e in got.items()]) if acct["equity"] > 0 and now.weekday() < 5: pts.append((today, acct["equity"], "live")) if len(pts) < 1: return {"start": start, "dates": [], "strategy": [], "spy": [], "stats": {}} dates, eq, src = [p[0] for p in pts], [p[1] for p in pts], [p[2] for p in pts] g = twr(dates, eq, flows) spy_now = a.latest_prices(["SPY"]).get("SPY") if spy_now and dates[-1] == today: bars[today] = spy_now closes = sorted(bars.items()) # latest S&P close on or before each date spy, j, last = [], 0, None for d in dates: while j < len(closes) and closes[j][0] <= d: last = closes[j][1] j += 1 spy.append(last) base = next((x for x in spy if x), None) bench = bench_value(eq, spy, dates, flows) # the same money (and deposits) in the S&P spy = [round(x / base, 6) if (x and base) else None for x in spy] days = (date.fromisoformat(dates[-1]) - date.fromisoformat(dates[0])).days tot = g[-1] - 1 spy_tot = (spy[-1] - 1) if spy and spy[-1] else None ann = (g[-1] ** (365.25 / days) - 1) if days >= 365 else None return {"start": start or dates[0], "base": dates[0], "dates": dates, "src": src, "value": [round(x, 2) for x in eq], "bench_value": bench, "strategy": [round(x, 6) for x in g], "spy": spy, "flows_today": flows.get(today, 0.0), "stats": {"return": tot, "spy_return": spy_tot, "annualised": ann, "max_drawdown": max_drawdown(g), "days": days, "equity": eq[-1]}}