$sloprun.dev

Options Strategy Builder

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 5 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A multi-leg options position drawn the way traders actually think about it: forget the Greeks, what does this thing pay on expiry day? It opens on a live iron condor — $300 credit, flat profit between 95 and 105, breakevens at 92.00 and 108.00, risk capped at $700 — and every leg you flip, retype or delete redraws the payoff curve and recomputes the breakevens, max profit, max loss and reward-to-risk on the keystroke, renaming the structure as it goes. Try deleting the two long wings (the 85 put and the 115 call): the condor becomes a short strangle and max loss flips from a capped $700 to unlimited. Then tick "show each leg" to see the four kinked lines that add up to the shape, and drag across the chart — or focus it and press ← → — to read the P/L at any price.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/options-payoff" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Options Strategy Builder — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/options-payoff

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1047 lines 46.9 KB index.html
demos/options-payoff/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Options Strategy Builder</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---------------- demo ---------------- */
/* native controls (checkbox, select popup, scrollbars) must follow the theme too */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }

* { box-sizing: border-box; }
html, body { overflow-x: hidden; }
body { margin: 0; padding: clamp(12px, 2.6vw, 26px); -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1180px; margin: 0 auto; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

header.top { margin-bottom: 14px; }
.kicker {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--accent); display: flex; align-items: center; gap: 7px;
}
.kicker::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
h1 {
  font-size: clamp(21px, 3.4vw, 28px); line-height: 1.15; margin: 8px 0 6px;
  letter-spacing: -.015em; font-weight: 650;
}
.lede { margin: 0; color: var(--muted); font-size: 14.5px; line-height: 1.5; max-width: 68ch; }

.grid { display: grid; gap: 14px; grid-template-columns: minmax(0, 1fr); align-items: start; }
@media (min-width: 950px) { .grid { grid-template-columns: minmax(0, 1.6fr) minmax(332px, 1fr); } }

.panel {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); min-width: 0; overflow: hidden;
}
.panel-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  padding: 10px 13px; border-bottom: 1px solid var(--line); flex-wrap: wrap;
}
.panel-head h2 {
  margin: 0; font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;
  letter-spacing: .14em; text-transform: uppercase; color: var(--muted);
}
.readout { font-family: var(--font-mono); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.readout .k { color: var(--muted); }
.readout .up { color: var(--run); }
.readout .down { color: var(--danger); }

/* ---- chart ---- */
.canvas-wrap { padding: 8px 8px 0; }
canvas {
  display: block; width: 100%; height: 300px; border-radius: 6px;
  touch-action: pan-y; cursor: crosshair;
}
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.underchart {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  flex-wrap: wrap; padding: 8px 13px 2px;
}
.toggle {
  display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .05em;
  color: var(--muted); user-select: none;
  min-height: 24px; padding: 4px 0;   /* label is the hit area — keep it tappable */
}
.toggle input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; cursor: pointer; }
.toggle:hover { color: var(--ink); }
.toggle.off { opacity: .7; cursor: not-allowed; }
.toggle.off input, .toggle.off:hover { cursor: not-allowed; color: var(--muted); }
.hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .04em; }

.chips {
  list-style: none; margin: 0; padding: 10px 13px 13px;
  display: grid; gap: 8px; grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 560px) { .chips { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.chip { border: 1px solid var(--line); border-radius: 8px; padding: 8px 9px 9px; background: var(--bg); min-width: 0; }
.chip .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); display: block; margin-bottom: 3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chip .v {
  font-family: var(--font-mono); font-size: clamp(14px, 2.3vw, 16.5px); font-weight: 600;
  font-variant-numeric: tabular-nums; display: block; line-height: 1.2; overflow-wrap: anywhere;
}
.chip .s {
  font-family: var(--font-mono); font-size: 10px; color: var(--muted);
  display: block; margin-top: 3px; letter-spacing: .03em; overflow-wrap: anywhere;
}
.chip.gain .v { color: var(--run); }
.chip.loss .v { color: var(--danger); }

.caveat {
  margin: 0; padding: 10px 13px; border-top: 1px solid var(--line);
  font-size: 11.5px; line-height: 1.5; color: var(--muted); background: var(--bg);
}

/* ---- builder ---- */
.strategy {
  font-family: var(--font-mono); font-size: 11px; color: var(--accent);
  letter-spacing: .09em; text-transform: uppercase; font-weight: 600;
  text-align: right; min-width: 0; overflow-wrap: anywhere;
}
button { font-family: inherit; font-size: 13px; color: inherit; cursor: pointer; }
.presets { display: flex; flex-wrap: wrap; gap: 7px; padding: 11px 13px 0; }
.preset {
  font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .03em;
  padding: 6px 10px; border-radius: 999px; border: 1px solid var(--line);
  background: var(--surface); color: var(--muted);
}
.preset:hover { border-color: var(--accent); color: var(--ink); }
.preset[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }

.spot-row { display: flex; align-items: center; gap: 9px; padding: 12px 13px 2px; }
.spot-row > label {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted);
}
input {
  font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums;
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 7px; padding: 6px 7px; width: 100%; min-width: 0;
}
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input:hover:not(:disabled) { border-color: var(--muted); }
input:focus-visible { border-color: var(--accent); }
input:disabled { background: var(--bg); color: var(--muted); cursor: not-allowed; }
input[aria-invalid="true"] { border-color: var(--danger); }
#spot { width: 94px; flex: none; }
.spot-hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }

.legs { padding: 10px 13px 0; display: grid; gap: 9px; }
.leg { border: 1px solid var(--line); border-radius: 9px; padding: 9px 10px 10px; background: var(--bg); }
.leg-top { display: flex; align-items: center; gap: 7px; margin-bottom: 9px; }
.side {
  font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: .09em;
  padding: 5px 9px; border-radius: 6px; border: 1px solid var(--line);
  background: var(--surface); min-width: 64px; flex: none;
}
.side[data-side="long"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.side[data-side="short"] { color: var(--muted); border-style: dashed; }
select {
  font-family: var(--font-mono); font-size: 12px; color: var(--ink);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 6px; padding: 5px 6px; flex: 1; min-width: 0; cursor: pointer;
}
.del {
  border: 1px solid var(--line); background: var(--surface); color: var(--muted);
  border-radius: 6px; width: 28px; height: 28px; line-height: 1; flex: none; font-size: 15px; padding: 0;
}
.del:hover { color: var(--danger); border-color: var(--danger); }
.fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.field { min-width: 0; }
.field > span {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted); display: block; margin-bottom: 3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.empty {
  font-size: 13px; color: var(--muted); border: 1px dashed var(--line);
  border-radius: 9px; padding: 16px 12px; text-align: center; margin: 0;
}
.add {
  margin: 9px 13px 12px; width: calc(100% - 26px); padding: 9px;
  border: 1px dashed var(--line); border-radius: 8px; background: transparent;
  color: var(--muted); font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em;
}
.add:hover { border-color: var(--accent); color: var(--accent); }
.note { margin: 0; padding: 0 13px 13px; font-size: 11.5px; color: var(--muted); line-height: 1.5; }

footer {
  margin-top: 16px; padding-top: 11px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em;
}
@media (prefers-reduced-motion: no-preference) {
  .preset, .side, .del, input, select, .add, .toggle { transition: border-color .14s ease, color .14s ease, background-color .14s ease; }
}
</style>
</head>
<body>

<div class="wrap">
  <header class="top">
    <div class="kicker">Payoff at expiry</div>
    <h1>Options Strategy Builder</h1>
    <p class="lede">Stack option legs and see what the position actually pays on expiry day. Breakevens, max profit, max loss and net premium recompute on every keystroke — it opens on a live iron condor.</p>
  </header>

  <main class="grid">
    <section class="panel" aria-label="Payoff chart and position statistics">
      <div class="panel-head">
        <h2>Profit / loss at expiry</h2>
        <div class="readout mono" id="readout"></div>
      </div>
      <div class="canvas-wrap">
        <canvas id="chart" tabindex="0" role="img" aria-describedby="chartHelp"></canvas>
      </div>
      <p class="sr-only" id="chartHelp">Interactive payoff chart. Point at it, or focus it and press the left and right arrow keys, to read profit or loss at any underlying price. Home and End jump to the ends of the range.</p>
      <div class="underchart">
        <label class="toggle" id="ghostLabel"><input type="checkbox" id="ghosts" name="ghosts"><span>show each leg</span></label>
        <span class="hint" id="hint">drag across the chart, or focus it and press &larr; &rarr;</span>
      </div>
      <ul class="chips" id="chips">
        <li class="chip"><span class="k">Net premium</span><span class="v" id="cPrem">&mdash;</span><span class="s" id="cPremSub">&nbsp;</span></li>
        <li class="chip"><span class="k">Max profit</span><span class="v" id="cMax">&mdash;</span><span class="s" id="cMaxSub">&nbsp;</span></li>
        <li class="chip"><span class="k">Max loss</span><span class="v" id="cMin">&mdash;</span><span class="s" id="cMinSub">&nbsp;</span></li>
        <li class="chip"><span class="k">Breakeven</span><span class="v" id="cBe">&mdash;</span><span class="s" id="cBeSub">&nbsp;</span></li>
        <li class="chip"><span class="k">Reward : risk</span><span class="v" id="cRr">&mdash;</span><span class="s" id="cRrSub">&nbsp;</span></li>
        <li class="chip"><span class="k" id="cSpotK">P/L at spot</span><span class="v" id="cSpot">&mdash;</span><span class="s" id="cSpotSub">&nbsp;</span></li>
      </ul>
      <p class="caveat">Expiry-only payoff: no time value, implied volatility, dividends, early assignment, commissions or margin. One contract = 100 shares. Educational tool, not investment advice.</p>
    </section>

    <section class="panel" aria-label="Position builder">
      <div class="panel-head">
        <h2>Position</h2>
        <span class="strategy" id="strategy">iron condor</span>
      </div>
      <div class="presets" role="group" aria-label="Preset strategies">
        <button type="button" class="preset" data-preset="condor" aria-pressed="true">Iron condor</button>
        <button type="button" class="preset" data-preset="straddle" aria-pressed="false">Straddle</button>
        <button type="button" class="preset" data-preset="covered" aria-pressed="false">Covered call</button>
      </div>
      <div class="spot-row">
        <label for="spot">Underlying</label>
        <input id="spot" name="spot" type="number" step="0.5" min="0.01" value="100" inputmode="decimal">
        <span class="spot-hint">price now</span>
      </div>
      <div class="legs" id="legs"></div>
      <button type="button" class="add" id="addLeg">+ add leg</button>
      <p class="note">Short legs collect premium, long legs pay it. A stock leg uses <em>entry</em> as the share price paid and counts in 100-share lots.</p>
    </section>
  </main>

  <p class="sr-only" id="live" aria-live="polite"></p>
  <footer class="mono">demo · sloprun.dev</footer>
</div>

<script>
(function () {
  "use strict";

  var CTR = 100;                 // shares per contract
  var root = document.documentElement;
  var cvs = document.getElementById("chart");
  var ctx = cvs.getContext("2d");
  var legsEl = document.getElementById("legs");

  /* ---------------- theme ---------------- */
  window.addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      requestAnimationFrame(draw);
    }
  });
  var mq = window.matchMedia("(prefers-color-scheme: dark)");
  var onScheme = function () { requestAnimationFrame(draw); };
  if (mq.addEventListener) mq.addEventListener("change", onScheme);
  else if (mq.addListener) mq.addListener(onScheme);

  /* ---------------- state ---------------- */
  var PRESETS = {
    condor: { spot: 100, legs: [
      { side: "long",  kind: "put",  strike: 85,  premium: 0.60, qty: 1 },
      { side: "short", kind: "put",  strike: 95,  premium: 2.10, qty: 1 },
      { side: "short", kind: "call", strike: 105, premium: 2.20, qty: 1 },
      { side: "long",  kind: "call", strike: 115, premium: 0.70, qty: 1 }
    ]},
    straddle: { spot: 100, legs: [
      { side: "long", kind: "call", strike: 100, premium: 4.50, qty: 1 },
      { side: "long", kind: "put",  strike: 100, premium: 4.20, qty: 1 }
    ]},
    covered: { spot: 100, legs: [
      { side: "long",  kind: "stock", strike: 0,   premium: 100.00, qty: 1 },
      { side: "short", kind: "call",  strike: 105, premium: 2.40,   qty: 1 }
    ]}
  };

  var uid = 0;
  var state = { spot: 100, legs: [], preset: "condor" };
  var scrub = null;            // underlying price under cursor / caret, or null
  var showGhosts = false;

  /* ---------------- math ---------------- */
  function legPL(l, S) {
    var sgn = l.side === "long" ? 1 : -1;
    var intr = l.kind === "stock" ? S
      : l.kind === "call" ? Math.max(S - l.strike, 0)
      : Math.max(l.strike - S, 0);
    return sgn * l.qty * CTR * (intr - l.premium);
  }
  function payoff(S) {
    var t = 0;
    for (var i = 0; i < state.legs.length; i++) t += legPL(state.legs[i], S);
    return t;
  }
  function netPremium() {       // + credit received, - debit paid (option legs only)
    var t = 0;
    for (var i = 0; i < state.legs.length; i++) {
      var l = state.legs[i];
      if (l.kind === "stock") continue;
      t += (l.side === "short" ? 1 : -1) * l.qty * CTR * l.premium;
    }
    return t;
  }
  function strikes() {
    var out = [], seen = {};
    for (var i = 0; i < state.legs.length; i++) {
      var l = state.legs[i];
      if (l.kind === "stock") continue;
      var k = Math.max(0, l.strike);
      if (!seen[k]) { seen[k] = 1; out.push(k); }
    }
    return out.sort(function (a, b) { return a - b; });
  }

  // the payoff is piecewise linear with kinks only at strikes, so everything
  // below is exact — no sampling error, no numeric search
  function stats() {
    var ks = strikes();
    var topK = ks.length ? ks[ks.length - 1] : state.spot;
    var far = Math.max(topK, state.spot, 1) * 4 + 500;
    var slope = payoff(far + 1) - payoff(far);

    var pts = [0].concat(ks.filter(function (k) { return k > 0; }));
    var vals = pts.map(payoff);
    var maxP = Math.max.apply(null, vals.length ? vals : [0]);
    var minP = Math.min.apply(null, vals.length ? vals : [0]);
    if (slope > 1e-7) maxP = Infinity;
    if (slope < -1e-7) minP = -Infinity;

    // a fat premium can push the last breakeven far past the strikes: extend the
    // search window out to the tail's own root so the crossing is not missed
    if (Math.abs(slope) > 1e-7) {
      var tailRoot = far - payoff(far) / slope;
      if (isFinite(tailRoot) && tailRoot > far) far = tailRoot + 1;
    }

    var xs = [0].concat(ks, [far]).sort(function (a, b) { return a - b; });
    var roots = [], i;
    for (i = 0; i < xs.length - 1; i++) {
      var a = xs[i], b = xs[i + 1];
      if (b - a < 1e-9) continue;
      var pa = payoff(a), pb = payoff(b);
      var za = Math.abs(pa) < 1e-7, zb = Math.abs(pb) < 1e-7;
      if (za && zb) continue;                       // sits flat on zero: not a crossing
      else if (za) roots.push(a);
      else if (zb) roots.push(b);
      else if ((pa < 0) !== (pb < 0)) roots.push(a + (-pa) * (b - a) / (pb - pa));
    }
    var be = [], seen = {};
    roots.sort(function (x, y) { return x - y; }).forEach(function (r) {
      var key = r.toFixed(2);
      if (!seen[key]) { seen[key] = 1; be.push(r); }
    });

    // where an extreme lives: a single price, a plateau, or an open-ended tail
    function whereRange(target) {
      var idx = [], j;
      for (j = 0; j < pts.length; j++) if (Math.abs(vals[j] - target) < 1e-6) idx.push(j);
      if (!idx.length) return "";
      var runs = [[idx[0], idx[0]]];
      for (j = 1; j < idx.length; j++) {
        if (idx[j] === runs[runs.length - 1][1] + 1) runs[runs.length - 1][1] = idx[j];
        else runs.push([idx[j], idx[j]]);
      }
      var last = pts.length - 1;
      var parts = runs.map(function (r) {
        var lo = pts[r[0]], hi = pts[r[1]];
        var flatTail = r[1] === last && Math.abs(slope) < 1e-7;
        if (r[0] === 0 && flatTail) return "at any price";
        if (flatTail) return "at " + price(lo) + " and up";
        if (lo === hi) return "at " + price(lo);
        if (r[0] === 0) return "at " + price(hi) + " and below";
        return "between " + price(lo) + "–" + price(hi);
      });
      return parts.join(" · ");
    }

    return {
      max: maxP, min: minP, be: be, ks: ks, net: netPremium(), atSpot: payoff(state.spot),
      maxAt: maxP === Infinity ? "rises past " + price(topK) : whereRange(maxP),
      minAt: minP === -Infinity ? "falls past " + price(topK) : whereRange(minP)
    };
  }

  /* ---------------- strategy naming ---------------- */
  function detect() {
    var ls = state.legs.filter(function (l) { return l.qty > 0; });
    if (!ls.length) return "no position";
    var stock = ls.filter(function (l) { return l.kind === "stock"; });
    var opts = ls.filter(function (l) { return l.kind !== "stock"; });
    var calls = opts.filter(function (l) { return l.kind === "call"; }).sort(byStrike);
    var puts = opts.filter(function (l) { return l.kind === "put"; }).sort(byStrike);
    var q = ls[0].qty;
    var evenQty = ls.every(function (l) { return l.qty === q; });
    function byStrike(a, b) { return a.strike - b.strike; }
    function is(l, side) { return l.side === side; }

    if (ls.length === 1) {
      var one = ls[0];
      if (one.kind === "stock") return one.side + " stock";
      return one.side + " " + one.kind;
    }
    if (evenQty && stock.length === 1 && opts.length === 1) {
      var s = stock[0], o = opts[0];
      if (is(s, "long") && o.kind === "call" && is(o, "short")) return "covered call";
      if (is(s, "long") && o.kind === "put" && is(o, "long")) return "protective put";
      if (is(s, "short") && o.kind === "put" && is(o, "short")) return "covered put";
      if (is(s, "short") && o.kind === "call" && is(o, "long")) return "protected short";
    }
    if (evenQty && stock.length === 1 && opts.length === 2 && calls.length === 1 && puts.length === 1) {
      if (is(stock[0], "long") && is(calls[0], "short") && is(puts[0], "long")) return "collar";
    }
    if (evenQty && !stock.length && opts.length === 2) {
      if (calls.length === 2) {
        if (is(calls[0], "long") && is(calls[1], "short")) return "bull call spread";
        if (is(calls[0], "short") && is(calls[1], "long")) return "bear call spread";
      } else if (puts.length === 2) {
        if (is(puts[0], "short") && is(puts[1], "long")) return "bear put spread";
        if (is(puts[0], "long") && is(puts[1], "short")) return "bull put spread";
      } else {
        var c = calls[0], p = puts[0], same = Math.abs(c.strike - p.strike) < 1e-9;
        if (is(c, "long") && is(p, "long")) return same ? "long straddle" : "long strangle";
        if (is(c, "short") && is(p, "short")) return same ? "short straddle" : "short strangle";
        if (is(c, "long") && is(p, "short")) return same ? "synthetic long stock" : "risk reversal";
        if (is(c, "short") && is(p, "long")) return same ? "synthetic short stock" : "reverse risk reversal";
      }
    }
    if (!stock.length && opts.length === 3 && (calls.length === 3 || puts.length === 3)) {
      var t = calls.length === 3 ? calls : puts;
      var wing = t[0].qty === t[2].qty && t[1].qty === 2 * t[0].qty;
      var spread = Math.abs((t[1].strike - t[0].strike) - (t[2].strike - t[1].strike)) < 1e-9;
      if (wing && spread) {
        if (is(t[0], "long") && is(t[1], "short") && is(t[2], "long")) return "long butterfly";
        if (is(t[0], "short") && is(t[1], "long") && is(t[2], "short")) return "short butterfly";
      }
    }
    if (evenQty && !stock.length && opts.length === 4) {
      if (calls.length === 2 && puts.length === 2 && puts[1].strike <= calls[0].strike) {
        var body = Math.abs(puts[1].strike - calls[0].strike) < 1e-9;
        if (is(puts[0], "long") && is(puts[1], "short") && is(calls[0], "short") && is(calls[1], "long"))
          return body ? "iron butterfly" : "iron condor";
        if (is(puts[0], "short") && is(puts[1], "long") && is(calls[0], "long") && is(calls[1], "short"))
          return body ? "reverse iron butterfly" : "reverse iron condor";
      }
      if (calls.length === 4 || puts.length === 4) {
        var f = calls.length === 4 ? calls : puts;
        if (is(f[0], "long") && is(f[1], "short") && is(f[2], "short") && is(f[3], "long")) return "long condor";
        if (is(f[0], "short") && is(f[1], "long") && is(f[2], "long") && is(f[3], "short")) return "short condor";
      }
    }
    return ls.length + "-leg position";
  }

  /* ---------------- formatting ---------------- */
  function money(n) {
    if (n === Infinity || n === -Infinity) return "unlimited";
    var s = n < -0.005 ? "−" : n > 0.005 ? "+" : "";
    return s + "$" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 });
  }
  function price(n) { return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
  function tone(n) { return n > 0.005 ? "gain" : n < -0.005 ? "loss" : ""; }

  /* ---------------- presets ---------------- */
  function loadPreset(key) {
    var p = PRESETS[key];
    state.spot = p.spot;
    state.preset = key;
    state.legs = p.legs.map(function (l) {
      return { id: ++uid, side: l.side, kind: l.kind, strike: l.strike, premium: l.premium, qty: l.qty };
    });
    scrub = null;
    var sp = document.getElementById("spot");
    sp.value = String(p.spot);
    sp.setAttribute("aria-invalid", "false");
    syncPresets();
    renderLegs();
    update();
  }
  function markCustom() { if (state.preset) { state.preset = null; syncPresets(); } }
  function syncPresets() {
    Array.prototype.forEach.call(document.querySelectorAll(".preset"), function (b) {
      b.setAttribute("aria-pressed", b.dataset.preset === state.preset ? "true" : "false");
    });
  }

  /* ---------------- leg cards ---------------- */
  function legLabel(l) {
    if (l.kind === "stock") return l.side + " stock, " + l.qty + " lots at " + price(l.premium);
    return l.side + " " + l.kind + " " + price(l.strike) + ", quantity " + l.qty;
  }
  function field(labelText, key, value, step, min) {
    var wrap = document.createElement("label");
    wrap.className = "field";
    var s = document.createElement("span");
    s.textContent = labelText;
    var inp = document.createElement("input");
    inp.type = "number"; inp.step = step; inp.value = String(value);
    inp.inputMode = "decimal"; inp.dataset.k = key; inp.name = key;
    if (min !== undefined) inp.min = String(min);
    wrap.appendChild(s); wrap.appendChild(inp);
    return wrap;
  }

  function legCard(leg) {
    var card = document.createElement("div");
    card.className = "leg";

    var top = document.createElement("div");
    top.className = "leg-top";

    var side = document.createElement("button");
    side.type = "button"; side.className = "side"; side.dataset.side = leg.side;
    side.textContent = leg.side.toUpperCase();
    function sideLabel() { side.setAttribute("aria-label", "Side: " + leg.side + ", activate to switch"); }
    sideLabel();
    side.addEventListener("click", function () {
      leg.side = leg.side === "long" ? "short" : "long";
      side.dataset.side = leg.side;
      side.textContent = leg.side.toUpperCase();
      sideLabel(); refreshLabels(); markCustom(); update();
    });

    var sel = document.createElement("select");
    sel.name = "instrument"; sel.setAttribute("aria-label", "Instrument");
    [["call", "Call"], ["put", "Put"], ["stock", "Stock"]].forEach(function (o) {
      var op = document.createElement("option");
      op.value = o[0]; op.textContent = o[1];
      if (leg.kind === o[0]) op.selected = true;
      sel.appendChild(op);
    });

    var del = document.createElement("button");
    del.type = "button"; del.className = "del"; del.innerHTML = "&times;";
    del.addEventListener("click", function () {
      var at = state.legs.indexOf(leg);
      state.legs = state.legs.filter(function (x) { return x !== leg; });
      markCustom(); renderLegs(); update();
      var dels = legsEl.querySelectorAll(".del");   // keep the keyboard where it was
      if (dels.length) dels[Math.max(0, Math.min(at, dels.length - 1))].focus();
      else document.getElementById("addLeg").focus();
    });

    top.appendChild(side); top.appendChild(sel); top.appendChild(del);

    var fields = document.createElement("div");
    fields.className = "fields";
    var fStrike = field("Strike", "strike", leg.strike, "1", 0);
    var fPrem = field("Premium", "premium", leg.premium, "0.05", 0);
    var fQty = field("Qty", "qty", leg.qty, "1", 0);
    fields.appendChild(fStrike); fields.appendChild(fPrem); fields.appendChild(fQty);

    function refreshLabels() { del.setAttribute("aria-label", "Remove leg: " + legLabel(leg)); }

    function applyKind() {
      var isStock = leg.kind === "stock";
      var sIn = fStrike.querySelector("input");
      sIn.disabled = isStock;
      sIn.value = isStock ? "" : String(leg.strike);
      sIn.placeholder = isStock ? "n/a" : "";
      sIn.setAttribute("aria-label", isStock ? "Strike, not used for stock" : "Strike price");
      sIn.setAttribute("aria-invalid", "false");
      fPrem.querySelector("span").textContent = isStock ? "Entry" : "Premium";
      fPrem.querySelector("input").step = isStock ? "1" : "0.05";
      fPrem.querySelector("input").setAttribute("aria-label", isStock ? "Share entry price" : "Premium per share");
      fQty.querySelector("span").textContent = isStock ? "Lots ×100" : "Qty";
      fQty.querySelector("input").setAttribute("aria-label", isStock ? "Lots of 100 shares" : "Qty, number of contracts");
      refreshLabels();
    }

    sel.addEventListener("change", function () {
      leg.kind = sel.value;
      if (leg.kind !== "stock" && !(leg.strike > 0)) leg.strike = Math.max(1, Math.round(state.spot));
      applyKind(); markCustom(); update();
    });

    fields.addEventListener("input", function (ev) {
      var inp = ev.target;
      if (!inp.dataset || !inp.dataset.k) return;
      var raw = inp.value.trim();
      var v = parseFloat(raw);
      var bad = raw === "" || !isFinite(v) || v < 0;
      inp.setAttribute("aria-invalid", bad ? "true" : "false");
      if (bad) v = 0;
      if (inp.dataset.k === "qty") v = Math.max(0, Math.round(v));
      leg[inp.dataset.k] = v;
      refreshLabels(); markCustom(); update();
    });

    card.appendChild(top); card.appendChild(fields);
    applyKind();
    return card;
  }

  function renderLegs() {
    legsEl.textContent = "";
    if (!state.legs.length) {
      var e = document.createElement("p");
      e.className = "empty";
      e.textContent = "No legs — add one, or pick a preset above.";
      legsEl.appendChild(e);
      return;
    }
    state.legs.forEach(function (leg) { legsEl.appendChild(legCard(leg)); });
  }

  /* ---------------- chart ---------------- */
  var view = null;
  function cssVar(n) { return getComputedStyle(root).getPropertyValue(n).trim() || "#888888"; }
  function alpha(hex, aa) { return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex + aa : hex; }

  function niceTicks(min, max, count) {
    var span = max - min;
    if (!(span > 0)) return [min];
    var raw = span / Math.max(1, count);
    var mag = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10));
    var norm = raw / mag;
    var step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag;
    var out = [], v = Math.ceil(min / step) * step;
    for (; v <= max + step * 1e-6 && out.length < 40; v += step) out.push(Math.round(v * 1e6) / 1e6);
    return out;
  }
  function shortMoney(v) {
    var a = Math.abs(v), s = v < 0 ? "−" : "";
    if (a >= 1000) { var k = a / 1000; return s + "$" + (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + "k"; }
    if (a < 10 && a % 1 !== 0) return s + "$" + Math.round(a * 100) / 100;
    return s + "$" + Math.round(a);
  }

  function computeView(w, h) {
    var ks = strikes();
    var lo, hi;
    if (ks.length) { lo = Math.min(ks[0], state.spot); hi = Math.max(ks[ks.length - 1], state.spot); }
    else { lo = state.spot; hi = state.spot; }
    // keep breakevens on screen — a payoff chart that never appears to cross zero
    // reads as broken, and fat premiums push the crossings outside the strikes
    var cap = Math.max(state.spot, hi, 1) * 5;
    stats().be.forEach(function (b) {
      if (b >= 0 && b <= cap) { lo = Math.min(lo, b); hi = Math.max(hi, b); }
    });
    var span = hi - lo;
    var pad = Math.max(span * 0.42, Math.max(state.spot, 1) * 0.22, 2);
    var xMin = Math.max(0, lo - pad), xMax = hi + pad;
    if (xMax - xMin < 1e-6) { xMin = Math.max(0, state.spot * 0.5); xMax = state.spot * 1.5 + 1; }

    var sample = [xMin, xMax, state.spot].concat(ks.filter(function (k) { return k > xMin && k < xMax; }));
    var vals = sample.map(payoff).concat([0]);
    var yMin = Math.min.apply(null, vals), yMax = Math.max.apply(null, vals);
    var yPad = Math.max((yMax - yMin) * 0.16, 1);
    yMin -= yPad; yMax += yPad;
    if (yMax - yMin < 1e-6) { yMin = -100; yMax = 100; }

    var pl = 52, pr = 14, pt = 14, pb = 26;
    return { xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax,
             pl: pl, pt: pt, pw: Math.max(20, w - pl - pr), ph: Math.max(20, h - pt - pb) };
  }
  function X(v) { return view.pl + (v - view.xMin) / (view.xMax - view.xMin) * view.pw; }
  function Y(v) { return view.pt + (view.yMax - v) / (view.yMax - view.yMin) * view.ph; }
  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function pxToPrice(px) { return view.xMin + clamp((px - view.pl) / view.pw, 0, 1) * (view.xMax - view.xMin); }

  function roundRect(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
    ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
    ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
    ctx.closePath();
  }

  function draw() {
    var host = cvs.parentNode;
    var w = Math.max(200, Math.round(host.clientWidth - 16));
    var h = Math.round(Math.max(240, Math.min(420, w * 0.55)));
    var dpr = Math.min(window.devicePixelRatio || 1, 2);
    if (cvs.width !== Math.round(w * dpr) || cvs.height !== Math.round(h * dpr)) {
      cvs.width = Math.round(w * dpr); cvs.height = Math.round(h * dpr);
    }
    cvs.style.height = h + "px";
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);

    view = computeView(w, h);
    // editing a strike/premium can shrink the x-range out from under the caret;
    // without this the crosshair and its dot get drawn outside the plot frame
    if (scrub !== null) scrub = clamp(scrub, view.xMin, view.xMax);
    var ink = cssVar("--ink"), muted = cssVar("--muted"), line = cssVar("--line"),
        accent = cssVar("--accent"), run = cssVar("--run"), danger = cssVar("--danger"),
        surface = cssVar("--surface"), monoFont = cssVar("--font-mono");

    var x0 = view.pl, x1 = view.pl + view.pw, y0 = view.pt, y1 = view.pt + view.ph;
    var zeroY = clamp(Y(0), y0, y1);
    var st = stats();

    ctx.font = "10px " + monoFont;
    ctx.lineWidth = 1;

    // grid
    ctx.textAlign = "right"; ctx.textBaseline = "middle";
    niceTicks(view.yMin, view.yMax, 5).forEach(function (v) {
      var y = Math.round(Y(v)) + 0.5;
      if (y < y0 - 1 || y > y1 + 1) return;
      ctx.strokeStyle = line;
      ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
      ctx.fillStyle = muted; ctx.fillText(shortMoney(v), x0 - 8, y);
    });
    ctx.textAlign = "center"; ctx.textBaseline = "top";
    niceTicks(view.xMin, view.xMax, Math.max(3, Math.floor(view.pw / 78))).forEach(function (v) {
      var x = Math.round(X(v)) + 0.5;
      if (x < x0 - 1 || x > x1 + 1) return;
      ctx.strokeStyle = line;
      ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y1); ctx.stroke();
      ctx.fillStyle = muted; ctx.fillText(String(Math.round(v * 100) / 100), x, y1 + 7);
    });

    // exact polyline: vertices at the view edges and every strike inside it
    var ks = st.ks.filter(function (k) { return k > view.xMin && k < view.xMax; });
    var pxs = [view.xMin].concat(ks, [view.xMax]);
    var pts = pxs.map(function (p) { return [X(p), Y(payoff(p))]; });

    function areaPath() {
      ctx.beginPath();
      ctx.moveTo(pts[0][0], zeroY);
      for (var i = 0; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
      ctx.lineTo(pts[pts.length - 1][0], zeroY);
      ctx.closePath();
    }
    if (state.legs.length) {
      // profit region above zero — green is earned here: it is money made
      ctx.save();
      ctx.beginPath(); ctx.rect(x0, y0, view.pw, Math.max(0, zeroY - y0)); ctx.clip();
      areaPath(); ctx.fillStyle = alpha(run, "2B"); ctx.fill();
      ctx.restore();
      ctx.save();
      ctx.beginPath(); ctx.rect(x0, zeroY, view.pw, Math.max(0, y1 - zeroY)); ctx.clip();
      areaPath(); ctx.fillStyle = alpha(danger, "24"); ctx.fill();
      ctx.restore();
    }

    // zero line
    ctx.strokeStyle = alpha(ink, "66"); ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(x0, Math.round(Y(0)) + 0.5); ctx.lineTo(x1, Math.round(Y(0)) + 0.5); ctx.stroke();

    // spot marker
    var spotX = X(state.spot);
    if (spotX >= x0 && spotX <= x1) {
      ctx.save();
      ctx.setLineDash([3, 4]); ctx.strokeStyle = muted;
      ctx.beginPath(); ctx.moveTo(Math.round(spotX) + 0.5, y0); ctx.lineTo(Math.round(spotX) + 0.5, y1); ctx.stroke();
      ctx.restore();
      ctx.textAlign = "center"; ctx.textBaseline = "top";
      var sw = ctx.measureText("SPOT").width;
      ctx.fillStyle = alpha(surface, "E6");
      ctx.fillRect(clamp(spotX, x0 + sw / 2 + 3, x1 - sw / 2 - 3) - sw / 2 - 3, y0, sw + 6, 12);
      ctx.fillStyle = muted;
      ctx.fillText("SPOT", clamp(spotX, x0 + sw / 2 + 3, x1 - sw / 2 - 3), y0 + 1);
    }

    // per-leg ghost curves
    if (showGhosts && state.legs.length > 1) {
      ctx.save();
      ctx.beginPath(); ctx.rect(x0, y0, view.pw, view.ph); ctx.clip();
      ctx.setLineDash([4, 3]); ctx.lineWidth = 1.25; ctx.strokeStyle = alpha(muted, "AA");
      state.legs.forEach(function (leg) {
        var vs = pxs.map(function (p) { return [X(p), Y(legPL(leg, p))]; });
        ctx.beginPath();
        vs.forEach(function (pt, i) { i ? ctx.lineTo(pt[0], pt[1]) : ctx.moveTo(pt[0], pt[1]); });
        ctx.stroke();
      });
      ctx.restore();
    }

    // payoff line
    if (state.legs.length) {
      ctx.strokeStyle = accent; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.lineCap = "round";
      ctx.beginPath();
      pts.forEach(function (p, i) { i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); });
      ctx.stroke();

      ctx.lineWidth = 1.5;
      ks.forEach(function (k) {
        ctx.fillStyle = surface; ctx.strokeStyle = accent;
        ctx.beginPath(); ctx.arc(X(k), Y(payoff(k)), 3, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
      });

      // breakevens, labelled on the zero line
      ctx.textAlign = "center"; ctx.textBaseline = "bottom";
      var placed = [];
      st.be.forEach(function (b) {
        if (b < view.xMin || b > view.xMax) return;
        var bx = X(b), by = Y(0);
        ctx.fillStyle = ink;
        ctx.beginPath(); ctx.arc(bx, by, 3.2, 0, Math.PI * 2); ctx.fill();
        var label = price(b), tw = ctx.measureText(label).width;
        var lx = clamp(bx, x0 + tw / 2 + 3, x1 - tw / 2 - 3);
        var up = by > y0 + 22;
        var ty = up ? by - 7 : by + 19;
        // a tight straddle puts both breakevens a few pixels apart; without this the
        // two labels print on top of each other and neither is readable
        var boxOf = function (t) { return { l: lx - tw / 2 - 3, r: lx + tw / 2 + 3, t: t - 11, b: t + 1 }; };
        var clashes = function (q) {
          for (var j = 0; j < placed.length; j++) {
            var p = placed[j];
            if (q.l < p.r && q.r > p.l && q.t < p.b && q.b > p.t) return true;
          }
          return false;
        };
        for (var g = 0; g < 8 && clashes(boxOf(ty)); g++) {
          var next = ty + (up ? -13 : 13);
          if (next - 11 < y0 || next + 1 > y1) break;   // out of frame: stop stacking
          ty = next;
        }
        placed.push(boxOf(ty));
        ctx.fillStyle = alpha(surface, "E6");
        ctx.fillRect(lx - tw / 2 - 3, ty - 11, tw + 6, 12);
        ctx.fillStyle = ink; ctx.fillText(label, lx, ty);
      });
    }

    // crosshair + tooltip
    if (scrub !== null && state.legs.length) {
      var cx = X(scrub), cyRaw = Y(payoff(scrub)), cy = clamp(cyRaw, y0, y1), v = payoff(scrub);
      ctx.save();
      ctx.strokeStyle = alpha(accent, "99"); ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(Math.round(cx) + 0.5, y0); ctx.lineTo(Math.round(cx) + 0.5, y1); ctx.stroke();
      ctx.restore();
      ctx.fillStyle = accent;
      ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fill();

      var l1 = "S  " + price(scrub);
      var l2 = money(v);
      ctx.font = "11px " + monoFont;
      var bw = Math.max(ctx.measureText(l1).width, ctx.measureText(l2).width) + 16;
      var bh = 36;
      var bx2 = cx + 12; if (bx2 + bw > x1) bx2 = cx - 12 - bw;
      bx2 = clamp(bx2, x0 + 2, x1 - bw - 2);
      var by2 = clamp(cy - bh - 10, y0 + 2, y1 - bh - 2);
      ctx.fillStyle = alpha(surface, "F2"); ctx.strokeStyle = line; ctx.lineWidth = 1;
      roundRect(bx2, by2, bw, bh, 6); ctx.fill(); ctx.stroke();
      ctx.textAlign = "left"; ctx.textBaseline = "top";
      ctx.fillStyle = muted; ctx.fillText(l1, bx2 + 8, by2 + 6);
      ctx.fillStyle = v > 0.005 ? run : v < -0.005 ? danger : ink;
      ctx.fillText(l2, bx2 + 8, by2 + 20);
    }

    // frame
    ctx.strokeStyle = line; ctx.lineWidth = 1;
    ctx.strokeRect(x0 + 0.5, y0 + 0.5, view.pw - 1, view.ph - 1);
  }

  /* ---------------- readout + chips ---------------- */
  function setChip(vId, sId, value, sub, cls) {
    var v = document.getElementById(vId);
    v.textContent = value;
    var s = document.getElementById(sId);
    s.textContent = sub || " ";
    v.parentNode.className = "chip" + (cls ? " " + cls : "");
  }

  var liveTimer = null;
  function update() {
    var st = stats(), n = state.legs.length;
    document.getElementById("strategy").textContent = detect();

    if (!n) {
      setChip("cPrem", "cPremSub", "—", "no legs", "");
      setChip("cMax", "cMaxSub", "—", "", "");
      setChip("cMin", "cMinSub", "—", "", "");
      setChip("cBe", "cBeSub", "—", "", "");
      setChip("cRr", "cRrSub", "—", "", "");
      setChip("cSpot", "cSpotSub", "—", "", "");
      document.getElementById("cSpotK").textContent = "P/L at spot";
    } else {
      setChip("cPrem", "cPremSub", money(st.net),
        st.net > 0.005 ? "credit received" : st.net < -0.005 ? "debit paid" : "no premium", "");
      setChip("cMax", "cMaxSub", money(st.max), st.maxAt, tone(st.max));
      setChip("cMin", "cMinSub", money(st.min), st.minAt, tone(st.min));
      setChip("cBe", "cBeSub", st.be.length ? st.be.map(price).join(" / ") : "none",
        st.be.length === 1 ? "1 crossing" : st.be.length ? st.be.length + " crossings" : "never crosses zero", "");

      var rr = "—", rrSub = "";
      if (st.max === Infinity && st.min === -Infinity) { rr = "∞ : ∞"; rrSub = "unbounded both ways"; }
      else if (st.max === Infinity) { rr = "∞ : 1"; rrSub = "profit is open-ended"; }
      else if (st.min === -Infinity) { rr = "1 : ∞"; rrSub = "risk is open-ended"; }
      else if (st.max <= 0.005) { rr = "no profit"; rrSub = "cannot finish green"; }
      else if (st.min >= -0.005) { rr = "no risk"; rrSub = "cannot finish red"; }
      else { rr = "1 : " + (Math.abs(st.min) / st.max).toFixed(2); rrSub = "risked per $1 of profit"; }
      setChip("cRr", "cRrSub", rr, rrSub, "");

      document.getElementById("cSpotK").textContent = "P/L at " + price(state.spot);
      setChip("cSpot", "cSpotSub", money(st.atSpot),
        st.atSpot > 0.005 ? "in profit" : st.atSpot < -0.005 ? "in loss" : "breakeven", tone(st.atSpot));
    }

    var summary = !n ? "No legs. Add a leg or choose a preset."
      : detect() + ". Max profit " + money(st.max) + ", max loss " + money(st.min) +
        ", breakeven " + (st.be.length ? st.be.map(price).join(" and ") : "none") +
        ", net " + money(st.net) + (st.net >= 0 ? " credit." : " debit.");
    cvs.setAttribute("aria-label", "Payoff at expiry. " + summary);
    if (liveTimer) clearTimeout(liveTimer);
    liveTimer = setTimeout(function () { document.getElementById("live").textContent = summary; }, 600);

    syncGhosts();
    draw();
    renderReadout();
  }

  function renderReadout() {
    var el = document.getElementById("readout");
    if (!state.legs.length) { el.innerHTML = '<span class="k">add a leg to begin</span>'; return; }
    var s = scrub === null ? state.spot : scrub;
    var v = payoff(s);
    var cls = v > 0.005 ? "up" : v < -0.005 ? "down" : "k";
    el.innerHTML = '<span class="k">S</span> ' + price(s) +
      ' <span class="k">→ P/L</span> <span class="' + cls + '">' + money(v) + '</span>';
  }

  /* ---------------- interaction ---------------- */
  function pointerPrice(ev) {
    var r = cvs.getBoundingClientRect();
    return pxToPrice(ev.clientX - r.left);
  }
  function setScrub(v) { scrub = v; draw(); renderReadout(); }

  cvs.addEventListener("pointermove", function (ev) { if (view) setScrub(pointerPrice(ev)); });
  cvs.addEventListener("pointerdown", function (ev) { if (view) setScrub(pointerPrice(ev)); });
  cvs.addEventListener("pointerleave", function () { if (document.activeElement !== cvs) setScrub(null); });
  cvs.addEventListener("focus", function () { if (scrub === null && view) setScrub(state.spot); });
  cvs.addEventListener("blur", function () { setScrub(null); });
  cvs.addEventListener("keydown", function (ev) {
    if (!view) return;
    var span = view.xMax - view.xMin;
    var step = span / 100 * (ev.shiftKey ? 10 : 1);
    var cur = scrub === null ? state.spot : scrub, next = null;
    if (ev.key === "ArrowLeft") next = cur - step;
    else if (ev.key === "ArrowRight") next = cur + step;
    else if (ev.key === "Home") next = view.xMin;
    else if (ev.key === "End") next = view.xMax;
    if (next === null) return;
    ev.preventDefault();
    setScrub(clamp(next, view.xMin, view.xMax));
  });

  document.getElementById("spot").addEventListener("input", function () {
    var v = parseFloat(this.value);
    var bad = !isFinite(v) || v < 0.01;      // matches the field's own min
    this.setAttribute("aria-invalid", bad ? "true" : "false");
    // clearing the box to retype must not snap the whole chart to a penny: flag the
    // field and hold the last good price until a usable one is typed
    if (bad) return;
    state.spot = v;
    scrub = null;
    markCustom(); update();
  });

  document.getElementById("addLeg").addEventListener("click", function () {
    state.legs.push({ id: ++uid, side: "long", kind: "call",
      strike: Math.max(1, Math.round(state.spot * 1.05)), premium: 2.00, qty: 1 });
    markCustom(); renderLegs(); update();
    var cards = legsEl.querySelectorAll(".leg");
    if (cards.length) cards[cards.length - 1].querySelector(".side").focus();
  });

  var ghostBox = document.getElementById("ghosts");
  var ghostLabel = document.getElementById("ghostLabel");
  // splitting the curve per leg only means anything with two or more legs, so the
  // checkbox goes unavailable rather than silently doing nothing
  function syncGhosts() {
    var can = state.legs.length > 1;
    ghostBox.disabled = !can;
    ghostLabel.classList.toggle("off", !can);
    showGhosts = ghostBox.checked && can;   // also covers a browser-restored checkbox
    document.getElementById("hint").textContent = !can
      ? "per-leg lines need two or more legs"
      : showGhosts ? "dashed lines are the individual legs"
      : "drag across the chart, or focus it and press ← →";
  }
  ghostBox.addEventListener("change", function () { syncGhosts(); draw(); });

  Array.prototype.forEach.call(document.querySelectorAll(".preset"), function (b) {
    b.addEventListener("click", function () { loadPreset(b.dataset.preset); });
  });

  var raf = 0;
  function onResize() {
    if (raf) return;
    raf = requestAnimationFrame(function () { raf = 0; draw(); });
  }
  window.addEventListener("resize", onResize);
  if (window.ResizeObserver) new ResizeObserver(onResize).observe(cvs.parentNode);

  loadPreset("condor");
})();
</script>

</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong