chart/static/index.html

383 lines
28 KiB
HTML
Raw 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">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>/ESsent</title>
<link rel="stylesheet" href="/static/style.css">
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
Pinned deliberately: an unpinned icon set is a silent redesign on someone
else's release. cdnjs 404s on this path — it does not carry 7.3.1. -->
<link rel="stylesheet" href="https://unpkg.com/@fortawesome/fontawesome-free@7.3.1/css/all.min.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script>
</head>
<body>
<div id="app">
<header>
<h1><strong>/ES</strong>sent</h1>
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
</header>
<main>
<section class="chart-shell">
<div class="chart-head">
<div class="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></span></div>
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div>
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
<div class="statusbar">
<span>FEED <b>{{ status.stream }}</b></span>
<span>LAST BAR <b>{{ barAge }}</b></span>
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedDrawing" class="arm-hint">Drawing selected — Delete removes it</span>
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
{{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
</button>
<span class="data-freshness"><span>BUILD <b>{{ buildStamp }}</b></span><span>UPDATED <b>{{ dataUpdatedAt }}</b></span></span>
</div>
</section>
<aside>
<details class="sidebar-section">
<summary>Layers</summary>
<div class="layer-group">
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
</div>
<div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
</div>
<div class="layer-group layer-inline">
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label>
</div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
</details>
<details class="sidebar-section config-section">
<summary>Config</summary>
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
</details>
<details class="sidebar-section tools-section" open>
<summary>Tools</summary>
<div class="tool tool-mark" :class="{armed: armedTool === 'symbol'}">
<div class="tool-head symbol-head" title="Choose a mark and click the chart, or drag a mark button onto it. Marks pin to that bar and price and can be dragged later with their grip.">
<button class="symbol-arm" @click="toggleSymbolPanel" :aria-expanded="symbolPanelOpen" :aria-pressed="armedTool === 'symbol'">
<span class="tool-glyph"><i class="fa-solid fa-icons"></i></span>Mark
</button>
<details class="color-picker symbol-head-color" @click.stop>
<summary :title="drawingColorName(symbolColor)"
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
:style="{backgroundColor: symbolColor}"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input"
:aria-label="`${row.id} row annotation`" @click.stop
@blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches">
<button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === symbolColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="symbolColor = color; $event.currentTarget.closest('details').open = false"></button>
</div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="symbolColor" aria-label="Custom symbol color"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel"
@click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
<span class="tool-state">{{ armedTool === 'symbol' ? 'click chart' : '' }}</span>
</div>
<div class="tool-body" v-show="symbolPanelOpen">
<div class="symbol-palette" aria-label="Mark choices">
<button v-for="symbol in symbolChoices" :key="symbol.icon" type="button" draggable="true"
:class="{active: selectedSymbol === symbol.icon}" :title="symbol.name" :aria-label="symbol.name"
@click="chooseSymbol(symbol)" @dragstart="startSymbolDrag($event, symbol)">
<i :class="`fa-solid fa-${symbol.icon}`"></i>
</button>
</div>
<label>Size<select v-model.number="symbolScale" aria-label="Mark size">
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
</select></label>
</div>
</div>
<div class="tool tool-level" :class="{armed: armedTool === 'level'}">
<button class="tool-head" @click="armTool('level')" :aria-pressed="armedTool === 'level'">
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'level'">
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
<div class="row">
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
</div>
<label>Alert early (pts)<input type="number" min="0" step="0.25" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
<form class="row price-row" @submit.prevent="addPriceAlert">
<label>Price<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
<button type="submit" :disabled="!alertPrice">Add</button>
</form>
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
</div>
</div>
<div class="tool tool-trendline" :class="{armed: armedTool === 'trendline'}">
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'trendline'">
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
<div class="row">
<div class="tool-color"><span>Colour</span>
<details class="color-picker" @click.stop>
<summary :title="drawingColorName(drawColor)" :aria-label="`Choose trendline color; current color ${drawingColorName(drawColor)}`" :style="{backgroundColor: drawColor}"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === drawColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="drawColor = color; $event.currentTarget.closest('details').open = false"></button></div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="drawColor" aria-label="Custom trendline color"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
</div>
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
</div>
<div class="row">
<!-- Side is inferred from the extreme you snap to, so it only has
anything to say when snapping is off. -->
<label v-if="!snap">Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
<span v-else class="side-auto" title="A high is resistance, a low is support">Side <b>auto</b></span>
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
</div>
</div>
</div>
<div class="tool tool-comment" :class="{armed: armedTool === 'comment'}">
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'comment'">
<label>Text<textarea v-model.trim="commentText" rows="2" placeholder="What happened here?" aria-label="Comment text"></textarea></label>
<label class="check"><input type="checkbox" v-model="commentFloat">Float (stay on screen)</label>
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
</div>
</div>
</details>
<details class="sidebar-section options-section" @toggle="onOptionsToggle">
<summary>Options</summary>
<div class="options-body">
<label>Expiration
<select v-model="optionExpiryId" :disabled="!optionExpirations.length" aria-label="Option expiration">
<option v-if="!optionExpirations.length" value="">Open to load</option>
<option v-for="row in optionExpirations" :key="row.id" :value="row.id">{{ row.label }}</option>
</select>
</label>
<div class="row">
<label>Side
<select v-model="optionSide" aria-label="Calls or puts">
<option value="P">Puts</option>
<option value="C">Calls</option>
</select>
</label>
<label>Filter
<select v-model="optionMode" aria-label="Filter by delta or price">
<option value="delta">Delta</option>
<option value="price">Price</option>
</select>
</label>
</div>
<div class="row">
<label>From<input type="number" step="any" v-model.number="optionMin" :aria-label="optionMode === 'delta' ? 'Minimum delta' : 'Minimum mark'"></label>
<label>To<input type="number" step="any" v-model.number="optionMax" :aria-label="optionMode === 'delta' ? 'Maximum delta' : 'Maximum mark'"></label>
</div>
<div class="row price-row">
<button type="button" :disabled="optionBusy || !optionExpiryId" @click="searchOptions">{{ optionBusy ? 'Searching…' : optionContracts.length ? 'Refresh' : 'Search' }}</button>
<span class="hint" v-if="optionUnderlying">/ES {{ optionUnderlying.toFixed(2) }}</span>
</div>
<p class="hint" v-if="optionError">{{ optionError }}</p>
<p class="hint" v-else-if="!optionSearched">No quotes until you search.</p>
<p class="hint" v-else-if="!optionContracts.length">No contracts in that range.</p>
<div v-else class="option-results">
<div class="option-row option-head">
<span>Strike</span><span>Mark</span><span>Δ ≈</span><span></span>
</div>
<div v-for="row in optionContracts" :key="row.symbol" class="option-row">
<span>{{ row.strike.toFixed(0) }}</span>
<span>{{ row.mark.toFixed(2) }}</span>
<span>{{ row.abs_delta == null ? '—' : row.abs_delta.toFixed(2) }}</span>
<button type="button" :title="`${row.tos} · ${row.bid ?? '—'} × ${row.ask ?? '—'} · vol ${row.volume} · oi ${row.open_interest}`" @click="copyOption(row)">{{ optionCopied === row.symbol ? 'Copied' : 'Copy' }}</button>
</div>
<p class="hint" v-if="optionContracts.length">Bid/ask on copy target. Δ is approximate.</p>
</div>
</div>
</details>
<details class="sidebar-section drawings-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
<div class="drawing-filters">
<select v-model="drawingKind" aria-label="Filter drawings by type">
<option value="all">All types</option>
<option value="trendline">Trendlines</option>
<option value="level">Levels</option>
<option value="comment">Comments</option>
<option value="symbol">Symbols</option>
</select>
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div>
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
<span class="selection-count">{{ selectedDrawings.length }} selected</span>
<button @click="toggleSelectedVisibility" :disabled="!selectedDrawings.length"
:aria-label="selectedAreHidden ? 'Show selected drawings' : 'Hide selected drawings'">
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
{{ selectedAreHidden ? 'Show' : 'Hide' }}
</button>
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
</div>
<div class="drawing-list-shell">
<div class="drawing-list" ref="drawingList">
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:data-drawing-id="item.id"
:class="{selected: selectedDrawings.includes(item.id), 'hidden-drawing': item.hidden}">
<input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)">
<div class="drawing-content">
<div class="drawing-primary">
<input v-if="item.line" :value="item.line.label" aria-label="Drawing name"
@click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)">
<label v-if="item.comment" class="drawing-state" @click.stop
:title="item.comment.pinned ? 'Pinned to its bar' : 'Floating on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
<i :class="item.comment.pinned ? 'fa-solid fa-thumbtack' : 'fa-solid fa-up-down-left-right'"></i>
</label>
<label v-else class="drawing-state" :class="{off: !item.line.armed}" @click.stop
:title="item.line.armed ? 'Armed — click to disarm' : 'Tripped — click to re-arm'">
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
<i :class="item.line.armed ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i>
</label>
<button class="drawing-delete" @click.stop="deleteDrawing(item)" aria-label="Delete drawing" title="Delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div class="drawing-secondary">
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
<span v-else-if="item.kind === 'level'" class="level-editors">
<label>Price<input type="number" min="0.25" step="0.25" :value="item.line.anchor_p"
aria-label="Level price in drawing list"
@keydown.enter="$event.target.blur()"
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
<label>Early<input type="number" min="0" step="0.25" :value="item.line.alert_early_points ?? ''"
placeholder="ATR" aria-label="Level alert early points"
@keydown.enter="$event.target.blur()"
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
<div class="drawing-controls" @click.stop v-if="item.line">
<select :value="item.line.line_width || 2" aria-label="Drawing width"
@change="updateLineStyle(item.line, {line_width: Number($event.target.value)})">
<option v-for="width in 9" :value="width">{{ width }}</option>
</select>
<details class="color-picker">
<summary :aria-label="`Choose drawing color; current color ${drawingColorName(item.line.color || '#65b7cf')}`"
:title="drawingColorName(item.line.color || '#65b7cf')"
:style="{ backgroundColor: item.line.color || '#65b7cf' }"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === item.line.color}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="updateLineStyle(item.line, {color}); $event.currentTarget.closest('details').open = false"></button></div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" :value="item.line.color || '#65b7cf'" aria-label="Custom drawing color" @change="updateLineStyle(item.line, {color: $event.target.value})"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
</div>
<div class="drawing-controls" @click.stop v-else>
<select v-if="item.kind === 'symbol'" class="mark-scale" :value="item.comment.scale || 1"
aria-label="Mark size"
@change="updateLineStyle(item.comment, {scale: Number($event.target.value)})">
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
</select>
<button v-if="item.kind === 'comment'" class="collapse-toggle" @click.stop="toggleComment(item.comment)"
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>
</button>
<details class="color-picker">
<summary :aria-label="`Choose comment color; current color ${drawingColorName(item.comment.color || '#c8992f')}`"
:title="drawingColorName(item.comment.color || '#c8992f')"
:style="{ backgroundColor: item.comment.color || '#c8992f' }"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === item.comment.color}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="updateLineStyle(item.comment, {color}); $event.currentTarget.closest('details').open = false"></button></div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" :value="item.comment.color || '#c8992f'" aria-label="Custom comment color" @change="updateLineStyle(item.comment, {color: $event.target.value})"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
<div class="drawing-list-resize" role="separator" aria-orientation="horizontal"
aria-label="Resize drawings list" @pointerdown="startDrawingListResize"></div>
</div>
</details>
<details class="sidebar-section">
<summary>Confluence zones</summary>
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
<!-- One line each, ordered by price: the list then reads like the
chart does, highest zone at the top, and they all fit on screen. -->
<div v-for="cluster in clustersByPrice" :key="cluster.id" class="cluster-row" :class="cluster.side"
:title="cluster.members.map(member => member.label).join(' · ')">
<span class="cluster-band">{{ cluster.low.toFixed(2) }}–{{ cluster.high.toFixed(2) }}</span>
<span class="cluster-score">{{ cluster.score.toFixed(1) }}</span>
<span class="cluster-dist">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(1) }}</span>
<span class="cluster-members">{{ cluster.members.map(member => member.label).join(' · ') }}</span>
</div>
</details>
<details class="sidebar-section">
<summary>Events ({{ events.length }})</summary>
<div v-if="!events.length" class="empty">No events yet.</div>
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
<time><b v-if="event.number" class="event-number">#{{ event.number }}</b>{{ event.at }}</time>
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
<template v-else>{{ event.message }}</template>
</div>
</details>
</aside>
</main>
</div>
<script src="/static/chart.js"></script>
<script src="/static/app.js"></script>
</body>
</html>