diff --git a/plugins/tastytrade/.claude-plugin/plugin.json b/plugins/tastytrade/.claude-plugin/plugin.json index e61d491..1e8a1ad 100644 --- a/plugins/tastytrade/.claude-plugin/plugin.json +++ b/plugins/tastytrade/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tastytrade", - "version": "0.3.0", + "version": "0.4.0", "description": "MCP server for the TastyTrade Open API: brokerage accounts, positions, market data, option chains, transactions, and order preview.", "author": { "name": "Walker Hughes" diff --git a/plugins/tastytrade/Makefile b/plugins/tastytrade/Makefile index 3e08640..ae656d8 100644 --- a/plugins/tastytrade/Makefile +++ b/plugins/tastytrade/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint lint-fix format typecheck check test test-unit test-integration coverage install-hooks validate-tasks mock-api benchmark-build benchmark benchmark-view +.PHONY: lint lint-fix format typecheck check selftest test test-unit test-integration coverage install-hooks validate-tasks mock-api benchmark-build benchmark benchmark-view lint: uv run ruff check . @@ -12,7 +12,12 @@ format: typecheck: uv run mypy src/ -check: lint typecheck test-unit +check: lint typecheck selftest test-unit + +# Skill scripts are shipped payload but live outside src/, so pytest never sees +# them. Each carries its own assertions; this is what runs them. +selftest: + python3 scripts/calendars.py --selftest test: uv run pytest diff --git a/plugins/tastytrade/README.md b/plugins/tastytrade/README.md index 8163c4b..38c68d0 100644 --- a/plugins/tastytrade/README.md +++ b/plugins/tastytrade/README.md @@ -33,6 +33,29 @@ Authentication uses the OAuth2 refresh-token flow with automatic token refresh. `TT_ENABLE_TRADING=true`, **and** each call must pass `confirm=true`. Otherwise the order is not sent and the previewed effect is returned. Always call `preview_order` first. +## Skills + +| Skill | What it does | +|---|---| +| `earnings-calendars` | Analyse an option chain for calendar spreads around an earnings event and rank them by risk-adjusted return. | + +`earnings-calendars` decomposes the vol term structure into base vol plus a one-time event +jump, then prices every candidate calendar against three move regimes using real bid/ask. +The screening rule it enforces is that a calendar only has edge if its profit band is wider +than the implied expected move, which is usually not the case and is the point. + +Its analysis runs in [`scripts/calendars.py`](scripts/calendars.py), which is standard +library only (no numpy) and self-testing: + +```bash +python3 scripts/calendars.py --selftest +python3 scripts/calendars.py fit skills/earnings-calendars/reference/pltr-2026-08-03.json +python3 scripts/calendars.py rank skills/earnings-calendars/reference/pltr-2026-08-03.json +``` + +That reference file is a real PLTR chain captured the afternoon of its 2026-08-03 print, and +doubles as a regression fixture. The skill never places orders; it is analysis only. + ## Architecture The design follows the Honeycomb MCP: a few curated tools, responses shaped for a model rather diff --git a/plugins/tastytrade/scripts/calendars.py b/plugins/tastytrade/scripts/calendars.py new file mode 100644 index 0000000..9551c6f --- /dev/null +++ b/plugins/tastytrade/scripts/calendars.py @@ -0,0 +1,697 @@ +#!/usr/bin/env python3 +"""Earnings calendar-spread analysis. Standard library only. + +Decomposes an option chain's term structure into base vol plus a one-time event +jump, then prices and ranks calendar spreads against that jump. + + calendars.py fit chain.json # term structure -> event jump, E|move| + calendars.py rank chain.json # rank candidate calendars, all regimes + calendars.py scenario chain.json --strikes 140 --front 2026-08-07 \ + --back 2026-09-18 # P&L by move + calendars.py --selftest # assert the pricer and fit still work + +Input JSON (see the skill for how to build it from get_option_chain): + + {"symbol": "PLTR", "spot": 125.64, + "history": [14.0, 6.85, 7.94, 7.85, 12.2, 24.0, 23.5, 10.1], + "chains": {"2026-08-07": {"dte": 4, + "calls": {"140": [2.34, 2.35]}, + "puts": {"115": [2.62, 2.65]}}, ...}} + +`history` is absolute post-earnings moves in percent, most recent first. Quotes +are [bid, ask]. Every number this prints comes from those quotes; nothing is +fetched here. + +ponytail: no numpy. The move distribution is a weighted grid rather than Monte +Carlo, which is exact, reproducible, and fast enough in pure Python. Swap in a +vectorised pricer only if a chain ever needs more than a few thousand strikes. +""" + +import argparse +import json +import math +import sys + +# Annualisation, and the carry rate used to build forwards. Override per-chain +# with a top-level "rate" key; the level barely moves a calendar, since both +# legs discount the same way. +YEAR = 365.0 +DEFAULT_RATE = 0.04 + +# Regimes we always score against. The point is that a calendar's edge is a +# claim about which of these is right, so all three get reported side by side. +REGIMES = ("calm", "implied", "history") + + +# -------------------------------------------------------------------------- +# Black-Scholes on forwards, plus a bisection IV solve. +# -------------------------------------------------------------------------- + + +def norm_cdf(x): + return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) + + +def bs(fwd, strike, t, sigma, kind, rate=DEFAULT_RATE): + """Undiscounted-forward Black-76, discounted back. kind is 'c' or 'p'.""" + if t <= 0: + intrinsic = max(0.0, fwd - strike) if kind == "c" else max(0.0, strike - fwd) + return intrinsic + sigma = max(sigma, 1e-6) + sq = sigma * math.sqrt(t) + d1 = (math.log(fwd / strike) + 0.5 * sigma * sigma * t) / sq + d2 = d1 - sq + disc = math.exp(-rate * t) + if kind == "c": + return disc * (fwd * norm_cdf(d1) - strike * norm_cdf(d2)) + return disc * (strike * norm_cdf(-d2) - fwd * norm_cdf(-d1)) + + +def implied_vol(price, fwd, strike, t, kind, rate=DEFAULT_RATE): + """Bisection. Returns None when the price is outside the arbitrage bounds.""" + lo, hi = 1e-4, 6.0 + if bs(fwd, strike, t, lo, kind, rate) > price: + return None + if bs(fwd, strike, t, hi, kind, rate) < price: + return None + for _ in range(100): + mid = 0.5 * (lo + hi) + if bs(fwd, strike, t, mid, kind, rate) < price: + lo = mid + else: + hi = mid + return 0.5 * (lo + hi) + + +def mid(quote): + return 0.5 * (quote[0] + quote[1]) + + +def half_spread(quote): + return 0.5 * (quote[1] - quote[0]) + + +# -------------------------------------------------------------------------- +# Chain plumbing. +# -------------------------------------------------------------------------- + + +class Chain: + """One parsed input file: quotes, forwards, ATM vols, smile shape.""" + + def __init__(self, blob): + self.symbol = blob.get("symbol", "?") + self.spot = float(blob["spot"]) + self.history = [abs(float(h)) / 100.0 for h in blob.get("history", [])] + self.rate = float(blob.get("rate", DEFAULT_RATE)) + self.exps = {} + for exp, body in blob["chains"].items(): + calls = {float(k): tuple(v) for k, v in body.get("calls", {}).items()} + puts = {float(k): tuple(v) for k, v in body.get("puts", {}).items()} + self.exps[exp] = {"dte": int(body["dte"]), "c": calls, "p": puts} + if not self.exps: + raise SystemExit("no expirations in input") + self.order = sorted(self.exps, key=lambda e: self.exps[e]["dte"]) + self.fwd = {e: self._forward(e) for e in self.order} + self.atm = {e: self._atm_vol(e) for e in self.order} + self.smile = self._smile() + + def t(self, exp, elapsed=0.0): + return max((self.exps[exp]["dte"] - elapsed) / YEAR, 0.0) + + def quote(self, exp, strike, kind=None): + """OTM side by default: puts below spot, calls above.""" + if kind is None: + kind = "p" if strike < self.spot else "c" + return self.exps[exp][kind].get(strike) + + def _forward(self, exp): + """Put-call parity across near-ATM strikes, else spot carried forward.""" + e = self.exps[exp] + t = self.t(exp) + both = sorted(set(e["c"]) & set(e["p"])) + near = [k for k in both if abs(k - self.spot) <= 0.06 * self.spot] + if not near: + return self.spot * math.exp(self.rate * t) + fs = [k + math.exp(self.rate * t) * (mid(e["c"][k]) - mid(e["p"][k])) for k in near] + return sum(fs) / len(fs) + + def _vol_points(self, exp): + """(log-moneyness / sqrt(t), iv) for every OTM strike we can solve.""" + e, t, f = self.exps[exp], self.t(exp), self.fwd[exp] + if t <= 0: + return [] + pts = [] + for kind in ("c", "p"): + for k, q in e[kind].items(): + if (kind == "c" and k < f) or (kind == "p" and k > f): + continue # ITM: wide and uninformative + v = implied_vol(mid(q), f, k, t, kind, self.rate) + if v is not None: + pts.append((math.log(k / f) / math.sqrt(t), v)) + return sorted(pts) + + def _atm_vol(self, exp): + pts = self._vol_points(exp) + if not pts: + return None + return interp(0.0, [p[0] for p in pts], [p[1] for p in pts]) + + def _smile(self): + """Normalised skew from the expiry with the widest strike coverage. + + Picked by coverage, not by tenor: the shape only needs to be + representative, and the event distorts level far more than shape. + """ + pts = [] + for exp in self.order: + p = self._vol_points(exp) + if len(p) > len(pts): + pts = p + if len(pts) < 3: + return ([0.0], [1.0]) + zs = [p[0] for p in pts] + atm = interp(0.0, zs, [p[1] for p in pts]) or pts[0][1] + return (zs, [p[1] / atm for p in pts]) + + def skew(self, z): + return interp(z, self.smile[0], self.smile[1]) + + +def interp(x, xs, ys): + """Linear interp, flat outside the knots.""" + if not xs: + return None + if x <= xs[0]: + return ys[0] + if x >= xs[-1]: + return ys[-1] + for i in range(1, len(xs)): + if x <= xs[i]: + span = xs[i] - xs[i - 1] + if span <= 0: + return ys[i] + w = (x - xs[i - 1]) / span + return ys[i - 1] * (1 - w) + ys[i] * w + return ys[-1] + + +# -------------------------------------------------------------------------- +# Term structure: total_var(T) = base_var(T)*T + J^2 +# -------------------------------------------------------------------------- + + +def fit_term_structure(chain): + """Solve for base vol a + b*ln(T) and a one-time event jump J. + + Two free shape params and a jump, fit to the ATM total-variance curve. + Coarse grid then two refinements: the surface is smooth and tiny, so this + beats hand-rolling an optimiser. + """ + pts = [(chain.t(e), chain.atm[e]) for e in chain.order if chain.atm[e] and chain.t(e) > 0] + if len(pts) < 2: + raise SystemExit("need >= 2 expirations with solvable ATM vols to fit") + tv = [(t, v * v * t) for t, v in pts] + + def sse(a, b): + js = [] + for t, total in tv: + base = a + b * math.log(t) + js.append(total - base * base * t) + j2 = max(0.0, sum(js) / len(js)) + err = 0.0 + for (t, total), _ in zip(tv, js): + base = a + b * math.log(t) + err += (base * base * t + j2 - total) ** 2 + return err, j2 + + lo_a, hi_a, lo_b, hi_b = 0.05, 2.0, -0.35, 0.35 + best = None + for _ in range(3): + step_a = (hi_a - lo_a) / 40.0 + step_b = (hi_b - lo_b) / 40.0 + for i in range(41): + a = lo_a + i * step_a + for j in range(41): + b = lo_b + j * step_b + err, j2 = sse(a, b) + if best is None or err < best[0]: + best = (err, a, b, math.sqrt(j2)) + _, a, b, _ = best + lo_a, hi_a = a - step_a, a + step_a + lo_b, hi_b = b - step_b, b + step_b + _, a, b, jump = best + return a, b, jump + + +def base_vol(a, b, t): + return max(0.05, a + b * math.log(max(t, 1e-6))) + + +def expected_abs_move(jump): + """E|X| for X ~ N(0, jump). The number every band gets compared to.""" + return jump * math.sqrt(2.0 / math.pi) + + +# -------------------------------------------------------------------------- +# Move distribution: a weighted grid, not a simulation. +# -------------------------------------------------------------------------- + + +def move_grid(history, rms, lo=-0.60, hi=0.60, n=481, bw=0.18): + """Sign-symmetrised, kernel-smoothed historical moves rescaled to `rms`. + + Earnings moves are bimodal: the stock rarely sits still. A normal centred on + zero understates the gap-away cases that decide a calendar, so the empirical + shape is kept and only its scale is set. + """ + if not history: + history = [rms] + # Smoothing widens each atom, so shrink the centres by the same factor to + # land on the requested rms rather than above it. + hist_rms = math.sqrt(sum(h * h for h in history) / len(history)) + scale = rms / (hist_rms * math.sqrt(1.0 + bw * bw)) + centres = [s * h * scale for h in history for s in (-1.0, 1.0)] + step = (hi - lo) / (n - 1) + grid = [lo + i * step for i in range(n)] + w = [0.0] * n + for c in centres: + sd = max(bw * abs(c), 0.004) + # Normalise each kernel to unit mass. Without this a wide kernel spans + # more grid points and silently outweighs a narrow one, which tilts the + # whole distribution toward the largest historical moves. + k = [0.0] * n + for i, g in enumerate(grid): + z = (g - c) / sd + if abs(z) < 6.0: + k[i] = math.exp(-0.5 * z * z) + mass = sum(k) + if mass <= 0: + continue + for i, v in enumerate(k): + w[i] += v / mass + total = sum(w) + if total <= 0: + raise SystemExit("degenerate move distribution") + return grid, [x / total for x in w] + + +def regime_rms(chain, jump): + """calm = recent quarters, implied = the chain's own jump, history = all of it.""" + h = chain.history + out = {"implied": jump} + if h: + recent = h[: min(4, len(h))] + out["calm"] = math.sqrt(sum(x * x for x in recent) / len(recent)) + out["history"] = math.sqrt(sum(x * x for x in h) / len(h)) + else: + out["calm"] = jump * 0.75 + out["history"] = jump * 1.15 + return out + + +# -------------------------------------------------------------------------- +# Pricing a calendar after the event. +# -------------------------------------------------------------------------- + + +def leg_value(chain, a, b, strike, exp, spot1, vol_adj, elapsed, kind): + t = chain.t(exp, elapsed) + if t <= 0: + return max(0.0, spot1 - strike) if kind == "c" else max(0.0, strike - spot1) + z = math.log(strike / spot1) / math.sqrt(t) + vol = max(0.05, (base_vol(a, b, t) + vol_adj) * chain.skew(z)) + return bs(spot1 * math.exp(chain.rate * t), strike, t, vol, kind, chain.rate) + + +def calendar_cost(chain, strikes, front, back): + """Entry debit at mid plus a quarter-spread per leg. None if unquotable.""" + debit = slip = 0.0 + for k in strikes: + kind = "p" if k < chain.spot else "c" + qf, qb = chain.quote(front, k, kind), chain.quote(back, k, kind) + if qf is None or qb is None: + return None + debit += mid(qb) - mid(qf) + slip += 0.5 * (half_spread(qf) + half_spread(qb)) + if debit + slip <= 0.05: + return None + return debit + slip, slip + + +def calendar_pnl( + chain, a, b, strikes, front, back, moves, cost, front_adj=0.0, back_adj=0.0, skew_beta=0.0, elapsed=1.0 +): + """P&L per move. skew_beta lifts settled vol on selloffs (spot-vol corr).""" + entry, slip = cost + out = [] + for m in moves: + spot1 = chain.spot * math.exp(m) + bump = skew_beta * max(0.0, -m) + val = 0.0 + for k in strikes: + kind = "p" if k < chain.spot else "c" + val += leg_value(chain, a, b, k, back, spot1, back_adj + bump, elapsed, kind) + val -= leg_value(chain, a, b, k, front, spot1, front_adj + bump, elapsed, kind) + out.append(val - slip - entry) + return out + + +def summarise(pnl, weights, entry): + ev = sum(p * w for p, w in zip(pnl, weights)) + var = sum((p - ev) ** 2 * w for p, w in zip(pnl, weights)) + sd = math.sqrt(max(var, 1e-12)) + pwin = sum(w for p, w in zip(pnl, weights) if p > 0) + wins = [(p, w) for p, w in zip(pnl, weights) if p > 0] + losses = [(p, w) for p, w in zip(pnl, weights) if p <= 0] + aw = sum(p * w for p, w in wins) / sum(w for _, w in wins) if wins else 0.0 + al = sum(p * w for p, w in losses) / sum(w for _, w in losses) if losses else 0.0 + # 5% left tail, walking the grid in P&L order. + order = sorted(zip(pnl, weights)) + acc, tail = 0.0, [] + for p, w in order: + if acc >= 0.05: + break + take = min(w, 0.05 - acc) + tail.append((p, take)) + acc += take + cvar = sum(p * w for p, w in tail) / acc if acc > 0 else 0.0 + return { + "ev": ev, + "sd": sd, + "pwin": pwin, + "roc": ev / entry, + "sharpe": ev / sd, + "avg_win": aw, + "avg_loss": al, + "wl": (aw / abs(al)) if al < 0 else float("inf"), + "cvar5": cvar, + } + + +def profit_band(chain, a, b, strikes, front, back, cost, skew_beta=0.0): + """Contiguous move range where the trade makes money. The screening gate.""" + grid = [-0.50 + i * 0.0025 for i in range(401)] + pnl = calendar_pnl(chain, a, b, strikes, front, back, grid, cost, skew_beta=skew_beta) + pos = [g for g, p in zip(grid, pnl) if p > 0] + if not pos: + return None, max(pnl) + return (min(pos), max(pos)), max(pnl) + + +# -------------------------------------------------------------------------- +# Candidate generation. +# -------------------------------------------------------------------------- + + +def candidates(chain, front, back, max_moneyness=0.14, doubles=True): + """Singles near the money, plus doubles that straddle spot.""" + ks = sorted( + k + for k in set(list(chain.exps[front]["c"]) + list(chain.exps[front]["p"])) + if chain.quote(front, k) and chain.quote(back, k) + ) + out = [[k] for k in ks if abs(k - chain.spot) <= max_moneyness * chain.spot] + if doubles: + for i, k1 in enumerate(ks): + for k2 in ks[i + 1 :]: + if k1 < chain.spot < k2 and 0.04 * chain.spot <= k2 - k1 <= 0.25 * chain.spot: + out.append([k1, k2]) + return out + + +def pairs(chain, max_front_dte=10): + """(front, back): front is the first expiry after the event, backs follow.""" + fronts = [e for e in chain.order if chain.exps[e]["dte"] <= max_front_dte] + if not fronts: + fronts = chain.order[:1] + front = fronts[0] + return [(front, b) for b in chain.order if chain.exps[b]["dte"] > chain.exps[front]["dte"]] + + +# -------------------------------------------------------------------------- +# Commands. +# -------------------------------------------------------------------------- + + +def load(path): + with open(path) as fh: + return Chain(json.load(fh)) + + +def cmd_fit(args): + chain = load(args.chain) + a, b, jump = fit_term_structure(chain) + eam = expected_abs_move(jump) + print(f"{chain.symbol} spot {chain.spot:.2f}") + print(f"\n{'expiry':12}{'dte':>5}{'fwd':>9}{'ATM IV':>9}{'base(de-earnings)':>20}") + for e in chain.order: + v = chain.atm[e] + bv = base_vol(a, b, chain.t(e)) + shown = f"{v * 100:8.1f}%" if v else " na" + print(f"{e:12}{chain.exps[e]['dte']:>5}{chain.fwd[e]:>9.2f}{shown}{bv * 100:>19.1f}%") + print(f"\nbase vol(T) = {a:.4f} {b:+.4f}*ln(T)") + print(f"implied event jump sigma = {jump * 100:.2f}%") + print(f"implied E|move| = {eam * 100:.2f}% <-- compare every profit band to this") + if chain.history: + h = chain.history + rec = h[: min(4, len(h))] + s = sorted(h) + med = s[len(s) // 2] if len(s) % 2 else 0.5 * (s[len(s) // 2 - 1] + s[len(s) // 2]) + print( + f"\nhistorical |move| n={len(h)} median {med * 100:.1f}% " + f"mean {sum(h) / len(h) * 100:.1f}% " + f"rms {math.sqrt(sum(x * x for x in h) / len(h)) * 100:.1f}%" + ) + print(f" most recent {len(rec)}: rms {math.sqrt(sum(x * x for x in rec) / len(rec)) * 100:.1f}%") + rms_all = math.sqrt(sum(x * x for x in h) / len(h)) + verdict = ( + "event vol looks RICH vs history" + if jump > rms_all * 1.15 + else "event vol looks CHEAP vs history" + if jump < rms_all * 0.85 + else "event vol is FAIRLY PRICED vs history (no vol edge to harvest)" + ) + print(f" verdict: {verdict}") + + +def cmd_rank(args): + chain = load(args.chain) + a, b, jump = fit_term_structure(chain) + eam = expected_abs_move(jump) + rms = regime_rms(chain, jump) + grids = {r: move_grid(chain.history, rms[r]) for r in REGIMES} + + rows = [] + for front, back in pairs(chain): + for ks in candidates(chain, front, back, doubles=not args.no_doubles): + cost = calendar_cost(chain, ks, front, back) + if cost is None: + continue + band, peak = profit_band(chain, a, b, ks, front, back, cost, args.skew_beta) + stats = {} + for r in REGIMES: + g, w = grids[r] + pnl = calendar_pnl(chain, a, b, ks, front, back, g, cost, skew_beta=args.skew_beta) + stats[r] = summarise(pnl, w, cost[0]) + rows.append( + { + "ks": ks, + "front": front, + "back": back, + "entry": cost[0], + "slip": 2 * cost[1], + "band": band, + "peak": peak, + "stats": stats, + "mean_sharpe": sum(stats[r]["sharpe"] for r in REGIMES) / len(REGIMES), + } + ) + if not rows: + raise SystemExit("no quotable calendars; check that strikes overlap between expiries") + rows.sort(key=lambda r: -r["mean_sharpe"]) + + print(f"{chain.symbol} spot {chain.spot:.2f} event jump {jump * 100:.1f}% implied E|move| {eam * 100:.1f}%") + print("regime rms: " + " ".join(f"{r}={rms[r] * 100:.1f}%" for r in REGIMES)) + print( + f"\n{'strikes':14}{'cycle':22}{'entry':>7}{'slip':>6}{'band':>17}{'wide?':>7}" + + "".join(f"{'ROC ' + r[:4]:>10}" for r in REGIMES) + + f"{'meanSh':>8}{'W/L':>6}" + ) + for r in rows[: args.top]: + lbl = "/".join(f"{k:g}" for k in r["ks"]) + cyc = f"{r['front'][5:]}-{r['back'][5:]}" + if r["band"]: + band = f"[{r['band'][0] * 100:+.0f}%,{r['band'][1] * 100:+.0f}%]" + wide = "yes" if min(abs(r["band"][0]), abs(r["band"][1])) >= eam else "NO" + else: + band, wide = "none", "NO" + print( + f"{lbl:14}{cyc:22}{r['entry']:>7.2f}{r['slip']:>6.2f}{band:>17}{wide:>7}" + + "".join(f"{r['stats'][x]['roc'] * 100:>9.1f}%" for x in REGIMES) + + f"{r['mean_sharpe']:>8.3f}{r['stats']['implied']['wl']:>6.2f}" + ) + print("\n'wide?' = does the profit band cover the implied E|move| on both sides.") + print("A NO means the trade needs a below-consensus move to pay. Check W/L too:") + print("a high win rate with W/L well under 1 is the classic double-calendar trap.") + pos = [r for r in rows if r["stats"]["implied"]["roc"] > 0] + print(f"\n{len(pos)} of {len(rows)} structures are positive-EV under the implied distribution.") + + +def cmd_scenario(args): + chain = load(args.chain) + a, b, jump = fit_term_structure(chain) + ks = [float(x) for x in args.strikes.split(",")] + cost = calendar_cost(chain, ks, args.front, args.back) + if cost is None: + raise SystemExit("that structure is not quotable in the input") + entry, slip = cost + lbl = "/".join(f"{k:g}" for k in ks) + print(f"{chain.symbol} {lbl} calendar short {args.front} long {args.back}") + print(f"entry {entry:.2f} (incl {slip:.2f} slip) spot {chain.spot:.2f}\n") + + betas = [0.0, args.skew_beta] if args.skew_beta else [0.0] + moves = [x / 100.0 for x in (0, -2, -5, -7, -10, -12, -15, -20, -25, 2, 5, 7, 10, 12, 15, 20, 25)] + moves = sorted(set(moves)) + print("P&L by realised move (next-day exit), by spot-vol beta k:") + print(f"{'move':>7}{'spot':>9}" + "".join(f"{'k=' + str(k):>10}" for k in betas) + f"{' %debit':>10}") + for m in moves: + row = [calendar_pnl(chain, a, b, ks, args.front, args.back, [m], cost, skew_beta=k)[0] for k in betas] + print( + f"{m * 100:>6.0f}%{chain.spot * math.exp(m):>9.2f}" + + "".join(f"{v:>10.2f}" for v in row) + + f"{row[-1] / entry * 100:>9.0f}%" + ) + + for k in betas: + band, peak = profit_band(chain, a, b, ks, args.front, args.back, cost, k) + if band: + print( + f"\nk={k}: profit band [{band[0] * 100:+.1f}%, {band[1] * 100:+.1f}%] " + f"(spot {chain.spot * math.exp(band[0]):.2f} to " + f"{chain.spot * math.exp(band[1]):.2f}) peak +{peak:.2f}" + ) + else: + print(f"\nk={k}: no profitable move. peak {peak:+.2f}") + + # Directional fragility: an asymmetric band is a direction bet in disguise. + rms = regime_rms(chain, jump) + g, w = move_grid(chain.history, rms["implied"]) + for k in betas: + pnl = calendar_pnl(chain, a, b, ks, args.front, args.back, g, cost, skew_beta=k) + dn = [(p, wt) for p, wt, m in zip(pnl, w, g) if m < 0] + up = [(p, wt) for p, wt, m in zip(pnl, w, g) if m >= 0] + wd, wu = sum(x[1] for x in dn), sum(x[1] for x in up) + ed = sum(p * x for p, x in dn) / wd if wd else 0.0 + eu = sum(p * x for p, x in up) / wu if wu else 0.0 + ev = sum(p * x for p, x in zip(pnl, w)) + thresh = eu / (eu - ed) if eu > 0 > ed else None + line = f"\nk={k}: E[P&L|down] {ed:+.2f} E[P&L|up] {eu:+.2f} EV {ev:+.2f} ({ev / entry * 100:+.1f}%)" + if thresh is not None: + line += f"\n flips negative-EV once P(down) exceeds {thresh * 100:.0f}%" + print(line) + + +# -------------------------------------------------------------------------- +# Self-test. +# -------------------------------------------------------------------------- + + +def selftest(): + # Pricer round-trips through the IV solver. + f, k, t, v = 100.0, 100.0, 0.25, 0.40 + px = bs(f, k, t, v, "c") + got = implied_vol(px, f, k, t, "c") + assert abs(got - v) < 1e-4, got + + # Put-call parity holds on the pricer. + c = bs(105.0, 100.0, 0.5, 0.3, "c") + p = bs(105.0, 100.0, 0.5, 0.3, "p") + assert abs((c - p) - math.exp(-DEFAULT_RATE * 0.5) * 5.0) < 1e-8, (c, p) + + # A synthetic chain built from a known (flat base vol, jump) is recovered. + spot, a0, j0 = 100.0, 0.50, 0.12 + chains = {} + for name, dte in (("2026-01-05", 4), ("2026-01-12", 11), ("2026-01-30", 29), ("2026-02-20", 50)): + t = dte / YEAR + tot = a0 * a0 * t + j0 * j0 + sig = math.sqrt(tot / t) + fwd = spot * math.exp(DEFAULT_RATE * t) + calls, puts = {}, {} + for strike in range(80, 126, 5): + calls[str(strike)] = _tight(bs(fwd, strike, t, sig, "c")) + puts[str(strike)] = _tight(bs(fwd, strike, t, sig, "p")) + chains[name] = {"dte": dte, "calls": calls, "puts": puts} + chain = Chain({"symbol": "TEST", "spot": spot, "history": [10, 12, 8, 14], "chains": chains}) + a, b, jump = fit_term_structure(chain) + assert abs(jump - j0) < 0.01, f"jump {jump} != {j0}" + assert abs(base_vol(a, b, 20 / YEAR) - a0) < 0.03, base_vol(a, b, 20 / YEAR) + + # Flat smile in, flat skew out. + assert abs(chain.skew(0.0) - 1.0) < 0.02, chain.skew(0.0) + + # Move grid: weights normalise and rms is hit. + g, w = move_grid([10, 12, 8, 14], 0.13) + assert abs(sum(w) - 1.0) < 1e-9 + rms = math.sqrt(sum(x * x * wt for x, wt in zip(g, w))) + assert abs(rms - 0.13) < 0.003, rms + + # A calendar is worth more at its strike than far away from it. + cost = calendar_cost(chain, [100.0], "2026-01-05", "2026-01-30") + assert cost is not None + at, away = calendar_pnl(chain, a, b, [100.0], "2026-01-05", "2026-01-30", [0.0, 0.35], cost) + assert at > away, (at, away) + + # Band is finite and brackets zero for an ATM calendar. + band, peak = profit_band(chain, a, b, [100.0], "2026-01-05", "2026-01-30", cost) + assert band and band[0] < 0 < band[1], band + assert peak > 0 + + # Summary stats: EV of a constant payoff is that constant. + s = summarise([2.0, 2.0], [0.5, 0.5], 1.0) + assert abs(s["ev"] - 2.0) < 1e-9 and abs(s["pwin"] - 1.0) < 1e-9 + + print("selftest ok") + + +def _tight(px): + """A synthetic 2c-wide quote around a theoretical price.""" + return [round(max(px - 0.01, 0.01), 4), round(max(px + 0.01, 0.02), 4)] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--selftest", action="store_true") + sub = ap.add_subparsers(dest="cmd") + + f = sub.add_parser("fit", help="term structure -> base vol + event jump") + f.add_argument("chain") + f.set_defaults(fn=cmd_fit) + + r = sub.add_parser("rank", help="rank calendars across move regimes") + r.add_argument("chain") + r.add_argument("--top", type=int, default=12) + r.add_argument("--no-doubles", action="store_true") + r.add_argument("--skew-beta", type=float, default=0.0, help="lift settled vol by beta*|move| on selloffs (try 0.6)") + r.set_defaults(fn=cmd_rank) + + s = sub.add_parser("scenario", help="P&L by move for one structure") + s.add_argument("chain") + s.add_argument("--strikes", required=True, help="e.g. 140 or 115,140") + s.add_argument("--front", required=True) + s.add_argument("--back", required=True) + s.add_argument("--skew-beta", type=float, default=0.6) + s.set_defaults(fn=cmd_scenario) + + args = ap.parse_args() + if args.selftest: + selftest() + return + if not getattr(args, "fn", None): + ap.print_help() + sys.exit(1) + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/plugins/tastytrade/skills/earnings-calendars/SKILL.md b/plugins/tastytrade/skills/earnings-calendars/SKILL.md new file mode 100644 index 0000000..e7acfb9 --- /dev/null +++ b/plugins/tastytrade/skills/earnings-calendars/SKILL.md @@ -0,0 +1,162 @@ +--- +name: earnings-calendars +description: Analyse an option chain for calendar-spread opportunities around an earnings event, and rank the candidates by risk-adjusted return. Decomposes the vol term structure into base vol plus an event jump, prices every calendar against three move regimes with real bid/ask, and screens on whether the profit band covers the implied move. Use when the user asks about calendar or double-calendar spreads into earnings, whether an earnings vol crush is worth selling, how a name's implied move compares to its history, or wants an earnings options chain analysed or ranked. +--- + +# Earnings calendars + +A calendar into earnings is one bet: **that the stock moves less than the options +say it will.** The IV crush is real and usually large, but it is only edge if the +profit band is wider than the implied move. Most of the time it isn't, and the +job here is to find that out before recommending anything. + +`SCRIPT` below means `${CLAUDE_PLUGIN_ROOT}/scripts/calendars.py`. It is stdlib +only, so `python3 SCRIPT` works with no install. Run `python3 SCRIPT --selftest` +if anything looks wrong. + +## 1. Confirm the event + +Get the date **and** whether it is before or after the close. Do not trust +`get_market_data`'s `next_earnings` field: it has returned dates a year stale. +Verify with a web search for the company's own earnings announcement. + +The front expiry must be the first one that expires *after* the report. If the +report is Monday after the close, the Friday weekly is the front. + +## 2. Pull the chain + +``` +get_option_chain(symbol) # expirations first +get_option_chain(symbol, expiration=..., strikes_near=25) # then each cycle +``` + +Pull the front plus **three or four** back cycles. You need the far ones even if +you would never trade them: the term-structure fit needs the long end to separate +base vol from the event jump. + +Two things to expect: + +- **`iv` comes back null.** Fine. The script solves IVs itself from mids, with + the forward backed out of put-call parity. Never report an IV you didn't solve. +- **Default strike windows are too narrow.** Ask for `strikes_near=25` or more. + You need strikes out to roughly ±1.5x the implied move to test double + calendars and to fit the smile. + +Pull calls and puts. The script uses the OTM side of each strike automatically. + +## 3. Build the input file + +One JSON file, written to the scratchpad: + +```json +{"symbol": "PLTR", "spot": 125.64, + "history": [14.0, 6.85, 7.94, 7.85, 12.2, 24.0, 23.5, 10.1], + "chains": {"2026-08-07": {"dte": 4, + "calls": {"140": [2.34, 2.35]}, + "puts": {"115": [2.62, 2.65]}}}} +``` + +Quotes are `[bid, ask]`, never mids. Spreads decide this analysis: deep-ITM +strikes look cheapest at mid and are often quoted 50c wide. + +`history` is absolute post-earnings moves in percent, most recent first, ideally +8 quarters. Search for them. If you genuinely cannot find them, say so in the +writeup, because the "calm" and "history" regimes become guesses without it. + +`reference/pltr-2026-08-03.json` is a complete worked example. + +## 4. Fit, and apply the gate + +```bash +python3 SCRIPT fit chain.json +``` + +Gives the term structure, the fitted event jump, and **implied E|move|**, which +is the number everything else is compared to. It also prints whether the event +looks rich, cheap, or fairly priced against the name's own history. + +**The gate: if no structure's profit band covers the implied E|move| on both +sides, there is no vol edge and you should say so plainly.** A steeply inverted +term structure and a 90th-percentile IV rank are not edge. They are the market +correctly pricing a large event. This is the single most common way to talk +yourself into a bad calendar. + +Note the fit's limit: `b` (base-vol slope) and the jump `J` trade off against +each other, so the split is weakly identified. E|move| is robust to about 0.3pp; +don't quote the jump to more precision than that. + +## 5. Rank + +```bash +python3 SCRIPT rank chain.json --skew-beta 0.6 +``` + +Every candidate is scored against three move regimes: `calm` (the last four +quarters), `implied` (the chain's own jump), and `history` (all quarters given). +A structure that only works under `calm` is a bet that the name's moves have +permanently compressed. That may be true, but it is a view, not an edge, and it +belongs in the writeup as one. + +Reading the output: + +- **`wide?`** is the gate from step 4, per structure. +- **`W/L`** is average win over average loss. Read it before win rate. A 66% win + rate with W/L 0.46 loses money, and that combination is the standard double + calendar. +- **`ROC impl`** is the honest number. `ROC calm` is the optimistic case. +- The footer counts how many structures are positive-EV under the implied + distribution. When that is zero, lead with it. + +`--skew-beta` lifts settled vol on selloffs, which is real for high-beta names +and cushions the downside. 0.6 is a reasonable default, 0 is the conservative +case. Report which you used. + +## 6. Stress the finalist, especially against it + +```bash +python3 SCRIPT scenario chain.json --strikes 140 \ + --front 2026-08-07 --back 2026-09-18 +``` + +P&L across the move range, the profit band, and the directional-fragility check. + +**Always run the adverse direction.** A calendar whose band is asymmetric (say +-1% to +24%) is a direction bet wearing a vol-trade costume. The command prints +the P(down) at which it flips negative-EV. If that threshold is near 50%, the +structure has no cushion, and if the name has a recent directional pattern in its +earnings reactions, say so and name the threshold. + +Also check `--strikes` for the exit assumption. Next-day exit beats holding to +front expiry in essentially every down scenario, so recommend exiting the morning +after unless something says otherwise. + +## What the numbers have taught + +Carry these into the writeup rather than rediscovering them: + +- **Band versus E|move| first.** Everything else is secondary. +- **Doubles are usually the wrong shape.** A double is roughly twice the short + gamma of a single for similar capital. In the tails both wings lose: the stock + blows through one strike and runs away from the other. On PLTR, 0 of 38 double + configs were positive-EV. Prefer a single unless the implied move is small + relative to the strike spacing. +- **Extending the back leg barely widens the band** (~0.2 to 0.7pp going from 25 + to 46 DTE). The band is set by the extrinsic *ratio* between the legs, not + absolute time. What the further month buys is flatter regime sensitivity and + better fills. Prefer a standard monthly over a nearby weekly for the long leg. +- **Price with real spreads.** Quarter-spread per leg per side. This flips + rankings and kills ITM strikes that look cheap at mid. +- **Anchor post-crush IV to settled IV**, not to a generous haircut off the + pre-event level. + +## Reporting + +Lead with the verdict, then the evidence. Give the ranking the user asked for +even when the whole set is unattractive, and say plainly that it is. State which +`--skew-beta` and which regime each number came from. Quote the profit band and +E|move| together so the comparison is visible. + +Close with the standing caveats: this is chain analysis rather than investment +advice, you are not a licensed advisor, and you have not placed or previewed any +orders. **Never place an order from this skill.** Order placement is a separate, +explicitly gated action, and analysis is never authorisation for it. diff --git a/plugins/tastytrade/skills/earnings-calendars/reference/pltr-2026-08-03.json b/plugins/tastytrade/skills/earnings-calendars/reference/pltr-2026-08-03.json new file mode 100644 index 0000000..d498972 --- /dev/null +++ b/plugins/tastytrade/skills/earnings-calendars/reference/pltr-2026-08-03.json @@ -0,0 +1,90 @@ +{ + "_comment": "Worked example and regression fixture. PLTR quotes captured ~13:40 ET on 2026-08-03, the afternoon of its Q2 print. Used by the skill's walkthrough; `fit` on this file should return an event jump near 12.8% and E|move| near 10.2%.", + "symbol": "PLTR", + "spot": 125.64, + "asof": "2026-08-03", + "history": [14.0, 6.85, 7.94, 7.85, 12.2, 24.0, 23.5, 10.1], + "chains": { + "2026-08-07": { + "dte": 4, + "calls": { + "121": [9.45, 9.55], "122": [8.90, 9.00], "123": [8.40, 8.45], + "124": [7.85, 7.95], "125": [7.40, 7.45], "126": [6.90, 7.00], + "127": [6.45, 6.55], "128": [6.00, 6.10], "129": [5.60, 5.70], + "130": [5.25, 5.30], "131": [4.85, 4.95], "132": [4.50, 4.60], + "133": [4.15, 4.25], "134": [3.85, 3.95], "135": [3.55, 3.60], + "136": [3.25, 3.35], "137": [3.00, 3.10], "138": [2.76, 2.79], + "139": [2.53, 2.56], "140": [2.34, 2.35], "141": [2.12, 2.15] + }, + "puts": { + "105": [0.71, 0.73], "110": [1.42, 1.44], "113": [2.07, 2.10], + "115": [2.62, 2.65], "117": [3.20, 3.35], "118": [3.55, 3.70], + "119": [3.95, 4.05], "120": [4.35, 4.45], "121": [4.75, 4.90], + "122": [5.25, 5.35], "123": [5.70, 5.80], "124": [6.20, 6.30], + "125": [6.70, 6.80], "126": [7.25, 7.35], "127": [7.80, 7.90], + "128": [8.35, 8.50], "129": [8.95, 9.10], "130": [9.55, 9.65] + } + }, + "2026-08-14": { + "dte": 11, + "calls": { + "120": [10.90, 11.10], "125": [8.30, 8.45], "126": [7.80, 7.95], + "127": [7.35, 7.50], "128": [6.90, 7.05], "129": [6.50, 6.65], + "130": [6.15, 6.25], "132": [5.40, 5.50], "134": [4.70, 4.85], + "135": [4.40, 4.55], "136": [4.10, 4.25], "138": [3.55, 3.70], + "139": [3.30, 3.45], "140": [3.10, 3.20] + }, + "puts": { + "120": [5.05, 5.20], "121": [5.50, 5.65], "122": [5.95, 6.10], + "123": [6.40, 6.60], "124": [6.90, 7.10], "125": [7.40, 7.60], + "126": [7.90, 8.15], "127": [8.50, 8.70], "128": [9.05, 9.25], + "129": [9.60, 9.85], "130": [10.25, 10.45] + } + }, + "2026-08-21": { + "dte": 18, + "calls": { + "127": [8.15, 8.35], "128": [7.70, 7.90], "129": [7.30, 7.45], + "130": [6.95, 7.00], "131": [6.50, 6.70], "132": [6.15, 6.35], + "133": [5.80, 5.95], "134": [5.45, 5.65], "135": [5.15, 5.30], + "136": [4.85, 5.00], "137": [4.55, 4.70], "138": [4.25, 4.45], + "139": [4.00, 4.10], "140": [3.75, 3.85] + }, + "puts": { + "127": [9.25, 9.40], "128": [9.75, 9.95], "129": [10.35, 10.55], + "130": [11.00, 11.15], "131": [11.55, 11.75], "132": [12.10, 12.40], + "133": [12.75, 13.05], "134": [13.40, 13.70], "135": [14.25, 14.40] + } + }, + "2026-08-28": { + "dte": 25, + "calls": { + "124": [10.30, 10.50], "125": [9.80, 10.00], "126": [9.35, 9.55], + "127": [8.90, 9.10], "128": [8.45, 8.65], "129": [8.05, 8.20], + "130": [7.65, 7.80], "131": [7.25, 7.40], "132": [6.85, 7.05], + "134": [6.15, 6.35], "135": [5.85, 6.00], "136": [5.50, 5.70], + "138": [4.90, 5.10], "139": [4.65, 4.85], "140": [4.40, 4.55] + }, + "puts": { + "100": [1.13, 1.18], "105": [1.85, 1.93], "110": [2.95, 3.05], + "113": [3.80, 3.90], "115": [4.45, 4.55], "117": [5.15, 5.30], + "118": [5.50, 5.80], "119": [6.00, 6.15], "120": [6.40, 6.55], + "121": [6.85, 7.00], "122": [7.30, 7.45], "123": [7.80, 7.95], + "124": [8.30, 8.45], "125": [8.80, 9.00], "126": [9.35, 9.50] + } + }, + "2026-09-18": { + "dte": 46, + "calls": { + "125": [11.70, 11.85], "130": [9.55, 9.60], "135": [7.65, 7.75], + "140": [6.10, 6.20], "145": [4.85, 4.90], "150": [3.80, 3.85], + "155": [2.98, 3.05], "160": [2.34, 2.41] + }, + "puts": { + "95": [1.26, 1.34], "100": [1.93, 2.00], "105": [2.89, 3.00], + "110": [4.20, 4.35], "115": [5.90, 6.05], "120": [7.95, 8.10], + "125": [10.45, 10.60], "130": [13.25, 13.45], "135": [16.40, 16.60] + } + } + } +}