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]}}