experiments: added sentiment

This commit is contained in:
nym21
2026-08-07 14:35:32 +02:00
parent 02706799f5
commit 9f2744941a
4 changed files with 731 additions and 5 deletions
+5 -1
View File
@@ -360,7 +360,11 @@
<span class="credit-copy">Source:</span>
<a href="https://bitview.space" target="_blank" rel="noreferrer">Bitview.space</a>
<span class="credit-copy">· Powered by</span>
<span>Bitcoin Research Kit</span>
<a
href="https://bitcoinresearchkit.org"
target="_blank"
rel="noreferrer"
>Bitcoin Research Kit</a>
</div>
<button
+11 -2
View File
@@ -228,6 +228,10 @@
text-decoration: none;
}
#credit .credit-copy {
opacity: 0.45;
}
#credit a:hover,
#credit a:focus-visible {
color: #fff;
@@ -305,9 +309,14 @@
</div>
<div id="credit">
Source:
<span class="credit-copy">Source:</span>
<a href="https://bitview.space" target="_blank" rel="noreferrer">Bitview.space</a>
· Powered by <span>Bitcoin Research Kit</span>
<span class="credit-copy">· Powered by</span>
<a
href="https://bitcoinresearchkit.org"
target="_blank"
rel="noreferrer"
>Bitcoin Research Kit</a>
</div>
</main>
+708
View File
@@ -0,0 +1,708 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta
name="description"
content="Bitcoin capital sentiment and long or short position history."
/>
<link rel="preconnect" href="https://bitview.space" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://unpkg.com" crossorigin />
<title>Bitcoin Capital Sentiment</title>
<style>
@font-face {
font-family: "Lilex";
src: url("https://cdn.jsdelivr.net/fontsource/fonts/lilex:vf@5.3.0/latin-wght-normal.woff2")
format("woff2-variations");
font-style: normal;
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: "Instrument";
src: url("https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@5.3.0/latin-400-normal.woff2")
format("woff2");
font-style: normal;
font-weight: 400;
font-display: block;
}
:root {
color-scheme: dark;
--background: #000;
--foreground: #f5f5f5;
--muted: #8a8a8a;
--faint: #202020;
--red: #fb2c36;
--orange: #ff6900;
--yellow: #f0b100;
--green: #00c950;
--chart-bottom-padding: max(
30px,
calc(env(safe-area-inset-bottom) + 26px)
);
--font-mono:
"Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
--font-serif:
"Instrument", Charter, "Bitstream Charter", "Sitka Text", Cambria,
serif;
}
* {
box-sizing: border-box;
}
html,
body,
main,
#chart {
width: 100%;
height: 100%;
margin: 0;
}
body {
min-width: 320px;
overflow: hidden;
background: var(--background);
color: var(--foreground);
font-family: var(--font-mono);
}
main {
position: relative;
height: 100dvh;
isolation: isolate;
}
#chart {
position: absolute;
inset: 0 0 var(--chart-bottom-padding);
height: auto;
}
#identity {
position: absolute;
z-index: 2;
top: max(15px, env(safe-area-inset-top));
left: max(18px, env(safe-area-inset-left));
display: grid;
gap: 7px;
max-width: calc(100% - 112px);
pointer-events: none;
text-transform: capitalize;
text-shadow:
-1px -1px 0 var(--background),
0 -1px 0 var(--background),
1px -1px 0 var(--background),
-1px 0 0 var(--background),
1px 0 0 var(--background),
-1px 1px 0 var(--background),
0 1px 0 var(--background),
1px 1px 0 var(--background);
}
h1 {
margin: 0;
font-family: var(--font-serif);
font-size: clamp(34px, 4.5vw, 52px);
font-weight: 400;
letter-spacing: -0.025em;
line-height: 0.92;
}
#readout {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.22em;
min-height: 18px;
color: var(--foreground);
font-family: var(--font-serif);
font-size: clamp(28px, 4vw, 44px);
font-variant-numeric: tabular-nums;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 0.95;
text-transform: capitalize;
}
#sentiment {
color: var(--sentiment-color, var(--muted));
}
#readout > span:first-child {
color: var(--muted);
}
#status {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
max-width: min(420px, calc(100% - 32px));
padding: 7px 10px;
border-radius: 6px;
color: var(--muted);
background: rgb(20 20 20 / 86%);
font-size: 11px;
font-weight: 550;
letter-spacing: 0.08em;
line-height: 18px;
text-align: center;
text-transform: uppercase;
transform: translate(-50%, -50%);
pointer-events: none;
transition: opacity 160ms ease;
}
#status:empty {
opacity: 0;
}
#status[data-error="true"] {
color: var(--foreground);
background: rgb(95 19 25 / 92%);
}
#source-credit {
position: absolute;
z-index: 2;
bottom: max(7px, env(safe-area-inset-bottom));
left: max(18px, env(safe-area-inset-left));
color: var(--muted);
font-size: 9px;
font-weight: 550;
letter-spacing: 0.08em;
line-height: 16px;
text-transform: uppercase;
}
#source-credit a {
color: inherit;
text-decoration: none;
pointer-events: auto;
}
#source-credit .credit-copy {
opacity: 0.45;
}
#source-credit a:hover,
#source-credit a:focus-visible {
color: var(--foreground);
text-decoration: underline;
text-underline-offset: 3px;
}
@media (max-width: 600px) {
#identity {
top: max(12px, env(safe-area-inset-top));
left: max(12px, env(safe-area-inset-left));
max-width: calc(100% - 90px);
}
h1 {
font-size: 36px;
}
#source-credit {
left: max(12px, env(safe-area-inset-left));
}
}
</style>
</head>
<body>
<main>
<div
id="chart"
role="img"
aria-busy="true"
aria-label="Bitcoin price colored by capital sentiment, with long and short position history"
></div>
<header id="identity">
<h1>Bitcoin Capital Sentiment</h1>
<div id="readout" aria-live="polite">
<span>Phase:</span>
<span id="sentiment">--</span>
</div>
</header>
<div id="status">Loading sentiment</div>
<div id="source-credit">
<span class="credit-copy">Source:</span>
<a href="https://bitview.space" target="_blank" rel="noreferrer">
Bitview.space
</a>
<span class="credit-copy">· Powered by</span>
<a
href="https://bitcoinresearchkit.org"
target="_blank"
rel="noreferrer"
>
Bitcoin Research Kit
</a>
</div>
</main>
<script type="module">
import {
AreaSeries,
CandlestickSeries,
CrosshairMode,
HistogramSeries,
LineSeries,
LineStyle,
PriceScaleMode,
createChart,
} from "https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.mjs";
const API = "https://bitview.space/api/series";
const DAY_ZERO = Date.UTC(2009, 0, 1);
const DAY_SECONDS = 24 * 60 * 60;
const PRICE_CANDLE_ENTER_BARS = 500;
const PRICE_CANDLE_EXIT_BARS = 600;
const PRICE_SCALE_MIN_WIDTH = 64;
const PRICE_PANE_STRETCH = 5;
const POSITION_PANE_STRETCH = 1;
const COLORS = Object.freeze({
background: "#000000",
foreground: "#f5f5f5",
muted: "#8a8a8a",
faint: "#202020",
red: "#fb2c36",
orange: "#ff6900",
yellow: "#f0b100",
green: "#00c950",
cyan: "#00b8db",
fuchsia: "#e12afb",
});
const SENTIMENT = Object.freeze({
[-2]: { label: "bear", color: COLORS.red },
[-1]: { label: "limbo", color: COLORS.orange },
1: { label: "cautious bull", color: COLORS.yellow },
2: { label: "bull", color: COLORS.green },
});
const ENDPOINTS = Object.freeze({
priceOhlc: `${API}/price_ohlc/day`,
score: `${API}/capital_sentiment_score/day`,
phase: `${API}/capital_sentiment_phase/day`,
isLong: `${API}/capital_sentiment_is_long/day`,
all: `${API}/capitalized_price/day`,
sth: `${API}/sth_capitalized_price/day`,
lth: `${API}/lth_capitalized_price/day`,
sma: `${API}/price_sma_1y/day`,
});
const REFERENCES = Object.freeze([
{ key: "all", color: COLORS.orange, style: LineStyle.Dashed },
{ key: "sth", color: COLORS.yellow, style: LineStyle.Dashed },
{ key: "lth", color: COLORS.fuchsia, style: LineStyle.Dashed },
{ key: "sma", color: COLORS.cyan, style: LineStyle.Dotted },
]);
const chartElement = document.getElementById("chart");
const statusElement = document.getElementById("status");
const sentimentElement = document.getElementById("sentiment");
const rowsByTime = new Map();
let latestRow = null;
function rgba(hex, alpha) {
const value = Number.parseInt(hex.slice(1), 16);
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function dayToTimestamp(day) {
return Math.floor(DAY_ZERO / 1000) + day * DAY_SECONDS;
}
function timestampForTime(time) {
if (Number.isFinite(time)) return time;
if (typeof time === "string") {
return Math.floor(Date.parse(time) / 1000);
}
if (time && Number.isFinite(time.year)) {
return Math.floor(
Date.UTC(time.year, time.month - 1, time.day) / 1000,
);
}
return null;
}
function seriesValue(series, day) {
return series.data[day - series.start];
}
function isPrice(value) {
return Number.isFinite(value) && value > 0;
}
function isCandle(value) {
return Array.isArray(value) && value.length >= 4 && value.every(isPrice);
}
function sentimentFor(score) {
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 buildRows({
priceOhlc,
score,
phase,
isLong,
all,
sth,
lth,
sma,
}) {
const rows = [];
for (let index = 0; index < priceOhlc.data.length; index += 1) {
const day = priceOhlc.start + index;
const priceValue = priceOhlc.data[index];
const scoreValue = seriesValue(score, day);
const phaseValue = seriesValue(phase, day);
const isLongValue = seriesValue(isLong, day);
if (!isCandle(priceValue) || !sentimentFor(scoreValue)) continue;
if (typeof phaseValue !== "string") continue;
if (typeof isLongValue !== "boolean") continue;
const [open, high, low, close] = priceValue;
rows.push({
time: dayToTimestamp(day),
price: close,
open,
high,
low,
close,
score: scoreValue,
phase: phaseValue,
isLong: isLongValue,
all: seriesValue(all, day),
sth: seriesValue(sth, day),
lth: seriesValue(lth, day),
sma: seriesValue(sma, day),
});
}
return rows;
}
async function fetchSeries(name, url) {
const response = await fetch(url, { cache: "no-cache" });
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`);
}
return series;
}
function areaPoint(row) {
const color = sentimentFor(row.score).color;
return {
time: row.time,
value: row.price,
topColor: rgba(color, 0.58),
bottomColor: rgba(color, 0.045),
};
}
function positionPoint(row) {
return {
time: row.time,
value: row.isLong ? 1 : -1,
color: rgba(row.isLong ? COLORS.green : COLORS.red, 0.82),
};
}
function candlePoint(row) {
return {
time: row.time,
open: row.open,
high: row.high,
low: row.low,
close: row.close,
};
}
function referencePoint(row, key) {
const value = row[key];
return isPrice(value)
? { time: row.time, value }
: { time: row.time };
}
function setReadout(row) {
if (!row) return;
const sentiment = sentimentFor(row.score);
sentimentElement.textContent = row.phase.replaceAll("_", " ");
sentimentElement.style.setProperty("--sentiment-color", sentiment.color);
}
function createSentimentChart(rows) {
const chart = createChart(chartElement, {
autoSize: true,
layout: {
attributionLogo: false,
background: { color: "transparent" },
textColor: COLORS.muted,
fontFamily: getComputedStyle(document.body).fontFamily,
fontSize: 11,
panes: {
enableResize: false,
separatorColor: COLORS.faint,
separatorHoverColor: COLORS.faint,
},
},
grid: {
horzLines: { color: COLORS.faint },
vertLines: { color: COLORS.faint },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: COLORS.muted,
labelBackgroundColor: COLORS.foreground,
},
horzLine: {
color: COLORS.muted,
labelBackgroundColor: COLORS.foreground,
},
},
localization: {
locale: "en-US",
},
rightPriceScale: {
mode: PriceScaleMode.Logarithmic,
borderColor: COLORS.faint,
borderVisible: true,
minimumWidth: PRICE_SCALE_MIN_WIDTH,
scaleMargins: { top: 0.16, bottom: 0.12 },
},
timeScale: {
borderColor: COLORS.faint,
borderVisible: true,
enableConflation: true,
lockVisibleTimeRangeOnResize: true,
secondsVisible: false,
shiftVisibleRangeOnNewBar: false,
timeVisible: false,
},
});
const area = chart.addSeries(AreaSeries, {
lineVisible: false,
topColor: "transparent",
bottomColor: "transparent",
relativeGradient: true,
priceFormat: {
type: "custom",
minMove: 0.01,
formatter: formatPrice,
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
const price = chart.addSeries(LineSeries, {
color: COLORS.foreground,
lineWidth: 1.5,
priceFormat: {
type: "custom",
minMove: 0.01,
formatter: formatPrice,
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: false,
});
const candles = chart.addSeries(CandlestickSeries, {
visible: false,
upColor: COLORS.background,
downColor: COLORS.foreground,
wickUpColor: COLORS.foreground,
wickDownColor: COLORS.foreground,
borderUpColor: COLORS.foreground,
borderDownColor: COLORS.foreground,
borderVisible: true,
priceFormat: {
type: "custom",
minMove: 0.01,
formatter: formatPrice,
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
lastValueVisible: true,
});
const references = REFERENCES.map((definition) => {
const reference = chart.addSeries(LineSeries, {
color: rgba(definition.color, 0.75),
lineWidth: 1.5,
lineStyle: definition.style,
priceFormat: {
type: "custom",
minMove: 0.01,
formatter: formatPrice,
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
reference.setData(
rows.map((row) => referencePoint(row, definition.key)),
);
return reference;
});
const position = chart.addSeries(
HistogramSeries,
{
base: 0,
color: COLORS.green,
priceFormat: {
type: "custom",
minMove: 1,
formatter: (value) =>
value > 0 ? "long" : value < 0 ? "short" : "",
tickmarksFormatter: (values) => values.map(() => ""),
},
priceLineVisible: false,
lastValueVisible: true,
},
1,
);
area.setData(rows.map(areaPoint));
price.setData(
rows.map((row) => ({
time: row.time,
value: row.price,
})),
);
candles.setData(rows.map(candlePoint));
position.setData(rows.map(positionPoint));
area.setSeriesOrder(0);
for (const reference of references) reference.setSeriesOrder(1);
candles.setSeriesOrder(2);
price.setSeriesOrder(3);
chart
.panes()
.at(0)
?.setStretchFactor(PRICE_PANE_STRETCH);
chart
.panes()
.at(1)
?.setStretchFactor(POSITION_PANE_STRETCH);
chart.priceScale("right", 1).applyOptions({
borderColor: COLORS.faint,
borderVisible: true,
minimumWidth: PRICE_SCALE_MIN_WIDTH,
scaleMargins: { top: 0.12, bottom: 0.12 },
});
chart.subscribeCrosshairMove((parameter) => {
const timestamp = timestampForTime(parameter.time);
const row = Number.isFinite(timestamp)
? rowsByTime.get(timestamp)
: null;
setReadout(row ?? latestRow);
});
let priceCandlesVisible = false;
let pendingRange = null;
let rangeFrame = 0;
function updatePriceVisibility(range) {
const visibleBars = range ? range.to - range.from : Infinity;
const showCandles = priceCandlesVisible
? visibleBars <= PRICE_CANDLE_EXIT_BARS
: visibleBars <= PRICE_CANDLE_ENTER_BARS;
if (showCandles === priceCandlesVisible) return;
priceCandlesVisible = showCandles;
price.applyOptions({ visible: !showCandles });
candles.applyOptions({ visible: showCandles });
}
function schedulePriceVisibility(range) {
pendingRange = range;
if (rangeFrame) return;
rangeFrame = requestAnimationFrame(() => {
rangeFrame = 0;
updatePriceVisibility(pendingRange);
pendingRange = null;
});
}
chart.timeScale().fitContent();
chart.timeScale().subscribeVisibleLogicalRangeChange(
schedulePriceVisibility,
);
updatePriceVisibility(chart.timeScale().getVisibleLogicalRange());
}
async function load() {
const series = Object.fromEntries(await Promise.all(
Object.entries(ENDPOINTS).map(([name, url]) =>
fetchSeries(name, url).then((data) => [name, 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);
chartElement.setAttribute("aria-busy", "false");
statusElement.textContent = "";
}
load().catch((error) => {
statusElement.dataset.error = "true";
statusElement.textContent = "Unable to load sentiment";
chartElement.setAttribute("aria-busy", "false");
console.error(error);
});
</script>
</body>
</html>
+7 -2
View File
@@ -356,7 +356,7 @@
display: none;
}
#source-credit a::after {
#source-credit a:first-of-type::after {
content: " · ";
}
@@ -501,7 +501,12 @@
>Bitview.space</a
>
<span class="credit-copy">· Powered by</span>
<span>Bitcoin Research Kit</span>
<a
href="https://bitcoinresearchkit.org"
target="_blank"
rel="noreferrer"
>Bitcoin Research Kit</a
>
</div>
<div class="selection">