From f632ed82575892b02fe8114e2482a7633b7404ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:28:27 +0000 Subject: [PATCH 1/2] Liquidity chart: the wallet's share of each level Co-Authored-By: Claude Opus 5.5 --- src/features/swap.js | 45 ++++++++++++++++++++++++++++++++++++++++---- tests/swap.spec.mjs | 13 +++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/features/swap.js b/src/features/swap.js index 24df3e2..87b8737 100644 --- a/src/features/swap.js +++ b/src/features/swap.js @@ -1010,6 +1010,22 @@ function hoverPosition(tokenId) { document.querySelectorAll('#liquidity-positions tr[data-position]').forEach(row => row.classList.toggle('bg-white/5', row.dataset.position === tokenId)); depthChart?.update('none'); } +/** + * The wallet's share of each level (its fees' share while the price trades there): its positions' USDC over the + * level's, both L (√hi - √lo) in USD prices / 10^12 (USDC 6 decimals, DATA 18, whichever is token0) + */ +function levelShares(levels, ranges) { + const held = ranges.filter(r => r.L > 0); + return levels.map(l => { + if (!held.length || !(l.usd > 0)) return 0; + const mine = held.reduce((sum, r) => { + const [lo, hi] = [Math.max(l.lo, r.min), Math.min(l.hi, r.max)]; + return hi > lo ? sum + r.L * (Math.sqrt(hi) - Math.sqrt(lo)) * 1e-12 : sum; + }, 0); + return Math.min(1, mine / l.usd); + }); +} + const DEPTH_STEPS = [0.01, 0.02, 0.04, 0.08, 0.16]; // 25 levels each way: x1.28 up to x41 /** The pool's liquidity by price in bars (USDC blue, DATA orange), the price between them, the wallet's ranges shaded behind */ @@ -1028,7 +1044,7 @@ function renderDepth(depth, ranges) { const colors = levels.map(l => (l.side === 'bid' ? 'rgba(59, 130, 246, 0.6)' : 'rgba(249, 115, 22, 0.6)')); const data = { labels: levels.map(l => formatPrice(Math.sqrt(l.lo * l.hi))), - datasets: [{ data: levels.map(l => l.usd), backgroundColor: colors, borderWidth: 0, barPercentage: 1, categoryPercentage: 0.9, levels, ranges }] + datasets: [{ data: levels.map(l => l.usd), backgroundColor: colors, borderWidth: 0, barPercentage: 1, categoryPercentage: 0.9, levels, ranges, shares: levelShares(levels, ranges) }] }; // Wider levels show a wider range of prices const zoom = $('liquidity-zoom'); @@ -1086,6 +1102,25 @@ function renderDepth(depth, ranges) { } }; // The price: a line between the bids and the asks + // The wallet's share of each level: the bar's lower part, in white + const shareBars = { + id: 'liquidityShares', + afterDatasetsDraw(chart) { + const { shares } = chart.data.datasets[0]; + const bars = chart.getDatasetMeta(0).data; + if (!shares?.some(x => x > 0)) return; + const { ctx } = chart; + ctx.save(); + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; + bars.forEach((bar, k) => { + if (!(shares[k] > 0)) return; + const { x, y, base, width } = bar.getProps(['x', 'y', 'base', 'width'], true); + const height = (base - y) * shares[k]; + ctx.fillRect(x - width / 2, base - height, width, height); + }); + ctx.restore(); + } + }; const priceLine = { id: 'liquidityPrice', afterDatasetsDraw(chart) { @@ -1109,7 +1144,7 @@ function renderDepth(depth, ranges) { depthChart = new Chart(container.querySelector('canvas').getContext('2d'), { type: 'bar', data, - plugins: [rangeBands, priceLine], + plugins: [rangeBands, shareBars, priceLine], options: { responsive: true, maintainAspectRatio: false, @@ -1140,7 +1175,9 @@ function renderDepth(depth, ranges) { const total = l.side === 'bid' ? `Total USDC ${Utils.formatBigNumber(path.reduce((sum, x) => sum + x.counter, 0).toFixed(2))}` : `Total DATA ${formatData(data)}`; - return [total, `Average price ${formatPrice(data > 0 ? usd / data : l.lo)}`]; + const share = item.dataset.shares?.[item.dataIndex] || 0; + const yours = share > 0 ? [`Your share ${share < 0.01 ? '<1' : Math.round(share * 100)}%`] : []; + return [total, `Average price ${formatPrice(data > 0 ? usd / data : l.lo)}`, ...yours]; } } } @@ -1187,7 +1224,7 @@ function fitDraft() { /** The ranges on the chain's chart: the wallet's open positions, and the new position's while it is being made */ function depthRanges() { - const ranges = (Liquidity.positions() || []).filter(p => p.chain === state.chain && !p.closed).map(p => ({ tokenId: p.tokenId, min: p.min, max: p.max })); + const ranges = (Liquidity.positions() || []).filter(p => p.chain === state.chain && !p.closed).map(p => ({ tokenId: p.tokenId, min: p.min, max: p.max, L: Number(p.liquidity) })); const draft = LiquidityManage.draft(state.chain); return draft ? [...ranges, { tokenId: 'new', draft: true, ...draft }] : ranges; } diff --git a/tests/swap.spec.mjs b/tests/swap.spec.mjs index fc2f3fe..8f8616e 100644 --- a/tests/swap.spec.mjs +++ b/tests/swap.spec.mjs @@ -420,6 +420,19 @@ test('the Liquidity tab shows the pool, and the wallet\'s v4 positions with thei expect(dataset.colors.filter(c => c.startsWith('rgba(59, 130, 246')).length).toBe(25); expect(dataset.ranges).toHaveLength(1); expect(dataset.ranges[0].min).toBeCloseTo(0.00023518, 7); + // The wallet's share of each level: all of it inside its range (the mocked pool holds that position only), none outside + const shares = await page.evaluate(() => { + const d = window.Chart.getChart(document.querySelector('#liquidity-depth canvas')).data.datasets[0]; + const inside = d.levels.map((l, k) => [l, d.shares[k]]).filter(([l]) => l.lo >= d.ranges[0].min && l.hi <= d.ranges[0].max); + const outside = d.levels.map((l, k) => [l, d.shares[k]]).filter(([l]) => l.hi <= d.ranges[0].min || l.lo >= d.ranges[0].max); + const k = d.levels.findIndex(l => l.side === 'ask'); + const chart = window.Chart.getChart(document.querySelector('#liquidity-depth canvas')); + return { inside: inside.map(([, x]) => x), outside: outside.map(([, x]) => x), label: chart.options.plugins.tooltip.callbacks.label({ dataset: d, dataIndex: k }) }; + }); + expect(shares.inside.length).toBeGreaterThan(2); + shares.inside.forEach(x => expect(x).toBeCloseTo(1, 2)); + shares.outside.forEach(x => expect(x).toBe(0)); + expect(shares.label[2]).toBe('Your share 100%'); // A bar's tooltip sums the levels from the price to it (as the Depth): the second ask holds the first two const tooltip = await page.evaluate(() => { const chart = window.Chart.getChart(document.querySelector('#liquidity-depth canvas')); From 7c1e4fa63a7d4163c2d6c3633be138610ad1f0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:34:19 +0000 Subject: [PATCH 2/2] Liquidity chart: the share drawn over the range's prices only Co-Authored-By: Claude Opus 5.5 --- src/features/swap.js | 42 ++++++++++++++++++++++++++++-------------- tests/swap.spec.mjs | 9 +++++++++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/features/swap.js b/src/features/swap.js index 87b8737..85f79b5 100644 --- a/src/features/swap.js +++ b/src/features/swap.js @@ -1012,17 +1012,24 @@ function hoverPosition(tokenId) { } /** * The wallet's share of each level (its fees' share while the price trades there): its positions' USDC over the - * level's, both L (√hi - √lo) in USD prices / 10^12 (USDC 6 decimals, DATA 18, whichever is token0) + * level's, both L (√hi - √lo) in USD prices / 10^12 (USDC 6 decimals, DATA 18, whichever is token0). Per level the + * share of the whole level (the tooltip), and per position the part of the level inside its range with the share + * there (drawn: a range ending inside a level covers that part of the bar only) */ function levelShares(levels, ranges) { const held = ranges.filter(r => r.L > 0); return levels.map(l => { - if (!held.length || !(l.usd > 0)) return 0; - const mine = held.reduce((sum, r) => { - const [lo, hi] = [Math.max(l.lo, r.min), Math.min(l.hi, r.max)]; - return hi > lo ? sum + r.L * (Math.sqrt(hi) - Math.sqrt(lo)) * 1e-12 : sum; - }, 0); - return Math.min(1, mine / l.usd); + const parts = []; + let mine = 0; + if (held.length && l.usd > 0) { + for (const r of held) { + const [lo, hi] = [Math.max(l.lo, r.min), Math.min(l.hi, r.max)]; + if (!(hi > lo)) continue; + mine += r.L * (Math.sqrt(hi) - Math.sqrt(lo)) * 1e-12; + parts.push({ lo, hi, share: Math.min(1, r.L * (Math.sqrt(l.hi) - Math.sqrt(l.lo)) * 1e-12 / l.usd) }); + } + } + return { share: l.usd > 0 ? Math.min(1, mine / l.usd) : 0, parts }; }); } @@ -1044,7 +1051,10 @@ function renderDepth(depth, ranges) { const colors = levels.map(l => (l.side === 'bid' ? 'rgba(59, 130, 246, 0.6)' : 'rgba(249, 115, 22, 0.6)')); const data = { labels: levels.map(l => formatPrice(Math.sqrt(l.lo * l.hi))), - datasets: [{ data: levels.map(l => l.usd), backgroundColor: colors, borderWidth: 0, barPercentage: 1, categoryPercentage: 0.9, levels, ranges, shares: levelShares(levels, ranges) }] + datasets: [{ data: levels.map(l => l.usd), backgroundColor: colors, borderWidth: 0, barPercentage: 1, categoryPercentage: 0.9, levels, ranges, ...(() => { + const shares = levelShares(levels, ranges); + return { shares: shares.map(x => x.share), shareParts: shares.map(x => x.parts) }; + })() }] }; // Wider levels show a wider range of prices const zoom = $('liquidity-zoom'); @@ -1102,21 +1112,25 @@ function renderDepth(depth, ranges) { } }; // The price: a line between the bids and the asks - // The wallet's share of each level: the bar's lower part, in white + // The wallet's share of each level: the bar's lower part, in white, over the prices of its ranges only const shareBars = { id: 'liquidityShares', afterDatasetsDraw(chart) { - const { shares } = chart.data.datasets[0]; + const { shareParts, levels } = chart.data.datasets[0]; const bars = chart.getDatasetMeta(0).data; - if (!shares?.some(x => x > 0)) return; + if (!shareParts?.some(parts => parts.length) || bars.length !== levels.length) return; const { ctx } = chart; ctx.save(); ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; bars.forEach((bar, k) => { - if (!(shares[k] > 0)) return; const { x, y, base, width } = bar.getProps(['x', 'y', 'base', 'width'], true); - const height = (base - y) * shares[k]; - ctx.fillRect(x - width / 2, base - height, width, height); + const l = levels[k]; + // A price's x inside the bar, in proportion of the level's log width + const at = (price) => x - width / 2 + Math.log(price / l.lo) / Math.log(l.hi / l.lo) * width; + for (const part of shareParts[k]) { + const height = (base - y) * part.share; + ctx.fillRect(at(part.lo), base - height, at(part.hi) - at(part.lo), height); + } }); ctx.restore(); } diff --git a/tests/swap.spec.mjs b/tests/swap.spec.mjs index 8f8616e..1ea798b 100644 --- a/tests/swap.spec.mjs +++ b/tests/swap.spec.mjs @@ -433,6 +433,15 @@ test('the Liquidity tab shows the pool, and the wallet\'s v4 positions with thei shares.inside.forEach(x => expect(x).toBeCloseTo(1, 2)); shares.outside.forEach(x => expect(x).toBe(0)); expect(shares.label[2]).toBe('Your share 100%'); + // A level the range only enters: its white part over the range's prices, not the whole bar + const edge = await page.evaluate(() => { + const d = window.Chart.getChart(document.querySelector('#liquidity-depth canvas')).data.datasets[0]; + const k = d.levels.findIndex(l => l.lo < d.ranges[0].min && l.hi > d.ranges[0].min); + return k < 0 ? null : { level: d.levels[k], parts: d.shareParts[k] }; + }); + expect(edge.parts).toHaveLength(1); + expect(edge.parts[0].lo).toBeCloseTo(edge.level.lo < 0 ? 0 : Math.max(edge.level.lo, 0.00023518), 7); + expect(edge.parts[0].hi).toBe(edge.level.hi); // A bar's tooltip sums the levels from the price to it (as the Depth): the second ask holds the first two const tooltip = await page.evaluate(() => { const chart = window.Chart.getChart(document.querySelector('#liquidity-depth canvas'));