Add price-range zoom controls to the P/L chart

−/+ buttons (with a ±% readout) and a Fit button in the chart header widen
or narrow the underlying-price window the curve is sampled over, so you can
inspect a tight range around spot or zoom out to see the full payoff shape.
Bumped sampling density to 221 points.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 04:49:23 +00:00
parent 703c305cf1
commit 6ecc69df99

View File

@@ -96,15 +96,25 @@
<!-- P/L chart --> <!-- P/L chart -->
<div class="chart-card mb-3" x-show="legs.length > 0" x-cloak> <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-2"> <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> <h3 class="card-title text-white mb-0">Profit / Loss vs. Underlying Price</h3>
<div class="d-flex align-items-center gap-2" style="min-width:320px;"> <div class="d-flex align-items-center flex-wrap gap-3">
<!-- price-range zoom -->
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom price range">
<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="zoomFit()" title="Reset zoom">Fit</button>
</div>
<!-- time slider -->
<div class="d-flex align-items-center gap-2" style="min-width:300px;">
<span class="text-secondary small text-nowrap">Now</span> <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:160px;"> <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="text-secondary small text-nowrap">Exp</span>
<span class="badge bg-blue-lt text-nowrap" x-text="dteLabel"></span> <span class="badge bg-blue-lt text-nowrap" x-text="dteLabel"></span>
</div> </div>
</div> </div>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="plChart" style="min-height:380px;" role="img" aria-label="Profit and loss diagram"></div> <div id="plChart" style="min-height:380px;" role="img" aria-label="Profit and loss diagram"></div>
</div> </div>
@@ -300,6 +310,7 @@
// state // state
symbol: '', symbols: [], spot: 0, legs: [], symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0, dteOffset: 0,
xZoom: 1, lastHalfPct: 0,
refreshing: false, showManual: false, toast: '', refreshing: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null }, manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
chart: null, _renderTimer: null, chart: null, _renderTimer: null,
@@ -517,6 +528,9 @@
clearTimeout(this._renderTimer); clearTimeout(this._renderTimer);
this._renderTimer = setTimeout(() => this.renderChart(), 40); 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.renderChart(); },
renderChart() { renderChart() {
const legs = this.activeLegs, net = this.netCost; const legs = this.activeLegs, net = this.netCost;
@@ -528,10 +542,12 @@
const strikes = legs.map(l=>l.strike); const strikes = legs.map(l=>l.strike);
const minK = Math.min(...strikes), maxK = Math.max(...strikes); const minK = Math.min(...strikes), maxK = Math.max(...strikes);
const span = maxK - minK; const span = maxK - minK;
const half = Math.max(spot*0.22, span*1.5, (maxK-spot)*1.4, (spot-minK)*1.4, 5); 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 lo = Math.max(0.01, spot - half), hi = spot + half; const lo = Math.max(0.01, spot - half), hi = spot + half;
this.lastHalfPct = spot > 0 ? Math.round(half / spot * 100) : 0;
const expAt = this.minDTE; const expAt = this.minDTE;
const N = 161; const N = 221;
// x-grid = evenly spaced points + the exact strike kinks (so payoff // x-grid = evenly spaced points + the exact strike kinks (so payoff
// vertices — butterfly peak, condor body, etc. — render sharply) // vertices — butterfly peak, condor body, etc. — render sharply)
const xGrid = []; const xGrid = [];