File size: 6,913 Bytes
8378e52 37f39a7 8378e52 074b8e3 37f39a7 8378e52 37f39a7 8378e52 37f39a7 8378e52 37f39a7 8378e52 37f39a7 8378e52 074b8e3 8378e52 37f39a7 074b8e3 37f39a7 8378e52 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | """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]}}
|