chart/static/index.html
Chris Amow a395818581 Post cross-thread events through the loop, and measure how late it runs
P0 from docs/async_refactor.md. The mutating routes are sync `def`, so FastAPI
runs them in a threadpool, and they reach Runtime.broadcast through
rebuild_levels — writing asyncio.Queue directly from there. That queue is not
thread-safe: it wakes a consumer by resolving a Future, which only the loop
thread may do. A dropped wakeup means a drawing made in one browser does not
reach another until the next market tick.

broadcast now posts through call_soon_threadsafe when it is off the loop, and
publishes directly when it is on it, so the stream's own path pays nothing.

Worth being straight about the tests: the race is timing-dependent and did not
reproduce in twenty attempts — a foreign-thread put_nowait usually lands in the
ready queue before the loop sleeps, and a tick every second covers the rest.
Even asyncio's debug thread-affinity check stays quiet unless a consumer is
parked on the Future at that instant. So the tests assert the contract rather
than provoke the failure: a broadcast from a worker thread must go through
call_soon_threadsafe, one from the loop must deliver synchronously, and both
must arrive.

Also adds the loop-lag probe, which reports scheduling drift as loop_lag_ms on
/api/status. It found P1 on its first run: 19,441ms worst against 1.5ms in
steady state, which is seeding blocking the loop. "The chart feels laggy" is now
a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:30:45 -05:00

237 lines
16 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><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</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"></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="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></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 tools-section" open>
<summary>Tools</summary>
<div class="tool" :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">
<label>Colour<input type="color" v-model="drawColor" aria-label="Trendline colour"></label>
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in [1,2,3,4]" :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" :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 [1,2,3,4]" :value="width">{{ width }}px</option></select></label>
</div>
<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" :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" 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>
</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">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
:title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
Delete shown ({{ filteredDrawings.length }})
</button>
</div>
<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"
:class="{selected: selectedLines.includes(item.id)}"
@click="item.kind !== 'comment' && toggleLineSelection(item.id)">
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
:checked="selectedLines.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleLineSelection(item.id)">
<span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span>
<div class="drawing-content">
<div class="drawing-primary">
<input v-if="item.kind !== 'comment'" :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.kind === '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 === 'level'">#{{ item.number }} · L {{ item.line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
<div class="drawing-controls" @click.stop v-if="item.kind !== 'comment'">
<details class="color-picker">
<summary aria-label="Choose drawing color" title="Choose color"
:style="{ backgroundColor: item.line.color || '#65b7cf' }"></summary>
<div class="color-popover">
<button v-for="color in drawingColors" :key="color" type="button"
:class="{selected: color === item.line.color}" :style="{backgroundColor: color}"
:aria-label="`Use color ${color}`"
@click.stop="updateLineStyle(item.line, {color}); $event.currentTarget.closest('details').open = false"></button>
<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="Close color palette" title="Close"
@click.stop="$event.currentTarget.closest('details').open = false">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</details>
<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 [1,2,3,4]" :value="width">{{ width }}</option>
</select>
</div>
<div class="drawing-controls" @click.stop v-else>
<details class="color-picker">
<summary aria-label="Choose comment color" title="Choose color"
:style="{ backgroundColor: item.comment.color || '#c8992f' }"></summary>
<div class="color-popover">
<button v-for="color in drawingColors" :key="color" type="button"
:class="{selected: color === item.comment.color}" :style="{backgroundColor: color}"
:aria-label="`Use color ${color}`"
@click.stop="updateLineStyle(item.comment, {color}); $event.currentTarget.closest('details').open = false"></button>
<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="Close color palette" title="Close"
@click.stop="$event.currentTarget.closest('details').open = false">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</details>
<button 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>
</div>
</div>
</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>
<div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
</div>
</details>
<details class="sidebar-section">
<summary>Alert log ({{ alerts.length }})</summary>
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
</details>
</aside>
</main>
</div>
<script src="/static/chart.js"></script>
<script src="/static/app.js"></script>
</body>
</html>