// Market Read: who is opening and who is closing across 20 of Hyperliquid's largest perps, in a
// panel of its own. BTC's bars clock it; every market is read the same way.
// Map each market by its price change and its open interest change over `momBars` bars,
// in four quadrants: price and OI up is new longs, price up on falling OI is shorts
// covering, price down on rising OI is new shorts, both down is longs exiting. Each
// point trails where it was, as a relative rotation graph does, so the way it is
// turning shows.
// History how many of the 20 sat in each quadrant on every bar, stacked.
// Absorption a rolling PCA of the 20 markets' returns (Kritzman, Li, Page and Rigobon, 2010): the
// share of their variance the top 4 components (a fifth of the assets) explain. High
// means everything is one trade.
// Own move residual momentum: each market's return less its beta to the equal-weighted market
// times the market's return, what it did on its own.
// Open interest is in coins, so it counts positions opened and closed, not price moving the old ones.
// Every value is as of a closed bar; the PCA and betas are fitted on bars before.
indicator('Market Read', { panel: { market: 'BTC', interval: '1h' } });
const momBars = input.int('Change bars', 42, { min: 2, max: 500, group: 'Model', tooltip: 'Bars the price and open interest changes are measured over (42 on 4h is 7 days)' });
const winLen = input.int('PCA window', 120, { min: 30, max: 1000, group: 'Model', tooltip: 'Bars of returns the components and betas are fitted on' });
const refit = input.int('Refit every', 6, { min: 1, max: 100, group: 'Model' });
const tailPoints = input.int('Tail points', 4, { min: 0, max: 30, group: 'Display', tooltip: 'Earlier positions each market trails on the map' });
const tailStep = input.int('Tail step', 6, { min: 1, max: 50, group: 'Display', tooltip: 'Bars between a tail\'s points' });
const historyBars = input.int('History bars', 240, { min: 20, max: 1000, group: 'Display' });
const UNIVERSE = ['BTC', 'ETH', 'SOL', 'HYPE', 'XRP', 'DOGE', 'SUI', 'AVAX', 'LINK', 'ENA', 'AAVE', 'TAO', 'ZEC', 'BNB', 'LTC', 'ADA', 'WLD', 'FARTCOIN', 'kPEPE', 'WIF'];
const N = UNIVERSE.length;
const K = 4;
const LONGS = color.up;
// The theme's own blue and amber (its study palette's slots 12 and 0), so they read on light themes too.
const COVER = 'theme:study:12';
const EXIT = 'theme:study:0';
const SHORTS = color.down;
// Quadrants by index: 0 new longs, 1 shorts covering, 2 longs exiting, 3 new shorts.
const QUAD = [LONGS, COVER, EXIT, SHORTS];
const QNAME = ['New longs', 'Shorts covering', 'Longs exiting', 'New shorts'];
const chart = bars();
const markets = UNIVERSE.map((m) => bars({ market: m }));
const ois = UNIVERSE.map((m) => openInterest({ market: m }));
const prev = UNIVERSE.map(() => series(2));
const logPx = UNIVERSE.map(() => series(600));
const logOi = UNIVERSE.map(() => series(600));
// Each market's two changes per bar, kept for its tail.
const chg = UNIVERSE.map(() => series(1600));
const oiChg = UNIVERSE.map(() => series(1600));
const times = series(1001);
const mkt = series(600);
const betas = UNIVERSE.map(() => stats.beta(winLen));
const pca = ml.pca(N, { window: winLen, components: K, standardize: true, refit });
const mktLevel = state.bar(0);
const tiles = panel.stats('', { format: 'percent', precision: 1 });
const map = panel.scatter('Positioning', {
x: { title: 'Price change', format: 'percent', precision: 1, scale: 'sqrt' },
y: { title: 'OI change', format: 'percent', precision: 1, scale: 'sqrt' },
quadrants: [{ label: QNAME[3], color: SHORTS }, { label: QNAME[0], color: LONGS }, { label: QNAME[2], color: EXIT }, { label: QNAME[1], color: COVER }],
});
const table = panel.table('Markets', {
columns: [
{ title: 'Market', format: 'market' },
{ title: 'Positioning', format: 'text' },
{ title: 'Price', format: 'percent', precision: 1, signed: true },
{ title: 'OI', format: 'percent', precision: 1, signed: true },
{ title: 'Own move', format: 'percent', precision: 1, signed: true },
{ title: 'Beta', format: 'number', precision: 2 },
],
sort: 'Own move',
});
const history = graph('Positioning history', { axis: 'time', stack: true, format: 'percent', precision: 0 });
const stack = QUAD.map((hue, q) => history.bars(QNAME[q], { color: hue }));
function quadrant(ret: number, oi: number): number {
return ret >= 0 ? (oi >= 0 ? 0 : 1) : oi >= 0 ? 3 : 2;
}
const pct = (logChange: number) => (isNa(logChange) ? na : (Math.exp(logChange) - 1) * 100);
chart.onBar(() => {
const r: number[] = [];
let sum = 0;
let have = 0;
for (let i = 0; i < N; i += 1) {
const c = markets[i].close;
const p = prev[i].ago(1);
prev[i].set(c);
const x = isNa(c) || isNa(p) || p <= 0 ? na : Math.log(c / p);
r.push(x);
if (!isNa(x)) {
sum += x;
have += 1;
}
logPx[i].set(isNa(c) || c <= 0 ? na : Math.log(c));
// A market's newest hour can have no open interest yet (its minutes land a poll behind): it holds the last it had.
const o = ois[i].available ? ois[i].close() : na;
logOi[i].set(isNa(o) || o <= 0 ? logOi[i].ago(1) : Math.log(o));
}
const m = have >= N - 2 ? sum / have : na;
if (!isNa(m)) mktLevel.value += m;
mkt.set(mktLevel.value);
times.set(chart.time);
const pc = pca.update(r.map((x) => (isNa(x) ? nz(m) : x)));
let ar = 0;
for (let k = 0; k < K; k += 1) ar += nz(pc.explained[k]);
ar = isNa(pc.explained[0]) ? na : ar;
const counts = [0, 0, 0, 0];
let n = 0;
const mk = mkt.value - mkt.ago(momBars);
const resid: number[] = [];
const betaOf: number[] = [];
for (let i = 0; i < N; i += 1) {
const b = betas[i].update(r[i], m);
const pr = logPx[i].value - logPx[i].ago(momBars);
const oc = logOi[i].value - logOi[i].ago(momBars);
chg[i].set(pr);
oiChg[i].set(oc);
betaOf.push(b);
resid.push(isNa(b) || isNa(pr) || isNa(mk) ? na : pr - b * mk);
if (isNa(pr) || isNa(oc)) continue;
counts[quadrant(pr, oc)] += 1;
n += 1;
}
// The stacked history: each quadrant's share of the markets with data, kept for `historyBars` bars.
for (let q = 0; q < 4; q += 1) stack[q].set(chart.time, n < N - 2 ? na : (counts[q] / n) * 100);
const old = times.ago(historyBars);
if (!isNa(old)) for (let q = 0; q < 4; q += 1) stack[q].delete(old);
if (!chart.isLast) return;
for (let i = 0; i < N; i += 1) {
const pr = chg[i].value;
const oc = oiChg[i].value;
const q = isNa(pr) || isNa(oc) ? -1 : quadrant(pr, oc);
const tail: [number, number][] = [];
for (let k = tailPoints; k >= 1; k -= 1) {
const tx = pct(chg[i].ago(k * tailStep));
const ty = pct(oiChg[i].ago(k * tailStep));
if (!isNa(tx) && !isNa(ty)) tail.push([tx, ty]);
}
map.point(UNIVERSE[i], pct(pr), pct(oc), { label: UNIVERSE[i], color: q < 0 ? null : QUAD[q], tail, note: q < 0 ? undefined : QNAME[q] });
table.row(UNIVERSE[i], [UNIVERSE[i], q < 0 ? 'โ' : QNAME[q], pct(pr), pct(oc), pct(resid[i]), betaOf[i]], { colors: [null, q < 0 ? null : QUAD[q]] });
}
let lead = 0;
for (let q = 1; q < 4; q += 1) if (counts[q] > counts[lead]) lead = q;
let oiSum = 0;
for (let i = 0; i < N; i += 1) if (!isNa(oiChg[i].value)) oiSum += oiChg[i].value;
const hours = chart.interval.isTime ? (momBars * chart.interval.ms) / 3600000 : na;
const span = isNa(hours) ? `${fmt.num(momBars, 0)} bars` : hours >= 48 && hours % 24 === 0 ? `${fmt.num(hours / 24, 0)}d` : `${fmt.num(hours, 0)}h`;
const regime = isNa(ar) ? '' : ar > 0.75 ? 'One trade' : ar > 0.6 ? 'Coupled' : 'Dispersed';
tiles.set('Positioning', QNAME[lead], { color: QUAD[lead], note: `${fmt.num(counts[lead], 0)} of ${fmt.num(n, 0)} markets` });
tiles.set('Market', pct(mk), { signed: true, note: `equal-weighted, ${span}` });
tiles.set('Open interest', n > 0 ? pct(oiSum / n) : na, { signed: true, note: `average, ${span}` });
tiles.set('Absorption', isNa(ar) ? na : ar * 100, { note: isNa(ar) ? 'fitting' : `${regime} ยท top ${K} components` });
});