diff --git a/experiments/events/index.html b/experiments/events/index.html new file mode 100644 index 000000000..91291c2e0 --- /dev/null +++ b/experiments/events/index.html @@ -0,0 +1,1173 @@ + + + + + + + + + + + Bitcoin Extreme Events + + + +
+ + + +
+

Bitcoin Extreme Events

+ +
+ + +
+ +
+ + + + + +
+
+ +
Loading extreme events
+ +
+ Source: + + Bitview.space + + ยท Powered by + + Bitcoin Research Kit + +
+
+ + + + diff --git a/experiments/sentiment/index.html b/experiments/sentiment/index.html index fd7932e48..c414844dc 100644 --- a/experiments/sentiment/index.html +++ b/experiments/sentiment/index.html @@ -264,13 +264,19 @@ createChart, } from "https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.mjs"; - const API = "https://bitview.space/api/series"; + const API_ROOT = "https://bitview.space/api"; + const API = `${API_ROOT}/series`; + const LIVE_PRICE_URL = `${API_ROOT}/oracle/price`; const DAY_ZERO = Date.UTC(2009, 0, 1); const DAY_SECONDS = 24 * 60 * 60; + const LIVE_PRICE_POLL_MS = 1000; + const LIVE_PRICE_TIMEOUT_MS = 1500; + const SERIES_POLL_MS = 60 * 1000; const PRICE_CANDLE_ENTER_BARS = 500; const PRICE_CANDLE_EXIT_BARS = 600; const PRICE_SCALE_MIN_WIDTH = 64; - const PRICE_PANE_STRETCH = 5; + const PRICE_SCALE_MARGINS = { top: 0.2, bottom: 0.02 }; + const PRICE_PANE_STRETCH = 8; const POSITION_PANE_STRETCH = 1; const COLORS = Object.freeze({ @@ -315,8 +321,16 @@ const statusElement = document.getElementById("status"); const sentimentElement = document.getElementById("sentiment"); const rowsByTime = new Map(); + const etags = new Map(); + const seriesCache = {}; + const NOT_MODIFIED = Symbol("not-modified"); + let rows = []; let latestRow = null; + let chartState = null; + let lastLivePrice = null; + let livePriceInFlight = false; + let seriesRefreshInFlight = false; function rgba(hex, alpha) { const value = Number.parseInt(hex.slice(1), 16); @@ -359,12 +373,39 @@ return SENTIMENT[score] ?? null; } - function formatPrice(value) { - if (!isPrice(value)) return "--"; - if (value >= 100000) return `$${Math.round(value / 1000)}k`; - if (value >= 10000) return `$${(value / 1000).toFixed(1)}k`; - if (value >= 1000) return `$${Math.round(value).toLocaleString("en-US")}`; - return `$${value.toFixed(2)}`; + function numberToUSNumber(value, digits) { + return value.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + } + + function formatPrice(value, digits) { + const absoluteValue = Math.abs(value); + if (!Number.isFinite(value)) return ""; + + if (absoluteValue < 10) { + return numberToUSNumber(value, Math.min(3, digits || 10)); + } + if (absoluteValue < 1000) { + return numberToUSNumber(value, Math.min(2, digits || 10)); + } + if (absoluteValue < 10000) { + return numberToUSNumber(value, Math.min(1, digits || 10)); + } + if (absoluteValue < 1000000) { + return numberToUSNumber(value, 0); + } + if (absoluteValue >= 1e27) return "Inf."; + + const log = Math.floor(Math.log10(absoluteValue) - 6); + const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"]; + const suffixIndex = Math.floor(log / 3); + const divisor = 1000000 * 1000 ** suffixIndex; + const suffix = suffixes[suffixIndex]; + const suffixDigits = log % 3 === 0 ? 3 : log % 3 === 1 ? 2 : 1; + + return `${numberToUSNumber(value / divisor, suffixDigits)}${suffix}`; } function buildRows({ @@ -411,17 +452,42 @@ return rows; } - async function fetchSeries(name, url) { - const response = await fetch(url, { cache: "no-cache" }); + async function fetchSeries(name, url, conditional = false) { + const headers = {}; + const etag = etags.get(name); + if (conditional && etag) headers["If-None-Match"] = etag; + + const response = await fetch(url, { cache: "no-cache", headers }); + if (response.status === 304) return NOT_MODIFIED; if (!response.ok) throw new Error(`${name}: ${response.status}`); const series = await response.json(); if (!Number.isFinite(series.start) || !Array.isArray(series.data)) { throw new Error(`${name}: invalid data`); } + const nextEtag = response.headers.get("etag"); + if (nextEtag) etags.set(name, nextEtag); return series; } + async function fetchAllSeries(conditional = false) { + const entries = await Promise.all( + Object.entries(ENDPOINTS).map(async ([name, url]) => [ + name, + await fetchSeries(name, url, conditional), + ]), + ); + + let changed = false; + for (const [name, series] of entries) { + if (series === NOT_MODIFIED) continue; + seriesCache[name] = series; + changed = true; + } + + return conditional && !changed ? null : seriesCache; + } + function areaPoint(row) { const color = sentimentFor(row.score).color; return { @@ -466,6 +532,108 @@ sentimentElement.style.setProperty("--sentiment-color", sentiment.color); } + function indexRows(nextRows) { + rows = nextRows; + rowsByTime.clear(); + for (const row of rows) rowsByTime.set(row.time, row); + latestRow = rows.at(-1) ?? null; + setReadout(latestRow); + } + + function applyRows(nextRows) { + const visibleRange = + chartState?.chart.timeScale().getVisibleLogicalRange() ?? null; + indexRows(nextRows); + if (!chartState) return; + + chartState.area.setData(rows.map(areaPoint)); + chartState.price.setData( + rows.map((row) => ({ time: row.time, value: row.price })), + ); + chartState.candles.setData(rows.map(candlePoint)); + chartState.position.setData(rows.map(positionPoint)); + for (let index = 0; index < REFERENCES.length; index += 1) { + const definition = REFERENCES[index]; + chartState.references[index].setData( + rows.map((row) => referencePoint(row, definition.key)), + ); + } + + if (visibleRange) { + chartState.chart.timeScale().setVisibleLogicalRange(visibleRange); + } + } + + function applyLivePrice(value, force = false) { + if (!chartState || !latestRow || !isPrice(value)) return; + if (!force && value === lastLivePrice) return; + lastLivePrice = value; + + const row = { + ...latestRow, + price: value, + high: Math.max(latestRow.high, value), + low: Math.min(latestRow.low, value), + close: value, + }; + rows[rows.length - 1] = row; + latestRow = row; + rowsByTime.set(row.time, row); + + chartState.area.update(areaPoint(row)); + chartState.price.update({ time: row.time, value }); + chartState.candles.update(candlePoint(row)); + } + + async function refreshLivePrice() { + if (livePriceInFlight) return; + livePriceInFlight = true; + + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(), + LIVE_PRICE_TIMEOUT_MS, + ); + try { + const response = await fetch(LIVE_PRICE_URL, { + cache: "no-store", + signal: controller.signal, + }); + if (!response.ok) throw new Error(`live price: ${response.status}`); + + applyLivePrice(Number(await response.json())); + } catch (error) { + console.warn(error); + } finally { + window.clearTimeout(timeout); + livePriceInFlight = false; + } + } + + async function refreshSeries() { + if (seriesRefreshInFlight) return; + seriesRefreshInFlight = true; + try { + const series = await fetchAllSeries(true); + if (!series) return; + + const nextRows = buildRows(series); + if (!nextRows.length) throw new Error("No aligned sentiment data"); + applyRows(nextRows); + if (isPrice(lastLivePrice)) applyLivePrice(lastLivePrice, true); + } catch (error) { + console.warn(error); + } finally { + seriesRefreshInFlight = false; + } + } + + function startPolling() { + void refreshLivePrice(); + window.setInterval(refreshLivePrice, LIVE_PRICE_POLL_MS); + window.setInterval(refreshSeries, SERIES_POLL_MS); + } + function createSentimentChart(rows) { const chart = createChart(chartElement, { autoSize: true, @@ -504,7 +672,7 @@ borderColor: COLORS.faint, borderVisible: true, minimumWidth: PRICE_SCALE_MIN_WIDTH, - scaleMargins: { top: 0.16, bottom: 0.12 }, + scaleMargins: PRICE_SCALE_MARGINS, }, timeScale: { borderColor: COLORS.faint, @@ -543,6 +711,7 @@ tickmarksFormatter: (values) => values.map(formatPrice), }, priceLineVisible: false, + priceLineColor: COLORS.foreground, lastValueVisible: true, crosshairMarkerVisible: false, }); @@ -563,6 +732,7 @@ tickmarksFormatter: (values) => values.map(formatPrice), }, priceLineVisible: false, + priceLineColor: COLORS.foreground, lastValueVisible: true, }); @@ -578,7 +748,8 @@ tickmarksFormatter: (values) => values.map(formatPrice), }, priceLineVisible: false, - lastValueVisible: false, + priceLineColor: rgba(definition.color, 0.75), + lastValueVisible: true, crosshairMarkerVisible: false, }); reference.setData( @@ -675,23 +846,18 @@ schedulePriceVisibility, ); updatePriceVisibility(chart.timeScale().getVisibleLogicalRange()); + + return { chart, area, price, candles, references, position }; } async function load() { - const series = Object.fromEntries(await Promise.all( - Object.entries(ENDPOINTS).map(([name, url]) => - fetchSeries(name, url).then((data) => [name, data]), - ), - )); + const series = await fetchAllSeries(); + const nextRows = buildRows(series); + if (!nextRows.length) throw new Error("No aligned sentiment data"); - const rows = buildRows(series); - if (!rows.length) throw new Error("No aligned sentiment data"); - - for (const row of rows) rowsByTime.set(row.time, row); - latestRow = rows.at(-1); - - setReadout(latestRow); - createSentimentChart(rows); + indexRows(nextRows); + chartState = createSentimentChart(rows); + startPolling(); chartElement.setAttribute("aria-busy", "false"); statusElement.textContent = "";