Files
options-pricer/frontend/strategy.html
ojy 2e565fae4d Add Top Movers screener (mid-cap+, options-tradable)
New /movers page surfaces Yahoo Finance's predefined screeners
(day_gainers, day_losers, most_actives, most_shorted_stocks)
filtered to common equities with market cap >= $2B, so every
listed name has a deep options chain. Per-row actions jump
straight into Chain / Vol Surface / IV Spike Scanner, or pin
the symbol to the Tracker watchlist.

- datafetch.ts: fetchMovers(category, count) using yf.screener,
  post-filtered to quoteType=EQUITY and marketCap >= $2B
- options.ts: GET /api/movers?category=&count=
- movers.html: Tabler page with 4-tab segmented control, sortable
  table, summary cards, volume-vs-avg ratio highlighting hot names
- All page sidebars: insert "Movers" link between Vol Surface
  and Scanner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:47:32 +00:00

1046 lines
59 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Strategy P/L — Options Pricer</title>
<link rel="stylesheet" href="/assets/tabler.min.css" />
<link rel="stylesheet" href="/assets/tabler-vendors.min.css" />
<script src="/assets/apexcharts.min.js"></script>
<style>
.chart-card { background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; }
.chart-card .card-header { background:transparent; border-bottom:1px solid #2d3045; }
#plChart { background:#1e2030; border-radius:0 0 .5rem .5rem; }
[x-cloak] { display:none !important; }
.leg-table th { background:#1a1c2e; color:#8b95a7; font-size:.7rem; text-transform:uppercase; letter-spacing:.05em; border-bottom:1px solid #2d3045; }
.leg-table td { border-bottom:1px solid #1e2030; color:#d0d5e0; font-size:.85rem; vertical-align:middle; }
.leg-table input, .leg-table select { background:#1e2030; border-color:#2d3045; color:#fff; }
.stat-box { background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; padding:.75rem 1rem; }
.stat-box .lbl { color:#8b95a7; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
.stat-box .val { font-size:1.15rem; font-weight:700; color:#fff; }
.val.pos { color:#51cf66; } .val.neg { color:#ff6b6b; } .val.amber { color:#ffd43b; }
.mono { font-family:'JetBrains Mono','Fira Code',monospace; }
.apexcharts-tooltip { background:#1e2030 !important; border:1px solid #2d3045 !important; color:#fff !important; }
.apexcharts-tooltip-title { background:#2d3045 !important; border-bottom:1px solid #3a3f5a !important; }
.toast-mini { position:fixed; bottom:1rem; right:1rem; z-index:1080; }
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="strategyApp()" x-init="init()">
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="d-flex align-items-center gap-2 text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-primary" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="4" y="8" width="4" height="8" rx="1"/>
<line x1="6" y1="4" x2="6" y2="8"/>
<line x1="6" y1="16" x2="6" y2="20"/>
<rect x="16" y="6" width="4" height="10" rx="1"/>
<line x1="18" y1="2" x2="18" y2="6"/>
<line x1="18" y1="16" x2="18" y2="22"/>
</svg>
<span class="fw-bold">Options Pricer</span>
</a>
</h1>
<div class="collapse navbar-collapse" id="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item ">
<a class="nav-link" href="index.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="4" y="4" width="6" height="5" rx="2"/>
<rect x="4" y="13" width="6" height="7" rx="2"/>
<rect x="14" y="4" width="6" height="11" rx="2"/>
<rect x="14" y="19" width="6" height="1" rx=".5"/>
</svg>
</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="chain.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="3" y="5" width="18" height="14" rx="2"/>
<path d="M3 10l18 0"/>
<path d="M10 5v14"/>
</svg>
</span>
<span class="nav-link-title">Options Chain</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="surface.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 12c1.333 -4.667 2.667 -7 4 -7s2.667 2.333 4 7s2.667 7 4 7s2.667 -2.333 4 -7"/>
</svg>
</span>
<span class="nav-link-title">Vol Surface</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="movers.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 17l6 -6l4 4l8 -8"/>
<path d="M14 7l7 0l0 7"/>
</svg>
</span>
<span class="nav-link-title">Movers</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="scanner.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="11" cy="11" r="7"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<path d="M11 8v6"/>
<path d="M8 11h6"/>
</svg>
</span>
<span class="nav-link-title">Scanner</span>
</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="strategy.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 19l4 -6l4 2l4 -8l4 5"/>
<path d="M4 4v16h16"/>
</svg>
</span>
<span class="nav-link-title">Strategy P/L</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="positions.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="3" y="7" width="18" height="13" rx="2"/>
<path d="M8 7v-2a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v2"/>
<line x1="12" y1="12" x2="12" y2="12.01"/>
<path d="M3 13a20 20 0 0 0 18 0"/>
</svg>
</span>
<span class="nav-link-title">Positions</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M12 12m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/>
<path d="M15 12l-3 -3"/>
</svg>
</span>
<span class="nav-link-title">Tracker</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="settings.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</span>
<span class="nav-link-title">Settings</span>
</a>
</li>
</ul>
</div>
</div>
</aside>
<!-- Page -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col-auto" x-show="symbols.length > 1" x-cloak>
<label class="form-label text-secondary mb-1" for="symPicker">Strategy</label>
<select id="symPicker" class="form-select form-select-sm fw-bold" style="min-width:7rem" :value="symbol" @change="switchSymbol($event.target.value)" aria-label="Pick strategy symbol">
<template x-for="s in symbols" :key="s"><option :value="s" x-text="s"></option></template>
</select>
</div>
<div class="col">
<h2 class="page-title">Strategy P/L Analyzer</h2>
<div class="text-secondary mt-1" x-show="legs.length > 0" x-cloak>
<span class="badge bg-purple-lt fs-6 me-2" x-text="strategyName"></span>
<span class="badge bg-blue-lt fs-6 me-2" x-show="dteSummary" x-text="dteSummary"></span>
<span x-show="symbols.length <= 1" x-text="symbol"></span><span x-show="symbols.length <= 1"> · </span>
<span x-text="legs.length + ' leg' + (legs.length===1?'':'s')"></span> ·
Spot <strong class="mono" x-text="spot > 0 ? '$'+spot.toFixed(2) : '—'"></strong>
<span x-show="symbols.length > 1" class="ms-2 text-secondary" x-text="'(' + symbols.length + ' symbols saved)'"></span>
</div>
</div>
<div class="col-auto" x-show="legs.length > 0" x-cloak>
<button class="btn btn-outline-primary btn-sm me-1" @click="reloadMarket()" :disabled="refreshing" title="Re-fetch spot, marks &amp; IVs; refresh entry price on unlocked legs">
<span x-show="refreshing" class="spinner-border spinner-border-sm me-1" role="status"></span>Reload
</button>
<button class="btn btn-outline-info btn-sm me-1" @click="saveToTracker()" :disabled="!symbol"
title="Add this symbol to the Tracker watchlist for IV / price-history monitoring">
Save to Tracker
</button>
<button class="btn btn-success btn-sm me-1" @click="enterPosition()" :disabled="savingOrder || activeLegs.length === 0"
title="Save this position (entered legs) — opens the Positions page">
<span x-show="savingOrder" class="spinner-border spinner-border-sm me-1" role="status"></span>Enter Position
</button>
<button class="btn btn-outline-danger btn-sm" @click="clearAll()">Clear all</button>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Empty state -->
<div class="card text-center py-5" x-show="legs.length === 0" x-cloak>
<div class="card-body">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-secondary mb-3"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l4 -6l4 2l4 -8l4 5"/><path d="M4 4v16h16"/></svg>
<h3 class="text-secondary">No strategy built yet</h3>
<p class="text-muted">Go to the <a href="chain.html">Options Chain</a>, then click <span class="badge bg-success">B</span> (buy) or <span class="badge bg-danger">S</span> (sell) on options to add legs — or add one manually below.</p>
<button class="btn btn-primary btn-sm mt-2" @click="showManual = !showManual">+ Add leg manually</button>
</div>
</div>
<!-- P/L chart -->
<div class="chart-card mb-3" x-show="legs.length > 0" x-cloak>
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-3">
<h3 class="card-title text-white mb-0">Profit / Loss vs. Underlying Price</h3>
<div class="d-flex align-items-center flex-wrap gap-3">
<!-- pan + zoom price range -->
<div class="btn-group btn-group-sm" role="group" aria-label="Pan and zoom price range">
<button class="btn btn-outline-secondary" @click="panLeft()" title="Move left — show lower prices"></button>
<button class="btn btn-outline-secondary" @click="zoomOut()" title="Zoom out — wider price range"></button>
<button class="btn btn-outline-secondary" disabled style="min-width:5rem" x-text="'±' + lastHalfPct + '%'"></button>
<button class="btn btn-outline-secondary" @click="zoomIn()" title="Zoom in — narrower price range">+</button>
<button class="btn btn-outline-secondary" @click="panRight()" title="Move right — show higher prices"></button>
<button class="btn btn-outline-secondary" @click="zoomFit()" title="Reset pan & zoom">Fit</button>
</div>
<!-- time slider -->
<div class="d-flex align-items-center flex-wrap gap-2" style="min-width:300px;">
<span class="text-secondary small text-nowrap">Now</span>
<input type="range" class="form-range" min="0" :max="maxDTE" step="1" x-model.number="dteOffset" @input="scheduleRender()" style="min-width:150px;">
<span class="text-secondary small text-nowrap">Exp</span>
<span class="badge bg-blue-lt text-nowrap" x-text="dteLabel"></span>
<small class="text-secondary w-100" x-show="hasMultiExpiry" x-text="'Remaining: ' + expiryBreakdown"></small>
</div>
</div>
</div>
<div class="card-body p-0">
<div id="plChart" style="min-height:380px;" role="img" aria-label="Profit and loss diagram"></div>
</div>
</div>
<!-- Stats -->
<div class="row g-2 mb-4" x-show="legs.length > 0" x-cloak>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net</div><div class="val" :class="netCost>=0?'neg':'pos'"><span x-text="netCost>=0?'Debit ':'Credit '"></span><span x-text="fmtMoney(Math.abs(netCost))"></span></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Max Profit</div><div class="val pos" x-text="stats.maxProfit"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Max Loss</div><div class="val neg" x-text="stats.maxLoss"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Break-even(s)</div><div class="val amber mono" style="font-size:.95rem" x-text="stats.breakevens"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Δ (shares)</div><div class="val mono" :class="stats.delta>=0?'pos':'neg'" x-text="stats.delta.toFixed(1)"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Γ</div><div class="val mono" x-text="stats.gamma.toFixed(2)"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Θ / day</div><div class="val mono" :class="stats.theta>=0?'pos':'neg'" x-text="fmtMoney(stats.theta)"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Vega / 1%</div><div class="val mono" :class="stats.vega>=0?'pos':'neg'" x-text="fmtMoney(stats.vega)"></div></div></div>
</div>
<!-- Legs table (at the bottom) -->
<div class="card mb-4" x-show="legs.length > 0 || showManual" x-cloak>
<div class="card-header d-flex align-items-center justify-content-between">
<h3 class="card-title mb-0">Legs <span class="text-secondary small fw-normal">— Mark = live mid · 🔒 locks entry price · uncheck to drop from chart</span></h3>
<div>
<span class="me-3" :class="netCost >= 0 ? 'text-danger' : 'text-success'">
Net <strong x-text="netCost >= 0 ? 'debit' : 'credit'"></strong>:
<strong class="mono" x-text="fmtMoney(Math.abs(netCost))"></strong>
</span>
<button class="btn btn-outline-primary btn-sm" @click="showManual = !showManual" x-text="showManual ? 'Hide manual entry' : '+ Add leg manually'"></button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm leg-table mb-0">
<thead>
<tr>
<th class="text-center" style="width:3rem">Show</th>
<th>Side</th><th>Qty</th><th>Type</th><th class="text-end">Strike</th>
<th style="min-width:13rem">
<div class="d-flex align-items-center gap-1 flex-nowrap" style="text-transform:none; letter-spacing:0;">
<span style="text-transform:uppercase; letter-spacing:.05em; font-size:.7rem;">Expiry</span>
<select class="form-select form-select-sm ms-1" :disabled="!expiryLocked" :value="masterExpiry" @change="onMasterExpiry($event.target.value)"
:title="expiryLocked ? 'All legs share this expiry — pick to change them all' : 'Click 🔒 to link all legs to one expiry'">
<template x-for="e in masterExpiryOpts" :key="e"><option :value="e" x-text="e"></option></template>
</select>
<button class="btn btn-sm btn-outline-secondary px-1" @click="toggleExpiryLock()"
:title="expiryLocked ? 'Unlock — each leg can have its own expiry' : 'Lock all legs to one expiry'"
x-text="expiryLocked ? '🔒' : '🔓'"></button>
</div>
</th>
<th class="text-end">Entry $</th><th class="text-end">Mark</th><th class="text-end">IV</th>
<th class="text-end">Cost</th><th class="text-end">Δ</th><th class="text-end">Θ/d</th><th></th>
</tr>
</thead>
<tbody>
<template x-for="lv in legsView" :key="lv.id">
<tr :style="lv.enabled === false ? 'opacity:.4' : ''">
<td class="text-center"><input type="checkbox" class="form-check-input m-0" :checked="lv.enabled !== false" @change="toggleLeg(lv.id)" :aria-label="'Show leg '+lv.strike+lv.type"></td>
<td style="width:7rem">
<select class="form-select form-select-sm" :value="lv.side" @change="updateLeg(lv.id, { side: $event.target.value })">
<option value="long">Long</option><option value="short">Short</option>
</select>
</td>
<td style="width:5rem"><input type="number" min="1" step="1" class="form-control form-control-sm" :value="lv.qty" @change="updateLeg(lv.id, { qty: Math.max(1, Math.round(+$event.target.value||1)) })"></td>
<td><span class="badge" :class="lv.type==='call' ? 'bg-success-lt text-success' : 'bg-danger-lt text-danger'" x-text="lv.type"></span></td>
<td class="text-end mono" style="width:7rem">
<select x-show="hasStrikeOpts(lv)" class="form-select form-select-sm text-end" :value="lv.strike" @change="changeStrike(lv.id, +$event.target.value)" title="Change strike — entry price, IV & mark update from the loaded chain">
<template x-for="k in strikeOpts(lv)" :key="k"><option :value="k" x-text="k"></option></template>
</select>
<span x-show="!hasStrikeOpts(lv)" x-text="lv.strike"></span>
</td>
<td class="mono small" style="width:11rem">
<div class="d-flex align-items-center gap-1 flex-nowrap">
<select x-show="hasExpiryOpts(lv)" class="form-select form-select-sm" :value="lv.expiry" :disabled="expiryLocked" @change="changeExpiry(lv.id, $event.target.value)" :title="expiryLocked ? 'Use the master Expiry at the top to change all legs together' : 'Change expiry — strike, entry, IV & mark update from the new chain'">
<template x-for="e in expiryOpts(lv)" :key="e"><option :value="e" x-text="e"></option></template>
</select>
<span x-show="!hasExpiryOpts(lv)" x-text="lv.expiry"></span>
<span class="badge bg-secondary-lt text-secondary" :title="'Days to expiry from today'" x-text="legDTEStr(lv)"></span>
</div>
</td>
<td style="width:8.5rem">
<div class="input-group input-group-sm flex-nowrap">
<input type="number" min="0" step="0.01" class="form-control form-control-sm text-end" :class="lv.locked ? 'opacity-75' : ''" :value="lv.entryPrice" :disabled="lv.locked" @change="updateLeg(lv.id, { entryPrice: Math.max(0, +$event.target.value||0) })">
<button class="btn btn-sm btn-outline-secondary px-1" @click="updateLeg(lv.id, { locked: !lv.locked })" :title="lv.locked ? 'Entry price locked — click to unlock (reload will update it)' : 'Click to lock entry price (reload won\'t change it)'" x-text="lv.locked ? '🔒' : '🔓'"></button>
</div>
</td>
<td class="text-end mono small">
<template x-if="lv.currentMark != null">
<span>$<span x-text="lv.currentMark.toFixed(2)"></span><span x-show="Math.abs(lv.currentMark - lv.entryPrice) > 0.005" :class="(lv.currentMark - lv.entryPrice) >= 0 ? 'text-success' : 'text-danger'" x-text="(lv.currentMark - lv.entryPrice >= 0 ? ' +' : ' ') + (lv.currentMark - lv.entryPrice).toFixed(2)"></span></span>
</template>
<template x-if="lv.currentMark == null"><span class="text-secondary"></span></template>
</td>
<td class="text-end mono small" x-text="lv.iv > 0 ? (lv.iv*100).toFixed(1)+'%' : '—'"></td>
<td class="text-end mono" :class="lv.cost >= 0 ? 'text-danger' : 'text-success'" x-text="fmtMoney(lv.cost)"></td>
<td class="text-end mono small" :class="lv.delta>=0?'text-success':'text-danger'" x-text="lv.delta.toFixed(1)"></td>
<td class="text-end mono small text-danger" x-text="lv.theta.toFixed(1)"></td>
<td class="text-end"><button class="btn btn-sm btn-ghost-danger" @click="removeLeg(lv.id)" aria-label="Remove leg"></button></td>
</tr>
</template>
<!-- Manual entry row -->
<template x-if="showManual">
<tr style="background:#161824;">
<td></td>
<td><select class="form-select form-select-sm" x-model="manual.side"><option value="long">Long</option><option value="short">Short</option></select></td>
<td><input type="number" min="1" step="1" class="form-control form-control-sm" x-model.number="manual.qty"></td>
<td><select class="form-select form-select-sm" x-model="manual.type"><option value="call">call</option><option value="put">put</option></select></td>
<td><input type="number" step="0.5" class="form-control form-control-sm text-end" placeholder="strike" x-model.number="manual.strike"></td>
<td><input type="date" class="form-control form-control-sm" x-model="manual.expiry"></td>
<td><input type="number" min="0" step="0.01" class="form-control form-control-sm text-end" placeholder="entry $" x-model.number="manual.entryPrice"></td>
<td></td>
<td><input type="number" min="0" step="0.5" class="form-control form-control-sm text-end" placeholder="IV %" x-model.number="manual.ivPct"></td>
<td colspan="4" class="text-end"><button class="btn btn-sm btn-primary" @click="addManualLeg()">Add leg</button></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast-mini" x-show="toast" x-transition x-cloak>
<div class="alert alert-success py-2 px-3 mb-0" x-text="toast"></div>
</div>
</div>
<script src="/assets/tabler.min.js" defer></script>
<script src="/assets/blackscholes.js"></script>
<script src="/assets/strategy-store.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script>
const R = 0.05; // risk-free rate (matches backend)
const MULT = 100; // contract multiplier
const DAY_MS = 86400000;
const CHART_BG='#1e2030', CHART_GRID='#2d3045', CHART_LABEL='#8b95a7';
const COLOR_EXP='#4dd4ac', COLOR_TN='#a98eda';
function legDTE(leg) {
// days from now to expiry; can be negative if expired
const t = Date.parse(leg.expiry + 'T00:00:00Z');
return (t - Date.now()) / DAY_MS;
}
function legSign(leg) { return leg.side === 'short' ? -1 : 1; }
function legCost(leg) { return legSign(leg) * leg.qty * MULT * (leg.entryPrice || 0); }
/** Value (per share) of a leg at underlying S, evaluated `offsetDays` from now. */
function legValueAt(leg, S, offsetDays) {
const remDays = legDTE(leg) - offsetDays;
if (remDays <= 0) return BS.intrinsic(S, leg.strike, leg.type);
const sigma = leg.iv > 0 ? leg.iv : 0.0001;
return BS.bsPrice(S, leg.strike, remDays / 365, R, sigma, leg.type);
}
/** Position P/L at underlying S, evaluated `offsetDays` from now. */
function plAt(legs, netCost, S, offsetDays) {
let v = 0;
for (const leg of legs) v += legSign(leg) * leg.qty * MULT * legValueAt(leg, S, offsetDays);
return v - netCost;
}
function detectStrategy(legs) {
const n = legs.length;
if (n === 0) return 'Empty';
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const sw = s => s === 'long' ? 'Long' : 'Short';
if (n === 1) return `${sw(legs[0].side)} ${cap(legs[0].type)}`;
const ls = [...legs].sort((a,b)=> a.type.localeCompare(b.type) || a.strike-b.strike || a.expiry.localeCompare(b.expiry));
const allSameExp = ls.every(l => l.expiry === ls[0].expiry);
const calls = ls.filter(l=>l.type==='call'), puts = ls.filter(l=>l.type==='put');
if (n === 2) {
const [a,b] = ls;
if (a.type===b.type && a.expiry===b.expiry && a.side!==b.side && a.qty===b.qty) {
const L = a.side==='long'?a:b, Sh = a.side==='long'?b:a;
if (a.type==='call') return L.strike < Sh.strike ? 'Bull Call Spread (debit)' : 'Bear Call Spread (credit)';
return L.strike > Sh.strike ? 'Bear Put Spread (debit)' : 'Bull Put Spread (credit)';
}
if (calls.length===1 && puts.length===1 && a.side===b.side && a.expiry===b.expiry)
return calls[0].strike===puts[0].strike ? `${sw(a.side)} Straddle` : `${sw(a.side)} Strangle`;
if (a.type===b.type && a.strike===b.strike && a.expiry!==b.expiry && a.side!==b.side) return 'Calendar Spread';
if (a.type===b.type && a.strike!==b.strike && a.expiry!==b.expiry && a.side!==b.side) return 'Diagonal Spread';
return 'Custom (2 legs)';
}
if (n === 3) {
if (allSameExp && (calls.length===3 || puts.length===3)) {
const [lo,mid,hi] = ls;
if (lo.side===hi.side && lo.side!==mid.side && lo.qty===hi.qty && mid.qty===2*lo.qty)
return `${lo.side==='long'?'Long':'Short'} ${cap(ls[0].type)} Butterfly`;
}
return 'Custom (3 legs)';
}
if (n === 4) {
if (allSameExp && calls.length===2 && puts.length===2) {
const [pL,pH] = puts, [cL,cH] = calls;
const ic = pL.side==='long'&&pH.side==='short'&&cL.side==='short'&&cH.side==='long';
if (ic && pH.strike===cL.strike) return 'Iron Butterfly';
if (ic) return 'Iron Condor';
const ric = pL.side==='short'&&pH.side==='long'&&cL.side==='long'&&cH.side==='short';
if (ric) return 'Reverse Iron Condor';
}
if (allSameExp && calls.length===4) return 'Call Condor';
if (allSameExp && puts.length===4) return 'Put Condor';
return 'Custom (4 legs)';
}
return `Custom (${n} legs)`;
}
function fmtMoney(v) {
if (v == null || !isFinite(v)) return '—';
const sign = v < 0 ? '-' : '';
const a = Math.abs(v);
return sign + '$' + a.toLocaleString('en-US', { maximumFractionDigits: a < 100 ? 2 : 0 });
}
function strategyApp() {
return {
// state
symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0,
xZoom: 1, xPan: 0, lastHalfPct: 0,
expiryLocked: false, masterExpiry: '',
refreshing: false, savingOrder: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
chart: null, _renderTimer: null,
_chainCache: {}, // "SYMBOL@EXPIRY" -> { "strike@type": optionRow }
_expiryCache: {}, // "SYMBOL" -> ["2026-06-20", ...]
init() {
this.reload();
this._chainCache = this._seedChainCache();
if (this.symbol) this._ensureExpiries(this.symbol);
// pull live spot / marks / IVs (and per-expiry chains) on open
if (this.legs.length > 0 && this.symbol) this.reloadMarket(false);
// re-sync if another tab changed the basket
window.addEventListener('storage', (e) => { if (e.key === StrategyStore.KEY) this.reload(); });
},
// seed the strike picker from whatever chain was last loaded on chain.html
_seedChainCache() {
try {
const vs = (typeof ViewState !== 'undefined') ? ViewState.load('chain') : null;
if (!vs || !vs.symbol || !vs.expiry) return {};
const map = {};
for (const o of (vs.calls || [])) map[Number(o.strike) + '@call'] = o;
for (const o of (vs.puts || [])) map[Number(o.strike) + '@put'] = o;
return Object.keys(map).length ? { [vs.symbol + '@' + vs.expiry]: map } : {};
} catch { return {}; }
},
_legMap(lv) { return this._chainCache[lv.symbol + '@' + lv.expiry] || null; },
hasStrikeOpts(lv) {
const m = this._legMap(lv);
if (!m) return false;
for (const k of Object.keys(m)) if (k.endsWith('@' + lv.type)) return true;
return false;
},
strikeOpts(lv) {
const m = this._legMap(lv);
const out = [];
if (m) for (const k of Object.keys(m)) { if (k.endsWith('@' + lv.type)) out.push(parseFloat(k)); }
if (!out.includes(lv.strike)) out.push(lv.strike);
return out.sort((a, b) => a - b);
},
changeStrike(id, newStrike) {
const leg = this.legs.find(l => l.id === id);
if (!leg || !Number.isFinite(newStrike) || newStrike === leg.strike) return;
const m = this._legMap(leg);
const o = m && m[Number(newStrike) + '@' + leg.type];
const patch = { strike: newStrike };
if (o) {
const mid = Math.round(((o.midPrice ?? o.mid ?? o.bsPrice ?? leg.entryPrice) || 0) * 100) / 100;
patch.entryPrice = mid;
patch.currentMark = mid;
if (o.iv > 0) patch.iv = o.iv;
patch.locked = false; // it's a different contract now — start fresh
}
this.updateLeg(id, patch);
},
// ---- expiry picker -----------------------------------------------
hasExpiryOpts(lv) { return Array.isArray(this._expiryCache[lv.symbol]) && this._expiryCache[lv.symbol].length > 0; },
expiryOpts(lv) {
const list = (this._expiryCache[lv.symbol] || []).slice();
if (!list.includes(lv.expiry)) list.push(lv.expiry);
return list.sort();
},
async _ensureExpiries(sym) {
if (!sym) return;
const have = this._expiryCache[sym];
if (Array.isArray(have) && have.length > 0) return;
try {
const r = await fetch('/api/expirations?symbol=' + encodeURIComponent(sym));
if (!r.ok) return;
const e = await r.json();
const data = e.data ?? e;
const list = data.expirations || (Array.isArray(data) ? data : []);
if (list.length) this._expiryCache = { ...this._expiryCache, [sym]: list };
} catch {}
},
async _ensureChain(sym, exp) {
const key = sym + '@' + exp;
if (this._chainCache[key]) return this._chainCache[key];
try {
const r = await fetch('/api/chain?symbol=' + encodeURIComponent(sym) + '&expiry=' + encodeURIComponent(exp));
if (!r.ok) return null;
const e = await r.json();
const snap = e.data?.snapshots?.[0];
if (!snap) return null;
const map = {};
for (const o of (snap.chain || [])) {
const t = (o.type || o.optionType || '').toLowerCase();
map[Number(o.strike) + '@' + t] = o;
}
this._chainCache = { ...this._chainCache, [key]: map };
if (snap.spot > 0) {
this.spot = snap.spot;
const st = StrategyStore.load(); st.spotSnapshot = snap.spot; StrategyStore.save(st);
}
return map;
} catch { return null; }
},
// ---- master expiry (lock-all-legs-to-one-date) -------------------
get masterExpiryOpts() {
const set = new Set();
for (const l of this.legs) if (l.expiry) set.add(l.expiry);
const cache = this._expiryCache[this.symbol] || [];
for (const e of cache) set.add(e);
return [...set].sort();
},
toggleExpiryLock() {
if (this.expiryLocked) { this.expiryLocked = false; return; }
// turning on — pick the most common leg expiry as the master, then snap
const counts = {};
for (const l of this.legs) counts[l.expiry] = (counts[l.expiry] || 0) + 1;
let best = '', bestN = -1;
for (const e of Object.keys(counts)) if (counts[e] > bestN) { best = e; bestN = counts[e]; }
this.masterExpiry = best || (this.legs[0] && this.legs[0].expiry) || '';
this.expiryLocked = true;
if (this.masterExpiry) this.changeAllExpiry(this.masterExpiry);
},
async onMasterExpiry(newExp) {
if (!this.expiryLocked || !newExp) return;
this.masterExpiry = newExp;
await this.changeAllExpiry(newExp);
},
async changeAllExpiry(newExp) {
const ids = this.legs.filter(l => l.expiry !== newExp).map(l => l.id);
for (const id of ids) {
await this.changeExpiry(id, newExp);
}
},
async changeExpiry(id, newExpiry) {
const leg = this.legs.find(l => l.id === id);
if (!leg || !newExpiry || newExpiry === leg.expiry) return;
this.refreshing = true;
try {
const map = await this._ensureChain(leg.symbol, newExpiry);
if (!map) { this.flash('No chain available for ' + newExpiry); return; }
// exact strike if it exists for this type, else closest
let opt = map[Number(leg.strike) + '@' + leg.type];
let pickedStrike = leg.strike;
if (!opt) {
let bestD = Infinity;
for (const k of Object.keys(map)) {
if (!k.endsWith('@' + leg.type)) continue;
const s = parseFloat(k);
const d = Math.abs(s - leg.strike);
if (d < bestD) { bestD = d; opt = map[k]; pickedStrike = s; }
}
}
const patch = { expiry: newExpiry, strike: pickedStrike, locked: false };
if (opt) {
const mid = Math.round(((opt.midPrice ?? opt.mid ?? opt.bsPrice ?? leg.entryPrice) || 0) * 100) / 100;
patch.entryPrice = mid;
patch.currentMark = mid;
if (opt.iv > 0) patch.iv = opt.iv;
}
this.updateLeg(id, patch);
this.flash('Expiry → ' + newExpiry + (pickedStrike !== leg.strike ? ' (strike ' + leg.strike + ' → ' + pickedStrike + ')' : ''));
} finally {
this.refreshing = false;
}
},
reload() {
const st = StrategyStore.load();
this.symbol = st.symbol || '';
this.symbols = st.symbols || [];
this.spot = st.spotSnapshot || 0;
this.legs = st.legs || [];
if (this.legs.length && (!this.masterExpiry || !this.legs.some(l => l.expiry === this.masterExpiry))) {
this.masterExpiry = this.legs[0].expiry || '';
}
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
if (this.legs.length > 0) this.$nextTick(() => this.renderChart()); else if (this.chart) this.chart.updateSeries([{name:'P/L',data:[]},{name:'P/L',data:[]}]);
},
switchSymbol(sym) {
if (!sym || sym === this.symbol) return;
StrategyStore.setActive(sym);
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
this.expiryLocked = false; this.masterExpiry = '';
this.reload();
this._ensureExpiries(sym);
if (this.legs.length > 0) this.reloadMarket(false);
},
// ── derived ───────────────────────────────────────────
// only legs with the "Show" checkbox ticked drive the chart / stats / Greeks
get activeLegs() { return this.legs.filter(l => l.enabled !== false); },
get netCost() { return this.activeLegs.reduce((s,l)=> s + legCost(l), 0); },
get strategyName() { return detectStrategy(this.activeLegs); },
get maxDTE() { const a = this.activeLegs; return a.length ? Math.max(1, Math.ceil(Math.max(0, ...a.map(legDTE)))) : 1; },
// exact (not floored) days to the earliest expiry — so the "expiration"
// curve uses true intrinsic value (sharp hockey stick), not a near-expiry BS approx
get minDTE() { const a = this.activeLegs; return a.length ? Math.max(0, Math.min(...a.map(legDTE))) : 0; },
get dteLabel() {
const d = new Date(Date.now() + this.dteOffset * DAY_MS);
const ds = d.toISOString().slice(0,10);
if (this.dteOffset === 0) return 'Today (' + ds + ')';
return 'T+' + this.dteOffset + 'd · ' + ds;
},
get hasMultiExpiry() {
return new Set(this.activeLegs.map(l => l.expiry)).size > 1;
},
get expiryBreakdown() {
const uniq = [...new Set(this.activeLegs.map(l => l.expiry))].sort();
return uniq.map(e => {
const rem = Math.max(0, Math.round(legDTE({expiry:e}) - this.dteOffset));
return e + ' (' + rem + 'd)';
}).join(' · ');
},
legDTEStr(lv) {
const d = legDTE(lv);
if (d < 0) return 'exp';
if (d < 1) return '<1d';
return Math.round(d) + 'd';
},
get dteSummary() {
const uniq = [...new Set(this.activeLegs.map(l => l.expiry))];
if (uniq.length === 0) return '';
const days = uniq.map(e => Math.max(0, Math.round(legDTE({expiry:e}))));
if (days.length === 1) return days[0] + ' DTE';
days.sort((a,b) => a - b);
return days.join(' / ') + ' DTE';
},
get legsView() {
return this.legs.map(l => {
const dteY = Math.max(legDTE(l), 0) / 365;
const sigma = l.iv > 0 ? l.iv : 0.0001;
const g = (this.spot > 0)
? BS.bsGreeks(this.spot, l.strike, dteY, R, sigma, l.type)
: { delta:0, gamma:0, theta:0, vega:0 };
const k = legSign(l) * l.qty * MULT;
return {
...l,
cost: legCost(l),
delta: g.delta * k,
gamma: g.gamma * k,
theta: g.theta * k,
vega: g.vega * k,
};
});
},
get stats() {
const legs = this.activeLegs, net = this.netCost, spot = this.spot;
if (legs.length === 0) return { maxProfit:'—', maxLoss:'—', breakevens:'—', delta:0, gamma:0, theta:0, vega:0 };
// dense expiration-curve sample for breakevens / extremes / unbounded
const lo0 = 0.01;
const hi0 = Math.max(spot * 2, ...legs.map(l=>l.strike)) * 1.5 + 10;
const Nd = 600;
const xs = [], ys = [];
for (let i = 0; i <= Nd; i++) {
const x = lo0 + (hi0 - lo0) * i / Nd;
xs.push(x); ys.push(plAt(legs, net, x, this.minDTE));
}
// breakevens (sign changes)
const bes = [];
for (let i = 1; i <= Nd; i++) {
const y0 = ys[i-1], y1 = ys[i];
if (y0 === 0) { bes.push(xs[i-1]); continue; }
if ((y0 < 0 && y1 > 0) || (y0 > 0 && y1 < 0)) {
const x = xs[i-1] + (0 - y0) * (xs[i]-xs[i-1]) / (y1 - y0);
bes.push(x);
}
}
const uniqBE = bes.filter((v,i)=> i===0 || Math.abs(v - bes[i-1]) > 1e-6);
// extremes — include the grid samples AND the exact kink points (strikes)
// plus the sample bounds, since piecewise payoffs peak/trough at strikes
let maxY = -Infinity, minY = Infinity;
for (const y of ys) { if (y > maxY) maxY = y; if (y < minY) minY = y; }
for (const k of new Set([...legs.map(l=>l.strike), lo0, hi0])) {
const y = plAt(legs, net, k, this.minDTE);
if (y > maxY) maxY = y; if (y < minY) minY = y;
}
// unbounded detection from the far-right slope of the expiration curve
// (downside is always bounded since the underlying can't go below 0)
const slopeR = (ys[Nd] - ys[Nd-1]) / (xs[Nd] - xs[Nd-1]);
const maxProfit = slopeR > 1e-2 ? 'Unlimited' : fmtMoney(maxY);
const maxLoss = slopeR < -1e-2 ? 'Unlimited' : fmtMoney(minY);
// net greeks now
let d=0,g=0,t=0,v=0;
for (const l of legs) {
const dteY = Math.max(legDTE(l), 0) / 365;
const sigma = l.iv > 0 ? l.iv : 0.0001;
const gr = (spot>0) ? BS.bsGreeks(spot, l.strike, dteY, R, sigma, l.type) : {delta:0,gamma:0,theta:0,vega:0};
const k = legSign(l) * l.qty * MULT;
d += gr.delta*k; g += gr.gamma*k; t += gr.theta*k; v += gr.vega*k;
}
return {
maxProfit, maxLoss,
breakevens: uniqBE.length ? uniqBE.map(x=>'$'+x.toFixed(2)).join(' / ') : '—',
delta:d, gamma:g, theta:t, vega:v,
};
},
// ── actions ───────────────────────────────────────────
updateLeg(id, patch) {
StrategyStore.updateLeg(id, patch);
this.reload();
},
toggleLeg(id) {
const leg = this.legs.find(l => l.id === id);
const cur = leg ? leg.enabled !== false : true;
StrategyStore.updateLeg(id, { enabled: !cur });
// refresh legs without resetting the date slider
const st = StrategyStore.load();
this.legs = st.legs || [];
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
this.$nextTick(() => this.renderChart());
},
removeLeg(id) {
StrategyStore.removeLeg(id);
this.reload();
},
clearAll() {
if (!confirm('Clear all ' + (this.symbol || '') + ' legs?')) return;
StrategyStore.clear();
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
this.expiryLocked = false; this.masterExpiry = '';
if (this.chart) { this.chart.destroy(); this.chart = null; }
this.reload(); // re-renders the chart if another symbol's basket is now active
},
addManualLeg() {
const m = this.manual;
if (!m.strike || m.strike <= 0 || !m.expiry) { alert('Need a strike and expiry.'); return; }
StrategyStore.addLeg({
symbol: this.symbol || 'MANUAL',
expiry: m.expiry, type: m.type, strike: +m.strike,
side: m.side, qty: Math.max(1, Math.round(+m.qty||1)),
entryPrice: Math.max(0, +m.entryPrice||0),
iv: (+m.ivPct||0) / 100,
});
this.manual = { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null };
this.reload();
this.flash('Leg added');
},
// Re-fetch spot + each leg's current mark & IV.
// When repriceUnlocked (default — the Reload button), unlocked legs also
// get their entry price reset to the current mark; locked legs always keep it.
async reloadMarket(repriceUnlocked = true) {
if (!this.symbol || this.legs.length === 0) return;
this.refreshing = true;
this._ensureExpiries(this.symbol); // fire-and-forget; fills the expiry dropdown
try {
const expiries = [...new Set(this.legs.map(l => l.expiry).filter(Boolean))];
const byExpiry = {}; // expiry -> { strike@type -> option }
let spot = 0;
for (const exp of expiries) {
const r = await fetch('/api/chain?symbol=' + encodeURIComponent(this.symbol) + '&expiry=' + encodeURIComponent(exp));
if (!r.ok) continue;
const e = await r.json();
const snap = e.data?.snapshots?.[0];
if (!snap) continue;
if (snap.spot > 0) spot = snap.spot;
const map = {};
for (const o of (snap.chain || [])) {
const t = (o.type || o.optionType || '').toLowerCase();
map[Number(o.strike) + '@' + t] = o;
}
byExpiry[exp] = map;
}
// make these chains available to the strike picker
const cacheAdds = {};
for (const exp of Object.keys(byExpiry)) cacheAdds[this.symbol + '@' + exp] = byExpiry[exp];
this._chainCache = { ...this._chainCache, ...cacheAdds };
const st = StrategyStore.load();
let updated = 0, relinked = 0;
for (const leg of st.legs) {
const map = byExpiry[leg.expiry];
if (!map) continue;
const o = map[Number(leg.strike) + '@' + leg.type];
if (!o) continue;
const mark = Math.round((o.midPrice ?? o.mid ?? o.bsPrice ?? 0) * 100) / 100;
leg.currentMark = mark;
if (o.iv > 0) leg.iv = o.iv;
if (!leg.locked && repriceUnlocked && mark > 0) { leg.entryPrice = mark; relinked++; }
updated++;
}
if (spot > 0) st.spotSnapshot = spot;
StrategyStore.save(st);
this.legs = st.legs;
if (spot > 0) this.spot = spot;
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
this.$nextTick(() => this.renderChart());
if (repriceUnlocked) {
this.flash(updated > 0
? `Reloaded ${updated} leg${updated===1?'':'s'}${relinked?` (${relinked} re-priced)`:''} · spot $${(spot||this.spot).toFixed(2)}`
: 'Reloaded — no matching contracts found in the current chain');
}
} catch (e) {
if (repriceUnlocked) this.flash('Reload failed: ' + e.message);
} finally {
this.refreshing = false;
}
},
flash(msg) { this.toast = msg; setTimeout(()=>{ this.toast=''; }, 2500); },
// Add the current symbol to the Tracker watchlist (localStorage).
saveToTracker() {
if (!this.symbol) return;
const sym = this.symbol.toUpperCase().trim();
let wl = [];
try { wl = JSON.parse(localStorage.getItem('optionsPricer:watchlist') || '[]'); } catch {}
if (!wl.includes(sym)) wl.push(sym);
wl.sort();
try { localStorage.setItem('optionsPricer:watchlist', JSON.stringify(wl)); } catch {}
this.flash(sym + ' added to Tracker watchlist (' + wl.length + ' symbol' + (wl.length===1?'':'s') + ')');
},
// POST the current active basket to /api/orders, then navigate to Positions.
async enterPosition() {
const legs = this.activeLegs;
if (legs.length === 0 || !this.symbol) return;
this.savingOrder = true;
try {
const body = {
symbol: this.symbol,
name: this.strategyName,
entryCost: this.netCost,
legs: legs.map(l => ({
symbol: l.symbol, expiry: l.expiry, type: l.type, strike: l.strike,
side: l.side, qty: l.qty, entryPrice: l.entryPrice, iv: l.iv, locked: !!l.locked,
})),
};
const r = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || ('HTTP ' + r.status));
this.flash('Position #' + d.data.id + ' entered — opening Positions…');
setTimeout(() => { window.location.href = '/positions.html'; }, 700);
} catch (e) {
this.flash('Enter failed: ' + e.message);
} finally {
this.savingOrder = false;
}
},
scheduleRender() {
clearTimeout(this._renderTimer);
this._renderTimer = setTimeout(() => this.renderChart(), 40);
},
zoomIn() { this.xZoom = Math.max(0.2, +(this.xZoom / 1.4).toFixed(3)); this.renderChart(); },
zoomOut() { this.xZoom = Math.min(8, +(this.xZoom * 1.4).toFixed(3)); this.renderChart(); },
zoomFit() { this.xZoom = 1; this.xPan = 0; this.renderChart(); },
_panStep() { return 0.4 * (this._lastHalf || (Math.max(this.spot, 1) * 0.22)); },
panLeft() { this.xPan -= this._panStep(); this.renderChart(); },
panRight() { this.xPan += this._panStep(); this.renderChart(); },
renderChart() {
const legs = this.activeLegs, net = this.netCost;
if (legs.length === 0) {
if (this.chart) this.chart.updateSeries([{ name: 'P/L', data: [] }, { name: 'P/L', data: [] }]);
return;
}
const spot = this.spot > 0 ? this.spot : (legs.reduce((s,l)=>s+l.strike,0)/legs.length);
const strikes = legs.map(l=>l.strike);
const minK = Math.min(...strikes), maxK = Math.max(...strikes);
const span = maxK - minK;
const baseHalf = Math.max(spot*0.22, span*1.5, (maxK-spot)*1.4, (spot-minK)*1.4, 5);
const half = baseHalf * (this.xZoom || 1);
const center = spot + (this.xPan || 0);
const lo = Math.max(0.01, center - half), hi = center + half;
this.lastHalfPct = spot > 0 ? Math.round(half / spot * 100) : 0;
this._lastHalf = half; this._lastSpot = spot;
const expAt = this.minDTE;
const N = 221;
// x-grid = evenly spaced points + the exact strike kinks (so payoff
// vertices — butterfly peak, condor body, etc. — render sharply)
const xGrid = [];
for (let i = 0; i <= N; i++) xGrid.push(lo + (hi-lo)*i/N);
for (const k of strikes) if (k > lo && k < hi) xGrid.push(k);
xGrid.sort((a,b)=>a-b);
const expData = [], tnData = [];
let yMin = Infinity, yMax = -Infinity;
for (const x of xGrid) {
const ye = plAt(legs, net, x, expAt);
const yt = plAt(legs, net, x, this.dteOffset);
expData.push([x, +ye.toFixed(2)]);
tnData.push([x, +yt.toFixed(2)]);
yMin = Math.min(yMin, ye, yt); yMax = Math.max(yMax, ye, yt);
}
// Keep the chart readable when one tail is (near-)unbounded: don't let it
// dominate the y-axis so badly the rest of the curve is a flat sliver.
const aHi = Math.max(yMax, 0), aLo = Math.max(-yMin, 0);
if (aLo > 1 && aHi > 6 * aLo) yMax = 6 * aLo;
if (aHi > 1 && aLo > 6 * aHi) yMin = -6 * aHi;
const pad = Math.max((yMax - yMin) * 0.08, 1);
yMin -= pad; yMax += pad;
// breakevens for light vertical lines
const bes = [];
for (let i = 1; i < expData.length; i++) {
const y0 = expData[i-1][1], y1 = expData[i][1];
if ((y0 < 0 && y1 > 0) || (y0 > 0 && y1 < 0)) {
const x = expData[i-1][0] + (0 - y0) * (expData[i][0]-expData[i-1][0]) / (y1 - y0);
bes.push(x);
}
}
const labelPad = { left: 6, right: 6, top: 3, bottom: 3 };
const xAnnos = [{
x: spot, borderColor: '#ffd43b', strokeDashArray: 4,
label: { text: 'Spot $'+spot.toFixed(2), position:'top', orientation:'horizontal', offsetY: -2,
borderColor:'#ffd43b', borderWidth:1,
style:{ color:'#1b1d27', background:'#ffd43b', fontSize:'11px', fontWeight:700, padding: labelPad } }
}].concat(bes.map(x => ({ x, borderColor:'#5aa9ff', strokeDashArray:3,
label:{ text:'BE $'+x.toFixed(2), position:'bottom', orientation:'horizontal', offsetY: 2,
borderColor:'#2f6fd0', borderWidth:1,
style:{ color:'#ffffff', background:'#2f6fd0', fontSize:'10px', fontWeight:700, padding: labelPad } } })));
const opts = {
chart: { type:'line', height:380, background:CHART_BG, foreColor:CHART_LABEL, fontFamily:'inherit', toolbar:{show:false}, animations:{enabled:false}, zoom:{enabled:false} },
series: [
{ name: this.dteOffset===0 ? 'P/L Today' : 'P/L '+this.dteLabel, type:'line', data: tnData, color: COLOR_TN },
{ name: 'P/L at Expiration', type:'line', data: expData, color: COLOR_EXP },
],
stroke: { width:[2,3], curve:['smooth','straight'], dashArray:[5,0] },
markers: { size:0 },
grid: { borderColor:CHART_GRID, strokeDashArray:3 },
dataLabels: { enabled:false },
legend: { labels:{ colors:'#d0d5e0' }, position:'top', horizontalAlign:'right' },
xaxis: {
type:'numeric', tickAmount:10,
labels:{ style:{colors:CHART_LABEL}, formatter:v=>'$'+Number(v).toFixed(0) },
title:{ text:'Underlying price', style:{color:CHART_LABEL} },
axisBorder:{color:CHART_GRID}, axisTicks:{color:CHART_GRID},
},
yaxis: {
min:yMin, max:yMax,
labels:{ style:{colors:CHART_LABEL}, formatter:v=>fmtMoney(v) },
title:{ text:'Profit / Loss ($)', style:{color:CHART_LABEL} },
},
tooltip: {
theme:'dark', shared:true, intersect:false,
x:{ formatter:v=>'Underlying $'+Number(v).toFixed(2) },
y:{ formatter:v=>fmtMoney(v) },
},
annotations: {
yaxis: [
{ y:0, y2:yMax, fillColor:'#2fb344', opacity:0.05, borderColor:'transparent' },
{ y:yMin, y2:0, fillColor:'#d63939', opacity:0.05, borderColor:'transparent' },
{ y:0, borderColor:'#aeb6c4', strokeDashArray:0,
label:{ text:'P/L = 0', position:'left', borderColor:'#3a3f5a', borderWidth:1,
style:{ color:'#e8ebf2', background:'#2a2e42', fontSize:'10px', fontWeight:600, padding:labelPad } } },
],
xaxis: xAnnos,
},
};
if (this.chart) {
this.chart.updateOptions(opts, true, false);
} else {
this.chart = new ApexCharts(document.getElementById('plChart'), opts);
this.chart.render();
}
},
fmtMoney,
};
}
</script>
</body>
</html>