From a29c886464a39e41112e9730133499d6ac693fb2 Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:54:25 -0700 Subject: [PATCH 1/2] feat(tastytrade): a skill for ranking earnings calendar spreads 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. Usually it isn't. The skill enforces that as a gate. calendars.py fits the vol term structure to base vol plus a one-time event jump, then prices every candidate calendar against three move regimes using real bid/ask, and prints per structure whether its band actually covers the implied expected move. Standard library only, so python3 scripts/calendars.py works with no install. The move distribution is a weighted grid rather than Monte Carlo, which is exact and reproducible and still runs 285 structures in about 4 seconds. Writing the selftest caught a bug in the kernel smoothing: each Gaussian's total weight scaled with its own bandwidth, so wide kernels spanned more grid points and silently outweighed narrow ones, tilting the distribution toward the largest historical moves. Each kernel is now normalised to unit mass. reference/pltr-2026-08-03.json is a real PLTR chain from the afternoon of its Q2 print. It doubles as a regression fixture: fit should return an event jump near 13% against a historical rms of 14.8%, which is the fairly-priced case where no calendar has edge. The skill never places orders. --- plugins/tastytrade/README.md | 23 + plugins/tastytrade/scripts/calendars.py | 697 ++++++++++++++++++ .../skills/earnings-calendars/SKILL.md | 162 ++++ .../reference/pltr-2026-08-03.json | 90 +++ 4 files changed, 972 insertions(+) create mode 100644 plugins/tastytrade/scripts/calendars.py create mode 100644 plugins/tastytrade/skills/earnings-calendars/SKILL.md create mode 100644 plugins/tastytrade/skills/earnings-calendars/reference/pltr-2026-08-03.json 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] + } + } + } +} From 669bec9885030958f7beac0ceb537b821be9ccc8 Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:58:00 -0700 Subject: [PATCH 2/2] fix(tastytrade): bump to 0.4.0 and run the skill selftest in CI The version gate caught the miss: scripts/ and skills/ are shipped payload, so without the bump every installed copy keeps serving 0.3.0 from cache and never sees the new skill. Wiring the selftest into `make check` closes a second hole. Skill scripts live outside src/, so pytest never collected calendars.py and its assertions were only running when someone invoked them by hand. --- plugins/tastytrade/.claude-plugin/plugin.json | 2 +- plugins/tastytrade/Makefile | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) 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