price multipliers instead of ticks

This commit is contained in:
johba 2025-08-16 18:22:32 +02:00
parent 2205ae719b
commit 6a012c5fd9
2 changed files with 186 additions and 129 deletions

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ docs/
*~ *~
*.swp *.swp
*.swo *.swo
.playwright-mcp/

View file

@ -125,10 +125,19 @@
<body> <body>
<h2>Kraiken Liquidity Position Simulator</h2> <h2>Kraiken Liquidity Position Simulator</h2>
<div style="background-color: #e3f2fd; border-radius: 4px; padding: 15px; margin-bottom: 20px; border-left: 4px solid #2196f3;"> <div style="background-color: #e3f2fd; border-radius: 4px; padding: 15px; margin-bottom: 20px; border-left: 4px solid #2196f3;">
<strong>📊 Anti-Arbitrage Three-Position Strategy</strong><br> <strong>📊 Anti-Arbitrage Three-Position Strategy (Uniswap V3 1% Pool)</strong><br>
<em>Floor</em>: Deep liquidity position - contains ETH when below current price, KRAIKEN when above<br> <br>
<em>Anchor</em>: Shallow liquidity around current price for fast slippage<br> <strong>Position Strategy:</strong><br>
<em>Discovery</em>: Edge liquidity position - contains KRAIKEN when below current price, ETH when above <em>Floor</em>: Deep liquidity position - holds ETH when ETH is cheap (below current price)<br>
<em>Anchor</em>: Shallow liquidity around current price for fast price discovery<br>
<em>Discovery</em>: Edge liquidity position - holds KRAIKEN when ETH is expensive (above current price)<br>
<br>
<strong>Price Multiples:</strong> Shows ETH price relative to current (1x):<br>
• 0.5x = ETH is half as expensive (Floor position holds ETH)<br>
• 1x = Current ETH price (red dashed line)<br>
• 2x = ETH is twice as expensive (Discovery position holds KRAIKEN)<br>
<br>
<em>Note: The x-axis automatically adjusts based on token ordering in the pool</em>
</div> </div>
<div id="status">Loading profitable scenario data...</div> <div id="status">Loading profitable scenario data...</div>
<textarea id="csvInput" placeholder="Paste CSV formatted data here..." style="display: none;"></textarea> <textarea id="csvInput" placeholder="Paste CSV formatted data here..." style="display: none;"></textarea>
@ -310,6 +319,24 @@
return Math.sqrt(price); return Math.sqrt(price);
} }
// Convert tick to price multiple relative to current price
// This represents ETH price multiples (how expensive ETH is relative to current)
function tickToPriceMultiple(tick, currentTick, token0isWeth) {
const price = tickToPrice(tick);
const currentPrice = tickToPrice(currentTick);
if (token0isWeth) {
// When ETH is token0, price = KRAIKEN/ETH
// We want ETH price multiple, so we need to invert
// Higher tick = more KRAIKEN per ETH = cheaper ETH
return currentPrice / price;
} else {
// When KRAIKEN is token0, price = ETH/KRAIKEN
// This is already ETH price, so just divide
return price / currentPrice;
}
}
// Calculate the invariant liquidity value from token amounts // Calculate the invariant liquidity value from token amounts
// This represents the actual liquidity deployed to the position, independent of current price // This represents the actual liquidity deployed to the position, independent of current price
function calculateInvariantLiquidity(token0Amount, token1Amount, tickLower, tickUpper, positionName = '') { function calculateInvariantLiquidity(token0Amount, token1Amount, tickLower, tickUpper, positionName = '') {
@ -486,24 +513,6 @@
header.innerHTML = `<h3>${precedingAction}</h3>`; header.innerHTML = `<h3>${precedingAction}</h3>`;
scenarioContainer.appendChild(header); scenarioContainer.appendChild(header);
// Create legend
const legend = document.createElement('div');
legend.className = 'legend';
legend.innerHTML = `
<div class="legend-item">
<div class="legend-color floor"></div>
<span>Floor Position (Foundation)</span>
</div>
<div class="legend-item">
<div class="legend-color anchor"></div>
<span>Anchor Position (Current Price)</span>
</div>
<div class="legend-item">
<div class="legend-color discovery"></div>
<span>Discovery Position (Growth)</span>
</div>
`;
scenarioContainer.appendChild(legend);
// Create charts container // Create charts container
const chartsContainer = document.createElement('div'); const chartsContainer = document.createElement('div');
@ -526,29 +535,60 @@
scenarioContainer.appendChild(chartsContainer); scenarioContainer.appendChild(chartsContainer);
// Create summary panel // Create summary panel
const summaryPanel = createSummaryPanel(positions, currentTick); const summaryPanel = createSummaryPanel(positions, currentTick, token0isWeth);
scenarioContainer.appendChild(summaryPanel); scenarioContainer.appendChild(summaryPanel);
// Add to page // Add to page
document.getElementById('simulations').appendChild(scenarioContainer); document.getElementById('simulations').appendChild(scenarioContainer);
// Create the combined chart // Create the combined chart
createCombinedChart(combinedChart, positions, currentTick, totalLiquidity); createCombinedChart(combinedChart, positions, currentTick, totalLiquidity, token0isWeth);
} }
function createCombinedChart(chartDiv, positions, currentTick, totalLiquidity) { function createCombinedChart(chartDiv, positions, currentTick, totalLiquidity, token0isWeth) {
const positionKeys = ['floor', 'anchor', 'discovery']; const positionKeys = ['floor', 'anchor', 'discovery'];
// Calculate bar widths to represent actual tick ranges // Convert positions to price multiples
const barWidths = positionKeys.map(key => { const priceMultiplePositions = {};
positionKeys.forEach(key => {
const pos = positions[key]; const pos = positions[key];
return pos.tickUpper - pos.tickLower; // Width = actual tick range const lowerMultiple = tickToPriceMultiple(pos.tickLower, currentTick, token0isWeth);
const upperMultiple = tickToPriceMultiple(pos.tickUpper, currentTick, token0isWeth);
const centerMultiple = tickToPriceMultiple(pos.tickLower + (pos.tickUpper - pos.tickLower) / 2, currentTick, token0isWeth);
priceMultiplePositions[key] = {
lowerMultiple: lowerMultiple,
upperMultiple: upperMultiple,
centerMultiple: centerMultiple,
width: upperMultiple - lowerMultiple,
...pos
};
console.log(`Position ${key}:`, {
ticks: [pos.tickLower, pos.tickUpper],
currentTick: currentTick,
multiples: [lowerMultiple, upperMultiple],
centerMultiple: centerMultiple,
token0isWeth: token0isWeth
});
// Warn about extreme positions
if (pos.tickLower > 180000 || pos.tickUpper > 180000) {
console.warn(`EXTREME TICKS: ${key} position has ticks above 180000, which represents extreme price multiples`);
}
});
// Calculate bar widths to represent actual price multiple ranges
const barWidths = positionKeys.map(key => {
const pos = priceMultiplePositions[key];
return pos.width; // Width in price multiple space
}); });
// Calculate bar positions (centered in tick ranges) // Calculate bar positions (centered in price multiple ranges)
const barPositions = positionKeys.map(key => { const barPositions = positionKeys.map(key => {
const pos = positions[key]; const pos = priceMultiplePositions[key];
return pos.tickLower + (pos.tickUpper - pos.tickLower) / 2; return pos.centerMultiple;
}); });
// ETH trace (left y-axis) // ETH trace (left y-axis)
@ -572,8 +612,8 @@
} }
}, },
text: positionKeys.map(key => { text: positionKeys.map(key => {
const pos = positions[key]; const pos = priceMultiplePositions[key];
return `${pos.name}<br>ETH: ${pos.eth.toFixed(6)}<br>Range: [${pos.tickLower}, ${pos.tickUpper}]`; return `${pos.name}<br>ETH: ${pos.eth.toFixed(6)}<br>Range: ${pos.lowerMultiple.toFixed(3)}x - ${pos.upperMultiple.toFixed(3)}x`;
}), }),
hoverinfo: 'text' hoverinfo: 'text'
}; };
@ -603,20 +643,25 @@
} }
}, },
text: positionKeys.map(key => { text: positionKeys.map(key => {
const pos = positions[key]; const pos = priceMultiplePositions[key];
return `${pos.name}<br>KRAIKEN: ${pos.kraiken.toFixed(6)}<br>Range: [${pos.tickLower}, ${pos.tickUpper}]`; return `${pos.name}<br>KRAIKEN: ${pos.kraiken.toFixed(6)}<br>Range: ${pos.lowerMultiple.toFixed(3)}x - ${pos.upperMultiple.toFixed(3)}x`;
}), }),
hoverinfo: 'text' hoverinfo: 'text'
}; };
// Calculate x-axis range based on position ranges with some padding // Calculate x-axis range based on position ranges with some padding
const allTicks = positionKeys.flatMap(key => [positions[key].tickLower, positions[key].tickUpper]); // Cap extreme values to keep chart readable
const minTick = Math.min(...allTicks); const MAX_REASONABLE_MULTIPLE = 50; // Cap at 50x for readability
const maxTick = Math.max(...allTicks); const MIN_REASONABLE_MULTIPLE = 0.02; // Cap at 0.02x for readability
const tickRange = maxTick - minTick;
const padding = tickRange * 0.1; // 10% padding on each side const allMultiples = positionKeys.flatMap(key => [priceMultiplePositions[key].lowerMultiple, priceMultiplePositions[key].upperMultiple]);
const xAxisMin = minTick - padding; const cappedMultiples = allMultiples.map(m => Math.min(MAX_REASONABLE_MULTIPLE, Math.max(MIN_REASONABLE_MULTIPLE, m)));
const xAxisMax = maxTick + padding; const minMultiple = Math.min(...cappedMultiples);
const maxMultiple = Math.max(...cappedMultiples);
const multipleRange = maxMultiple - minMultiple;
const padding = multipleRange * 0.1; // 10% padding on each side
const xAxisMin = Math.max(0.01, minMultiple - padding); // Don't go below 0.01x
const xAxisMax = Math.min(100, maxMultiple + padding); // Cap at 100x max
// Debug logging for chart range // Debug logging for chart range
console.log('Chart x-axis range:', { xAxisMin, xAxisMax }); console.log('Chart x-axis range:', { xAxisMin, xAxisMax });
@ -630,14 +675,23 @@
// Calculate max values for proper y-axis alignment // Calculate max values for proper y-axis alignment
const maxEth = Math.max(...positionKeys.map(key => positions[key].eth)); const maxEth = Math.max(...positionKeys.map(key => positions[key].eth));
const maxKraiken = Math.max(...positionKeys.map(key => positions[key].kraiken)); const maxKraiken = Math.max(...positionKeys.map(key => positions[key].kraiken));
const showPriceLine = currentTick >= xAxisMin && currentTick <= xAxisMax; const showPriceLine = true; // Always show price line at 1x
// Create liquidity × ticks traces for each position // Create liquidity × ticks traces for each position using shape/filled area approach
const liquidityTraces = positionKeys.map(key => { const liquidityTraces = [];
const pos = positions[key]; const shapes = [];
positionKeys.forEach((key, index) => {
const pos = priceMultiplePositions[key];
const tickRange = pos.tickUpper - pos.tickLower; const tickRange = pos.tickUpper - pos.tickLower;
const totalLiquidity = pos.liquidity * tickRange; const totalLiquidity = pos.liquidity * tickRange;
// Skip positions with zero liquidity
if (pos.liquidity === 0 || totalLiquidity === 0) {
console.warn(`Skipping ${key} position: zero liquidity`);
return;
}
// Debug logging for very small or large values // Debug logging for very small or large values
if (totalLiquidity < 1 || tickRange > 10000 || pos.tickLower > 180000) { if (totalLiquidity < 1 || tickRange > 10000 || pos.tickLower > 180000) {
console.log(`Warning: ${key} position has unusual values:`, { console.log(`Warning: ${key} position has unusual values:`, {
@ -645,108 +699,104 @@
tickRange: tickRange, tickRange: tickRange,
totalLiquidity: totalLiquidity, totalLiquidity: totalLiquidity,
ticks: [pos.tickLower, pos.tickUpper], ticks: [pos.tickLower, pos.tickUpper],
tickCenter: pos.tickLower + (pos.tickUpper - pos.tickLower) / 2 lowerMultiple: pos.lowerMultiple,
upperMultiple: pos.upperMultiple
}); });
} }
// Note: minVisibleLiquidity will be calculated after all positions are processed // Create a filled area for each position to show its exact range
// Cap display coordinates to keep within visible range
// For extremely low positions, ensure they're visible at the left edge
let displayLower = pos.lowerMultiple;
let displayUpper = pos.upperMultiple;
// Note: Width adjustment will be done after x-axis range is calculated // Ensure positions are visible even at extreme values
const visibleWidth = tickRange; if (pos.upperMultiple < 0.01) {
// Position is entirely below 0.01x - show it at the left edge
displayLower = xAxisMin;
displayUpper = xAxisMin * 1.5;
} else if (pos.lowerMultiple > 50) {
// Position is entirely above 50x - show it at the right edge
displayLower = xAxisMax * 0.8;
displayUpper = xAxisMax;
} else {
// Normal capping for positions that span the visible range
displayLower = Math.max(xAxisMin, Math.min(xAxisMax, pos.lowerMultiple));
displayUpper = Math.max(xAxisMin, Math.min(xAxisMax, pos.upperMultiple));
}
return { // Add indicator if position extends beyond visible range
x: [pos.tickLower + (pos.tickUpper - pos.tickLower) / 2], let rangeText = `Range: ${pos.lowerMultiple.toFixed(3)}x - ${pos.upperMultiple.toFixed(3)}x`;
y: [totalLiquidity], // Will be adjusted later let extendsBeyond = false;
width: visibleWidth, if (pos.lowerMultiple < xAxisMin || pos.upperMultiple > xAxisMax) {
type: 'bar', rangeText += ' <b>(extends beyond chart)</b>';
name: `${pos.name} Total Liquidity`, extendsBeyond = true;
marker: { }
color: POSITION_COLORS[key],
opacity: 0.8, // For extremely high or low multiples, show in scientific notation
line: { if (pos.upperMultiple > 100 || pos.lowerMultiple < 0.01) {
color: 'white', const lowerStr = pos.lowerMultiple < 0.01 ? pos.lowerMultiple.toExponential(2) : pos.lowerMultiple.toFixed(3) + 'x';
width: 2 const upperStr = pos.upperMultiple > 100 ? pos.upperMultiple.toExponential(2) : pos.upperMultiple.toFixed(3) + 'x';
} rangeText = `Range: ${lowerStr} - ${upperStr}`;
// Check if this is likely a VWAP protection position
if (key === 'floor' && (pos.lowerMultiple < 0.01 || pos.upperMultiple > 100)) {
rangeText += ' <b>(VWAP Protection - ETH Scarcity)</b>';
} else {
rangeText += ' <b>(extreme range)</b>';
}
}
const trace = {
x: [displayLower, displayLower, displayUpper, displayUpper],
y: [0, totalLiquidity, totalLiquidity, 0],
fill: 'toself',
fillcolor: POSITION_COLORS[key],
opacity: extendsBeyond ? 0.5 : 0.7,
type: 'scatter',
mode: 'lines',
line: {
color: POSITION_COLORS[key],
width: 2,
dash: extendsBeyond ? 'dash' : 'solid'
}, },
text: `${pos.name}<br>Liquidity: ${pos.liquidity.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>Tick Range: ${tickRange.toLocaleString()}<br>Total (L×Ticks): ${totalLiquidity.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}<br>ETH: ${pos.eth.toFixed(6)}<br>KRAIKEN: ${pos.kraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>Range: [${pos.tickLower.toLocaleString()}, ${pos.tickUpper.toLocaleString()}]`, name: pos.name,
text: `${pos.name}<br>Liquidity: ${pos.liquidity.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>Tick Range: ${tickRange.toLocaleString()}<br>Total (L×Ticks): ${totalLiquidity.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}<br>ETH: ${pos.eth.toFixed(6)}<br>KRAIKEN: ${pos.kraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>${rangeText}`,
hoverinfo: 'text', hoverinfo: 'text',
showlegend: false showlegend: true
}; };
liquidityTraces.push(trace);
}); });
const data = liquidityTraces; const data = liquidityTraces;
// Calculate max and min total liquidity (L × ticks) for y-axis scaling // Calculate max and min total liquidity (L × ticks) for y-axis scaling
const totalLiquidities = positionKeys.map(key => { const totalLiquidities = positionKeys.map(key => {
const pos = positions[key]; const pos = priceMultiplePositions[key];
return pos.liquidity * (pos.tickUpper - pos.tickLower); const tickRange = pos.tickUpper - pos.tickLower;
}); return pos.liquidity * tickRange;
const maxTotalLiquidity = Math.max(...totalLiquidities); }).filter(l => l > 0); // Only consider non-zero liquidities
const minTotalLiquidity = Math.min(...totalLiquidities.filter(l => l > 0));
// Calculate x-axis range first const maxTotalLiquidity = totalLiquidities.length > 0 ? Math.max(...totalLiquidities) : 1;
const allTicksForRange = []; const minTotalLiquidity = totalLiquidities.length > 0 ? Math.min(...totalLiquidities) : 0.1;
positionKeys.forEach(key => {
allTicksForRange.push(positions[key].tickLower, positions[key].tickUpper);
});
allTicksForRange.push(currentTick);
const minTickForWidth = Math.min(...allTicksForRange);
const maxTickForWidth = Math.max(...allTicksForRange);
const tickRangeTotal = maxTickForWidth - minTickForWidth;
const paddingForWidth = tickRangeTotal * 0.1;
const xAxisMinForWidth = minTickForWidth - paddingForWidth;
const xAxisMaxForWidth = maxTickForWidth + paddingForWidth;
const xRange = xAxisMaxForWidth - xAxisMinForWidth;
// Calculate minimum visible width as 2% of x-axis range
const minVisibleWidth = xRange * 0.02;
// Adjust bar widths to ensure visibility
liquidityTraces.forEach((trace, index) => {
const pos = positions[positionKeys[index]];
const actualWidth = pos.tickUpper - pos.tickLower;
if (actualWidth < minVisibleWidth) {
trace.width = minVisibleWidth;
// Add note about width adjustment
trace.text += `<br><i>(Width expanded for visibility - actual: ${actualWidth} ticks)</i>`;
}
});
// Sort liquidity values to find appropriate thresholds
const sortedLiquidities = [...totalLiquidities].sort((a, b) => a - b);
// Ensure all bars are visible on the log scale
// Set minimum height to be 2% of the median value, which should make all bars clearly visible
const medianLiquidity = sortedLiquidities[Math.floor(sortedLiquidities.length / 2)];
const minVisibleLiquidity = medianLiquidity * 0.02;
// Adjust y values to ensure minimum visibility
liquidityTraces.forEach((trace, index) => {
const actualValue = totalLiquidities[index];
if (actualValue < minVisibleLiquidity) {
trace.y[0] = minVisibleLiquidity;
// Add a note to the hover text that this value was adjusted for visibility
trace.text += `<br><i>(Height adjusted for visibility - actual: ${actualValue.toExponential(2)})</i>`;
}
});
// Ensure minimum is at least 1e-10 for log scale // Ensure minimum is at least 1e-10 for log scale
const yMin = Math.max(1e-10, Math.min(minTotalLiquidity / 100, minVisibleLiquidity / 10)); const yMin = Math.max(1e-10, minTotalLiquidity / 100);
const yMax = maxTotalLiquidity * 10;
if (showPriceLine) { if (showPriceLine) {
const priceLineTrace = { const priceLineTrace = {
x: [currentTick, currentTick], x: [1, 1], // Current price is always at 1x
y: [yMin, maxTotalLiquidity * 10], // Use log scale range y: [yMin, yMax], // Use calculated y range
mode: 'lines', mode: 'lines',
line: { line: {
color: 'red', color: 'red',
width: 3, width: 3,
dash: 'dash' dash: 'dash'
}, },
name: 'Current Price', name: 'Current Price (1x)',
hoverinfo: 'x', hoverinfo: 'name',
text: [`Current Price: ${currentTick}`],
showlegend: true showlegend: true
}; };
data.push(priceLineTrace); data.push(priceLineTrace);
@ -754,14 +804,17 @@
const layout = { const layout = {
title: { title: {
text: `Total Liquidity Distribution (L × Tick Range) - Current Price: ${currentTick}${showPriceLine ? '' : ' - Outside Range'}`, text: `Total Liquidity Distribution (L × Tick Range)`,
font: { size: 16 } font: { size: 16 }
}, },
xaxis: { xaxis: {
title: 'Price Ticks', title: 'Price Multiple (relative to current price)',
showgrid: true, showgrid: true,
gridcolor: '#e0e0e0', gridcolor: '#e0e0e0',
range: [xAxisMin, xAxisMax] range: [Math.log10(xAxisMin), Math.log10(xAxisMax)],
tickformat: '.2f',
ticksuffix: 'x',
type: 'log' // Use log scale for better visualization of price multiples
}, },
yaxis: { yaxis: {
title: 'Total Liquidity (L × Ticks)', title: 'Total Liquidity (L × Ticks)',
@ -770,7 +823,7 @@
gridcolor: '#e0e0e0', gridcolor: '#e0e0e0',
dtick: 1, // Major gridlines at powers of 10 dtick: 1, // Major gridlines at powers of 10
tickformat: '.0e', // Scientific notation tickformat: '.0e', // Scientific notation
range: [Math.log10(yMin), Math.log10(maxTotalLiquidity * 10)] range: [Math.log10(yMin), Math.log10(yMax)]
}, },
showlegend: true, showlegend: true,
legend: { legend: {
@ -783,7 +836,7 @@
plot_bgcolor: 'white', plot_bgcolor: 'white',
paper_bgcolor: 'white', paper_bgcolor: 'white',
margin: { l: 60, r: 60, t: 60, b: 50 }, margin: { l: 60, r: 60, t: 60, b: 50 },
barmode: 'group' hovermode: 'closest'
}; };
Plotly.newPlot(chartDiv, data, layout, {responsive: true}); Plotly.newPlot(chartDiv, data, layout, {responsive: true});
@ -974,7 +1027,7 @@
}); });
} }
function createSummaryPanel(positions, currentTick) { function createSummaryPanel(positions, currentTick, token0isWeth) {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'summary-panel'; panel.className = 'summary-panel';
@ -1010,13 +1063,15 @@
// Calculate position-specific liquidity percentage // Calculate position-specific liquidity percentage
const liquidityPercent = totalUniV3Liquidity > 0 ? (pos.liquidity / totalUniV3Liquidity * 100).toFixed(1) : '0.0'; const liquidityPercent = totalUniV3Liquidity > 0 ? (pos.liquidity / totalUniV3Liquidity * 100).toFixed(1) : '0.0';
const tickRange = pos.tickUpper - pos.tickLower; const tickRange = pos.tickUpper - pos.tickLower;
const lowerMultiple = tickToPriceMultiple(pos.tickLower, currentTick, token0isWeth);
const upperMultiple = tickToPriceMultiple(pos.tickUpper, currentTick, token0isWeth);
item.innerHTML = ` item.innerHTML = `
<strong>${pos.name} Position</strong><br> <strong>${pos.name} Position</strong><br>
ETH: ${pos.eth.toLocaleString(undefined, {minimumFractionDigits: 6, maximumFractionDigits: 6})}<br> ETH: ${pos.eth.toLocaleString(undefined, {minimumFractionDigits: 6, maximumFractionDigits: 6})}<br>
KRAIKEN: ${pos.kraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br> KRAIKEN: ${pos.kraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>
Uniswap V3 Liquidity: ${pos.liquidity.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} (${liquidityPercent}%)<br> Uniswap V3 Liquidity: ${pos.liquidity.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} (${liquidityPercent}%)<br>
Range: ${tickRange} ticks Range: ${lowerMultiple.toFixed(3)}x - ${upperMultiple.toFixed(3)}x
`; `;
grid.appendChild(item); grid.appendChild(item);
}); });