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
+
+
+
+
+
+
+
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 = "";