updraft / qt3 /performance.py
s-ttp's picture
Live chart: rebuild closes Alpaca hasn't posted, live point = account value, adaptive axis
37f39a7 verified
Raw History Blame Contribute Delete
6.91 kB
"""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]}}