The confluence engine had nothing to work with. Daily moving averages were the only level source, and they sat 163 to 697 points from price, so every cluster had exactly one member and no alert could ever fire. Two new sources, chosen for having a real following — the engine is a bet that many participants watch the same price, which is what makes a level hold: - Prior day high/low/close, from the last *closed* daily bar so mid-session the levels do not silently switch to today's own developing range. Full daily weight rather than the 0.75 average discount: a traded high is structure, not a derived average. - Session VWAP, anchored to the 18:00 ET open like the daily bars. Institutional execution is benchmarked against it, and zero-volume overnight minutes are skipped rather than dividing by zero. Both are stamped 1d, so they get their own colours to stay distinguishable from the daily averages. Prior-day levels draw as price lines, which span the chart and label the axis instead of relying on bar-index interpolation. VWAP re-prices every minute while a daily average carries hundreds of points and changes once a session, so broadcasting the whole level set on the VWAP cadence would have pushed the entire history every minute. Levels now go out as a delta that clients merge by id. Adding the levels then exposed two defects that had been invisible while nothing could cluster: - Cluster identity was sha1(side + round(center / tolerance)), and tolerance derives from ATR, so it changed every bar. The same zone was continually issued a new id, never matched the cooldown table, and the cooldown did nothing. Identity is now the set of converging levels. - Alert suppression keyed on that identity, so a level drifting in or out of a group read as a new zone. It now suppresses by proximity: two zones within an ATR are the same zone, and the strongest is the one reported. Over six replayed sessions at threshold 28 that is 247 alerts, then 54, then 40; raising the cooldown to 4h — which only affects repeats of the same area, never a genuinely new zone — gives 17 total with a worst session of 9. calibrate_alerts.py now sweeps threshold and cooldown together in one pass, since the threshold turns out to be quantised and nearly useless as a control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
|
|
|
|
// Shared access token. Blank when the server runs without CHART_AUTH_TOKEN,
|
|
// which is the normal local-development case — nothing prompts.
|
|
const TOKEN_KEY = 'chart-token';
|
|
let authToken = localStorage.getItem(TOKEN_KEY) || '';
|
|
|
|
// Reconnects and the status poll both hit 401s, so without this a visitor
|
|
// who cancels gets asked again every couple of seconds.
|
|
let promptDeclined = false;
|
|
|
|
function promptForToken() {
|
|
if (promptDeclined) return false;
|
|
const entered = window.prompt('Access token for this chart', '');
|
|
if (entered === null) {
|
|
promptDeclined = true;
|
|
return false;
|
|
}
|
|
authToken = entered.trim();
|
|
localStorage.setItem(TOKEN_KEY, authToken);
|
|
return true;
|
|
}
|
|
|
|
async function apiFetch(url, options = {}) {
|
|
const send = () => fetch(url, {
|
|
...options,
|
|
headers: authToken
|
|
? { ...(options.headers || {}), 'X-Chart-Token': authToken }
|
|
: { ...(options.headers || {}) },
|
|
});
|
|
const response = await send();
|
|
// A 401 means nothing was written, so retrying the same request is safe.
|
|
if (response.status === 401 && promptForToken()) return send();
|
|
return response;
|
|
}
|
|
|
|
const defaultPrefs = {
|
|
base_tf: '1m',
|
|
enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false, horizontal: true, vwap: true },
|
|
hidden_levels_score: false,
|
|
};
|
|
|
|
// Stored preferences outlive the shape they were written in. Anything added to
|
|
// defaultPrefs later would otherwise be missing for every existing visitor —
|
|
// and a missing `enabled.ma` is a crash, not a cosmetic gap.
|
|
function mergePrefs(defaults, stored) {
|
|
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return structuredClone(defaults);
|
|
const merged = structuredClone(defaults);
|
|
for (const [key, value] of Object.entries(stored)) {
|
|
if (!(key in merged)) continue;
|
|
const fallback = merged[key];
|
|
if (fallback && typeof fallback === 'object' && !Array.isArray(fallback)) {
|
|
merged[key] = mergePrefs(fallback, value);
|
|
} else if (Array.isArray(fallback)) {
|
|
if (Array.isArray(value)) merged[key] = value;
|
|
} else if (value !== null && typeof value !== 'object') {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function loadPrefs() {
|
|
try {
|
|
return mergePrefs(defaultPrefs, JSON.parse(localStorage.getItem('chart-layer-prefs')));
|
|
} catch {
|
|
return structuredClone(defaultPrefs);
|
|
}
|
|
}
|
|
|
|
createApp({
|
|
setup() {
|
|
const status = ref({ stream: 'disconnected', bars_held: {} });
|
|
const price = ref(null);
|
|
const prefs = ref(loadPrefs());
|
|
const timeframe = ref(prefs.value.base_tf || '1m');
|
|
const levels = ref([]);
|
|
const clusters = ref([]);
|
|
const alerts = ref([]);
|
|
let alertSequence = 0;
|
|
const drawMode = ref(false);
|
|
const drawName = ref('');
|
|
const drawColor = ref('#65b7cf');
|
|
const drawWidth = ref(2);
|
|
const drawSide = ref('support');
|
|
const snap = ref(true);
|
|
const drawPoints = ref([]);
|
|
const selectedLine = ref(null);
|
|
const selectedLines = ref([]);
|
|
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
|
const now = ref(Date.now());
|
|
let chartApi = null;
|
|
let socket = null;
|
|
let retryDelay = 2000;
|
|
let timer = null;
|
|
|
|
const barAge = computed(() => {
|
|
if (!status.value.last_bar_t) return '—';
|
|
const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t));
|
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
|
|
});
|
|
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
|
|
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
|
|
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
|
|
|
|
async function refreshStatus() {
|
|
const response = await apiFetch('/api/status');
|
|
if (response.ok) status.value = await response.json();
|
|
}
|
|
|
|
function connect() {
|
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
|
socket = new WebSocket(`${protocol}://${location.host}/ws${query}`);
|
|
socket.onopen = () => {
|
|
retryDelay = 2000;
|
|
socket.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value }));
|
|
sendPrefs();
|
|
};
|
|
socket.onmessage = ({ data }) => {
|
|
const message = JSON.parse(data);
|
|
if (message.type === 'snapshot') {
|
|
chartApi.setBars(message.bars);
|
|
levels.value = message.levels || [];
|
|
syncVisibleLevels();
|
|
price.value = message.price;
|
|
} else if (message.type === 'bar') {
|
|
chartApi.updateBar(message.bar);
|
|
price.value = message.bar.c;
|
|
status.value.last_bar_t = message.bar.t;
|
|
} else if (message.type === 'levels') {
|
|
// A delta, not a replacement: VWAP changes every minute while the
|
|
// daily averages carry hundreds of points and change once a session.
|
|
const removed = new Set(message.removed || []);
|
|
const byId = new Map(levels.value.filter(level => !removed.has(level.id)).map(level => [level.id, level]));
|
|
for (const level of message.changed || []) byId.set(level.id, level);
|
|
levels.value = [...byId.values()];
|
|
syncVisibleLevels();
|
|
} else if (message.type === 'clusters') {
|
|
clusters.value = message.clusters;
|
|
price.value = message.price;
|
|
} else if (message.type === 'alert') {
|
|
// Keyed on a counter, not the timestamp: two alerts inside the same
|
|
// second would collide and Vue would reuse the wrong row.
|
|
alerts.value = [
|
|
{ key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message },
|
|
...alerts.value,
|
|
].slice(0, 20);
|
|
playAlert();
|
|
}
|
|
};
|
|
socket.onclose = async () => {
|
|
status.value.stream = 'disconnected';
|
|
// A refused upgrade reaches the browser as 1006, not 1008: the server
|
|
// rejects the handshake with HTTP 403 before any close frame exists.
|
|
// So the close code cannot distinguish "bad token" from "server
|
|
// restarting". Re-check over HTTP instead — apiFetch prompts for a
|
|
// token when that is what is actually wrong — and back off so a
|
|
// visitor without one is not reconnecting twice a second forever.
|
|
await refreshStatus();
|
|
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
setTimeout(connect, retryDelay);
|
|
};
|
|
}
|
|
|
|
// One context for the page, not one per alert. Browsers cap how many a
|
|
// document may hold (~6), after which alerts silently stop making a sound.
|
|
let audioContext = null;
|
|
|
|
function playAlert() {
|
|
const Context = window.AudioContext || window.webkitAudioContext;
|
|
if (!Context) return;
|
|
if (!audioContext) audioContext = new Context();
|
|
// Autoplay policy suspends a context created before any user gesture.
|
|
if (audioContext.state === 'suspended') audioContext.resume();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gain = audioContext.createGain();
|
|
oscillator.frequency.value = 740;
|
|
gain.gain.setValueAtTime(0.12, audioContext.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.35);
|
|
oscillator.connect(gain).connect(audioContext.destination);
|
|
oscillator.start();
|
|
oscillator.stop(audioContext.currentTime + 0.35);
|
|
// Nodes are single-use; release them rather than letting them pile up.
|
|
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
|
}
|
|
|
|
function toggleDraw() {
|
|
drawMode.value = !drawMode.value;
|
|
drawPoints.value = [];
|
|
selectedLine.value = null;
|
|
chartApi.clearLinePreview();
|
|
}
|
|
|
|
function handleChartMove(param) {
|
|
if (!drawMode.value || drawPoints.value.length !== 1) return;
|
|
chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value);
|
|
}
|
|
|
|
async function handleChartClick(param) {
|
|
if (!drawMode.value) {
|
|
selectedLine.value = chartApi.hitTest(param);
|
|
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
|
|
return;
|
|
}
|
|
const point = chartApi.pointFromClick(param, snap.value);
|
|
if (point.snappedSide) drawSide.value = point.snappedSide;
|
|
drawPoints.value.push(point);
|
|
if (drawPoints.value.length < 2) return;
|
|
chartApi.clearLinePreview();
|
|
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t);
|
|
if (start.t === end.t) { drawPoints.value = []; return; }
|
|
const temporaryId = `tmp_${Date.now()}`;
|
|
const optimistic = {
|
|
id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value,
|
|
weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${drawSide.value}`,
|
|
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
|
|
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
|
|
color: drawColor.value, line_width: drawWidth.value,
|
|
};
|
|
levels.value.push(optimistic);
|
|
syncVisibleLevels();
|
|
drawPoints.value = [];
|
|
drawMode.value = false;
|
|
try {
|
|
const response = await apiFetch('/api/lines', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }),
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const saved = await response.json();
|
|
levels.value = levels.value.filter(level => level.id !== temporaryId);
|
|
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
|
|
selectedLine.value = saved.id;
|
|
selectedLines.value = [saved.id];
|
|
} catch (error) {
|
|
levels.value = levels.value.filter(level => level.id !== temporaryId);
|
|
console.error('Unable to save line', error);
|
|
}
|
|
syncVisibleLevels();
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean);
|
|
await deleteLines(ids);
|
|
}
|
|
|
|
async function deleteLine(id) {
|
|
await deleteLines([id]);
|
|
}
|
|
|
|
async function deleteLines(ids) {
|
|
if (!ids.length) return;
|
|
const deleting = new Set(ids);
|
|
levels.value = levels.value.filter(level => !deleting.has(level.id));
|
|
if (selectedLine.value && deleting.has(selectedLine.value)) selectedLine.value = null;
|
|
selectedLines.value = selectedLines.value.filter(id => !deleting.has(id));
|
|
syncVisibleLevels();
|
|
const responses = [];
|
|
for (const id of ids) {
|
|
responses.push(await apiFetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }));
|
|
}
|
|
responses.forEach(response => {
|
|
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
|
|
});
|
|
}
|
|
|
|
function selectLine(id) {
|
|
selectedLine.value = id;
|
|
selectedLines.value = [id];
|
|
}
|
|
|
|
function toggleLineSelection(id) {
|
|
selectedLines.value = selectedLines.value.includes(id)
|
|
? selectedLines.value.filter(value => value !== id)
|
|
: [...selectedLines.value, id];
|
|
selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null;
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
selectedLines.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
|
|
selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null;
|
|
}
|
|
|
|
async function deleteSelectedLines() {
|
|
await deleteLines([...selectedLines.value]);
|
|
}
|
|
|
|
async function renameLine(line, name) {
|
|
await updateLineStyle(line, { note: name.trim() });
|
|
}
|
|
|
|
async function updateLineStyle(line, changes) {
|
|
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(changes),
|
|
});
|
|
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
|
|
const saved = await response.json();
|
|
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
|
syncVisibleLevels();
|
|
}
|
|
|
|
async function updateLineGeometry(line) {
|
|
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
anchor_t: line.anchor_t, anchor_p: line.anchor_p,
|
|
slope: line.slope, last_t: line.last_t,
|
|
}),
|
|
});
|
|
if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; }
|
|
const saved = await response.json();
|
|
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
|
syncVisibleLevels();
|
|
}
|
|
|
|
async function endLineHere(line) {
|
|
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ cutoff_t: line.cutoff_t }),
|
|
});
|
|
if (!response.ok) { console.error(`Unable to end line: HTTP ${response.status}`); return; }
|
|
const saved = await response.json();
|
|
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
|
syncVisibleLevels();
|
|
}
|
|
|
|
// Backspace is a normal editing key inside a field. Without this guard,
|
|
// fixing a typo in a trendline's name deletes the trendline.
|
|
function isEditing(target) {
|
|
if (!target) return false;
|
|
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
|
}
|
|
|
|
function handleKeydown(event) {
|
|
if (isEditing(event.target)) return;
|
|
if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.value) {
|
|
event.preventDefault();
|
|
deleteSelected();
|
|
}
|
|
}
|
|
|
|
function selectTimeframe(tf) {
|
|
timeframe.value = tf;
|
|
prefs.value.base_tf = tf;
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'subscribe', tf }));
|
|
}
|
|
}
|
|
|
|
function enabled(level) {
|
|
if (level.kind === 'ma') return (prefs.value.enabled.ma[level.tf] || []).includes(level.period);
|
|
if (level.kind === 'manual') return prefs.value.enabled.manual;
|
|
if (level.kind === 'horizontal') return prefs.value.enabled.horizontal;
|
|
if (level.kind === 'vwap') return prefs.value.enabled.vwap;
|
|
return prefs.value.enabled.auto;
|
|
}
|
|
|
|
function syncVisibleLevels() {
|
|
if (chartApi) chartApi.syncLevels(levels.value.filter(enabled));
|
|
}
|
|
|
|
function sendPrefs() {
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'prefs', ...prefs.value }));
|
|
}
|
|
}
|
|
|
|
function allEnabled(tf) {
|
|
const available = tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21];
|
|
return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period));
|
|
}
|
|
|
|
function toggleGroup(tf, checked) {
|
|
prefs.value.enabled.ma[tf] = checked ? (tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]) : [];
|
|
}
|
|
|
|
watch(prefs, () => {
|
|
localStorage.setItem('chart-layer-prefs', JSON.stringify(prefs.value));
|
|
syncVisibleLevels();
|
|
sendPrefs();
|
|
}, { deep: true });
|
|
|
|
watch(selectedLine, id => {
|
|
if (chartApi) chartApi.setSelectedLine(id);
|
|
});
|
|
|
|
onMounted(() => {
|
|
chartApi = new ConfluenceChart();
|
|
chartApi.create(document.getElementById('chart'));
|
|
chartApi.setClickHandler(handleChartClick);
|
|
chartApi.setMoveHandler(handleChartMove);
|
|
chartApi.setLineChangeHandler(updateLineGeometry);
|
|
chartApi.setLineEndHandler(endLineHere);
|
|
window.addEventListener('keydown', handleKeydown);
|
|
refreshStatus();
|
|
connect();
|
|
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
|
|
});
|
|
onUnmounted(() => {
|
|
clearInterval(timer);
|
|
if (socket) socket.close();
|
|
if (chartApi) chartApi.destroy();
|
|
window.removeEventListener('keydown', handleKeydown);
|
|
});
|
|
|
|
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle };
|
|
},
|
|
}).mount('#app');
|