harb/onchain/analysis/scenario-visualizer.html
2025-08-17 15:09:41 +02:00

1181 lines
No EOL
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kraiken Liquidity Position Simulator</title>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
}
textarea {
width: 100%;
box-sizing: border-box;
height: 100px;
}
button {
margin-top: 10px;
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.scenario-container {
margin-bottom: 40px;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.scenario-header {
margin: 0 0 20px 0;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
border-left: 4px solid #007bff;
}
.charts-container {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.chart-wrapper {
flex: 1;
min-width: 0;
}
.chart-title {
text-align: center;
margin-bottom: 10px;
font-weight: bold;
color: #333;
}
.chart-div {
width: 100%;
height: 500px;
border: 1px solid #ddd;
border-radius: 4px;
}
.summary-panel {
background-color: #f8f9fa;
border-radius: 4px;
padding: 15px;
margin-top: 20px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 10px;
}
.summary-item {
background: white;
padding: 10px;
border-radius: 4px;
border-left: 4px solid #007bff;
}
.summary-item.floor {
border-left-color: #1f77b4;
}
.summary-item.anchor {
border-left-color: #ff7f0e;
}
.summary-item.discovery {
border-left-color: #2ca02c;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 20px;
font-size: 14px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
.legend-color.floor {
background-color: #1f77b4;
}
.legend-color.anchor {
background-color: #ff7f0e;
}
.legend-color.discovery {
background-color: #2ca02c;
}
@media (max-width: 768px) {
.charts-container {
flex-direction: column;
}
}
</style>
</head>
<body>
<h2>Kraiken Liquidity Position Simulator</h2>
<div style="background-color: #e3f2fd; border-radius: 4px; padding: 15px; margin-bottom: 20px; border-left: 4px solid #2196f3;">
<strong>📊 Anti-Arbitrage Three-Position Strategy (Uniswap V3 1% Pool)</strong><br>
<br>
<strong>Position Strategy:</strong><br>
<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>
&lt; 1x = ETH is cheaper than current price (positions below current hold ETH)<br>
• 1x = Current ETH price (red dashed line)<br>
&gt; 1x = ETH is more expensive than current price (positions above current hold KRAIKEN)<br>
<br>
<em>Note: The x-axis automatically adjusts based on token ordering in the pool</em><br>
<br>
<strong>Navigation:</strong> Use the Previous/Next buttons or URL parameter <code>?row=N</code> to view specific CSV rows
</div>
<div id="status">Loading profitable scenario data...</div>
<textarea id="csvInput" placeholder="Paste CSV formatted data here..." style="display: none;"></textarea>
<button onclick="parseAndSimulateCSV()" style="display: none;">Simulate CSV Data</button>
<div id="simulations"></div>
<script>
// Position color scheme
const POSITION_COLORS = {
floor: '#1f77b4', // Dark Blue - Foundation/Stability
anchor: '#ff7f0e', // Orange - Current Price/Center
discovery: '#2ca02c' // Green - Growth/Expansion
};
// Position names for display
const POSITION_NAMES = {
floor: 'Floor',
anchor: 'Anchor',
discovery: 'Discovery'
};
// Token ordering configuration - set this based on your deployment
// If ethIsToken0 = true: ETH is token0, KRAIKEN is token1
// If ethIsToken0 = false: KRAIKEN is token0, ETH is token1
// Default matches test setup: DEFAULT_TOKEN0_IS_WETH = false
const ethIsToken0 = false;
// Position Economic Model:
// - Floor Position: Deep liquidity - holds KRAIKEN above price, ETH below price
// - Anchor Position: Mixed tokens around current price for shallow liquidity
// - Discovery Position: Edge liquidity - holds ETH above price, KRAIKEN below price
// Get row parameter from URL
function getRowParameter() {
const urlParams = new URLSearchParams(window.location.search);
const row = urlParams.get('row');
return row ? parseInt(row) - 2 : 0; // Convert CSV line number to array index
}
// Auto-load CSV data on page load
document.addEventListener('DOMContentLoaded', function() {
loadCSVData();
});
function loadCSVData() {
const statusDiv = document.getElementById('status');
statusDiv.textContent = 'Loading profitable scenario data...';
// Try to load the CSV file generated by the analysis script
fetch('./profitable_scenario.csv')
.then(response => {
if (!response.ok) {
throw new Error('CSV file not found');
}
return response.text();
})
.then(csvText => {
statusDiv.textContent = 'Profitable scenario data loaded successfully!';
statusDiv.style.color = 'green';
const data = parseCSV(csvText);
simulateCSVData(data);
})
.catch(error => {
statusDiv.innerHTML = `
<div style="color: orange;">
<strong>Cannot load CSV automatically due to browser security restrictions.</strong><br>
<br>
<strong>Solution 1:</strong> Run a local server:<br>
<code>cd analysis && python3 -m http.server 8000</code><br>
Then open: <a href="http://localhost:8000/scenario-visualizer.html">http://localhost:8000/scenario-visualizer.html</a><br>
<br>
<strong>Solution 2:</strong> Use manual input mode below<br>
<br>
<em>If no CSV exists, run: forge script analysis/SimpleAnalysis.s.sol --ffi</em>
</div>
`;
// CSV load error - handled by status message above
});
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
const headers = lines[0].split(',').map(h => h.trim());
const data = lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim());
const entry = {};
headers.forEach((header, index) => {
entry[header] = values[index];
});
return entry;
});
return data;
}
function parseAndSimulateCSV() {
const csvInput = document.getElementById('csvInput').value;
const data = parseCSV(csvInput);
simulateCSVData(data);
// Clear input field after processing
document.getElementById('csvInput').value = '';
}
function simulateCSVData(data) {
// Get selected row from URL parameter
const selectedIndex = getRowParameter();
// Add current row info and navigation
const currentRowInfo = document.createElement('div');
currentRowInfo.style.cssText = 'margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 4px; text-align: center;';
currentRowInfo.innerHTML = `
<strong>Currently viewing: Line ${selectedIndex + 2} - ${data[selectedIndex].precedingAction}</strong><br>
<span style="color: #666; font-size: 14px;">Total rows: ${data.length} (Lines 2-${data.length + 1})</span>
`;
document.getElementById('simulations').appendChild(currentRowInfo);
// Add navigation buttons above if not first row
if (selectedIndex > 0) {
const topNavDiv = document.createElement('div');
topNavDiv.style.cssText = 'margin: 20px 0; text-align: center;';
topNavDiv.innerHTML = `
<button onclick="navigateRow(-1)" style="padding: 10px 20px; font-size: 16px;">
← Previous Row (Line ${selectedIndex + 1})
</button>
`;
document.getElementById('simulations').appendChild(topNavDiv);
}
// Process only the selected row
let previousRow = selectedIndex > 0 ? data[selectedIndex - 1] : null;
if (selectedIndex >= 0 && selectedIndex < data.length) {
const row = data[selectedIndex];
const index = selectedIndex;
const precedingAction = row.precedingAction;
const currentTick = parseFloat(row.currentTick);
const token0isWeth = row.token0isWeth === 'true' || row.token0isWeth === true;
const floorTickLower = parseFloat(row.floorTickLower);
const floorTickUpper = parseFloat(row.floorTickUpper);
const floorLiquidity = parseFloat(row.floorLiquidity || 0);
const anchorTickLower = parseFloat(row.anchorTickLower);
const anchorTickUpper = parseFloat(row.anchorTickUpper);
const anchorLiquidity = parseFloat(row.anchorLiquidity || 0);
const discoveryTickLower = parseFloat(row.discoveryTickLower);
const discoveryTickUpper = parseFloat(row.discoveryTickUpper);
const discoveryLiquidity = parseFloat(row.discoveryLiquidity || 0);
// Calculate token amounts from liquidity
const floorAmounts = getAmountsForLiquidity(floorLiquidity, floorTickLower, floorTickUpper, currentTick);
const anchorAmounts = getAmountsForLiquidity(anchorLiquidity, anchorTickLower, anchorTickUpper, currentTick);
const discoveryAmounts = getAmountsForLiquidity(discoveryLiquidity, discoveryTickLower, discoveryTickUpper, currentTick);
// FIXED calculation - properly determine ETH amounts based on token ordering
let floorEthAmount, anchorEthAmount, discoveryEthAmount;
let floorKraikenAmount, anchorKraikenAmount, discoveryKraikenAmount;
// Simply use the amounts from getAmountsForLiquidity and determine which is ETH
if (token0isWeth) {
// token0 is WETH, token1 is KRAIKEN
floorEthAmount = floorAmounts.amount0 / 1e18;
floorKraikenAmount = floorAmounts.amount1 / 1e18;
anchorEthAmount = anchorAmounts.amount0 / 1e18;
anchorKraikenAmount = anchorAmounts.amount1 / 1e18;
discoveryEthAmount = discoveryAmounts.amount0 / 1e18;
discoveryKraikenAmount = discoveryAmounts.amount1 / 1e18;
} else {
// token0 is KRAIKEN, token1 is WETH
floorEthAmount = floorAmounts.amount1 / 1e18;
floorKraikenAmount = floorAmounts.amount0 / 1e18;
anchorEthAmount = anchorAmounts.amount1 / 1e18;
anchorKraikenAmount = anchorAmounts.amount0 / 1e18;
discoveryEthAmount = discoveryAmounts.amount1 / 1e18;
discoveryKraikenAmount = discoveryAmounts.amount0 / 1e18;
}
const totalEth = floorEthAmount + anchorEthAmount + discoveryEthAmount;
// Use the already calculated ETH and KRAIKEN amounts from above
let floorEth = floorEthAmount;
let floorKraiken = floorKraikenAmount;
let anchorEth = anchorEthAmount;
let anchorKraiken = anchorKraikenAmount;
let discoveryEth = discoveryEthAmount;
let discoveryKraiken = discoveryKraikenAmount;
let actionAmount = '';
let additionalInfo = '';
if (previousRow) {
// Calculate previous token amounts from liquidity
const prevCurrentTick = parseFloat(previousRow.currentTick);
const prevFloorLiquidity = parseFloat(previousRow.floorLiquidity || 0);
const prevAnchorLiquidity = parseFloat(previousRow.anchorLiquidity || 0);
const prevDiscoveryLiquidity = parseFloat(previousRow.discoveryLiquidity || 0);
const prevFloorAmounts = getAmountsForLiquidity(prevFloorLiquidity, floorTickLower, floorTickUpper, prevCurrentTick);
const prevAnchorAmounts = getAmountsForLiquidity(prevAnchorLiquidity, anchorTickLower, anchorTickUpper, prevCurrentTick);
const prevDiscoveryAmounts = getAmountsForLiquidity(prevDiscoveryLiquidity, discoveryTickLower, discoveryTickUpper, prevCurrentTick);
let prevFloorEth, prevFloorKraiken, prevAnchorEth, prevAnchorKraiken, prevDiscoveryEth, prevDiscoveryKraiken;
if (token0isWeth === true) {
prevFloorEth = prevFloorAmounts.amount0 / 1e18;
prevFloorKraiken = prevFloorAmounts.amount1 / 1e18;
prevAnchorEth = prevAnchorAmounts.amount0 / 1e18;
prevAnchorKraiken = prevAnchorAmounts.amount1 / 1e18;
prevDiscoveryEth = prevDiscoveryAmounts.amount0 / 1e18;
prevDiscoveryKraiken = prevDiscoveryAmounts.amount1 / 1e18;
} else {
prevFloorEth = prevFloorAmounts.amount1 / 1e18;
prevFloorKraiken = prevFloorAmounts.amount0 / 1e18;
prevAnchorEth = prevAnchorAmounts.amount1 / 1e18;
prevAnchorKraiken = prevAnchorAmounts.amount0 / 1e18;
prevDiscoveryEth = prevDiscoveryAmounts.amount1 / 1e18;
prevDiscoveryKraiken = prevDiscoveryAmounts.amount0 / 1e18;
}
const ethDifference = (floorEth + anchorEth + discoveryEth) - (prevFloorEth + prevAnchorEth + prevDiscoveryEth);
const kraikenDifference = (floorKraiken + anchorKraiken + discoveryKraiken) - (prevFloorKraiken + prevAnchorKraiken + prevDiscoveryKraiken);
if (precedingAction.toLowerCase().includes('buy')) {
actionAmount = `${precedingAction} ETH`;
additionalInfo = `(${Math.abs(kraikenDifference).toFixed(6)} KRAIKEN bought)`;
} else if (precedingAction.toLowerCase().includes('sell')) {
actionAmount = `${precedingAction} KRAIKEN`;
additionalInfo = `(${Math.abs(ethDifference).toFixed(6)} ETH bought)`;
} else {
actionAmount = precedingAction;
}
}
// Calculate CSV line number (index + 2 to account for header line)
const lineNumber = index + 2;
const headline = `Line ${lineNumber}: ${precedingAction} ${additionalInfo}`;
simulateEnhanced(headline, currentTick,
floorTickLower, floorTickUpper, floorEth, floorKraiken, floorLiquidity,
anchorTickLower, anchorTickUpper, anchorEth, anchorKraiken, anchorLiquidity,
discoveryTickLower, discoveryTickUpper, discoveryEth, discoveryKraiken, discoveryLiquidity, token0isWeth, index, precedingAction);
// Add navigation buttons below
const bottomNavDiv = document.createElement('div');
bottomNavDiv.style.cssText = 'margin: 20px 0; text-align: center;';
const prevButton = selectedIndex > 0 ?
`<button onclick="navigateRow(-1)" style="padding: 10px 20px; font-size: 16px; margin-right: 20px;">
← Previous Row (Line ${selectedIndex + 1})
</button>` : '';
const nextButton = selectedIndex < data.length - 1 ?
`<button onclick="navigateRow(1)" style="padding: 10px 20px; font-size: 16px;">
Next Row (Line ${selectedIndex + 3}) →
</button>` : '';
bottomNavDiv.innerHTML = prevButton + nextButton;
document.getElementById('simulations').appendChild(bottomNavDiv);
}
}
// Function to navigate between rows
function navigateRow(direction) {
const currentIndex = getRowParameter();
const newLineNumber = currentIndex + direction + 2; // Convert back to CSV line number
const url = new URL(window.location);
url.searchParams.set('row', newLineNumber);
window.location = url;
}
// Uniswap V3 liquidity calculation functions
function tickToPrice(tick) {
return Math.pow(1.0001, tick);
}
function tickToSqrtPriceX96(tick) {
return Math.sqrt(Math.pow(1.0001, tick)) * (2 ** 96);
}
// Calculate token amounts from liquidity for a position
function getAmountsForLiquidity(liquidity, tickLower, tickUpper, currentTick) {
// Sort ticks to ensure tickLower < tickUpper
if (tickLower > tickUpper) {
[tickLower, tickUpper] = [tickUpper, tickLower];
}
const sqrtRatioAX96 = tickToSqrtPriceX96(tickLower);
const sqrtRatioBX96 = tickToSqrtPriceX96(tickUpper);
const sqrtRatioX96 = tickToSqrtPriceX96(currentTick);
let amount0 = 0;
let amount1 = 0;
const Q96 = 2 ** 96;
if (currentTick < tickLower) {
// Current price is below the range, position holds only token0
// When position was created above current price, ETH was used as amount1
// Use the amount1 formula to get the ETH that was deposited
amount0 = liquidity * (sqrtRatioBX96 - sqrtRatioAX96) / Q96;
} else if (currentTick >= tickUpper) {
// Current price is above the range, position holds only token1
// amount1 = liquidity * (sqrtRatioBX96 - sqrtRatioAX96) / Q96
amount1 = liquidity * (sqrtRatioBX96 - sqrtRatioAX96) / Q96;
} else {
// Current price is within the range
// amount0 = liquidity * (sqrtRatioBX96 - sqrtRatioX96) / (sqrtRatioX96 * sqrtRatioBX96) * Q96
amount0 = liquidity * Q96 * (sqrtRatioBX96 - sqrtRatioX96) / sqrtRatioBX96 / sqrtRatioX96;
// amount1 = liquidity * (sqrtRatioX96 - sqrtRatioAX96) / Q96
amount1 = liquidity * (sqrtRatioX96 - sqrtRatioAX96) / Q96;
}
return { amount0, amount1 };
}
function priceToSqrtPrice(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
// This represents the actual liquidity deployed to the position, independent of current price
function calculateInvariantLiquidity(token0Amount, token1Amount, tickLower, tickUpper, positionName = '') {
// Add safeguards for extreme tick values
if (tickLower > 180000 || tickUpper > 180000) {
console.warn(`${positionName} has extremely high ticks: [${tickLower}, ${tickUpper}]. This may cause precision issues.`);
}
const priceLower = tickToPrice(tickLower);
const priceUpper = tickToPrice(tickUpper);
const sqrtPriceLower = priceToSqrtPrice(priceLower);
const sqrtPriceUpper = priceToSqrtPrice(priceUpper);
// Handle edge cases where denominators would be zero
if (sqrtPriceUpper === sqrtPriceLower) {
return 0; // Invalid range
}
let liquidity = 0;
let calculatedFrom = '';
// If we have token0, calculate liquidity from it
if (token0Amount > 0) {
liquidity = token0Amount * (sqrtPriceUpper * sqrtPriceLower) / (sqrtPriceUpper - sqrtPriceLower);
calculatedFrom = 'token0';
}
// If we have token1, calculate liquidity from it
else if (token1Amount > 0) {
liquidity = token1Amount / (sqrtPriceUpper - sqrtPriceLower);
calculatedFrom = 'token1';
}
// Debug logging
return liquidity;
}
function calculateUniV3Liquidity(token0Amount, token1Amount, tickLower, tickUpper, currentTick) {
const priceLower = tickToPrice(tickLower);
const priceUpper = tickToPrice(tickUpper);
const priceCurrent = tickToPrice(currentTick);
const sqrtPriceLower = priceToSqrtPrice(priceLower);
const sqrtPriceUpper = priceToSqrtPrice(priceUpper);
const sqrtPriceCurrent = priceToSqrtPrice(priceCurrent);
// Handle edge cases where denominators would be zero
if (sqrtPriceUpper === sqrtPriceLower) {
return 0; // Invalid range
}
if (priceCurrent <= priceLower) {
// Price below range - position holds only token0 (ETH)
// Calculate liquidity from ETH amount using the full price range
if (token0Amount > 0) {
return token0Amount * (sqrtPriceUpper * sqrtPriceLower) / (sqrtPriceUpper - sqrtPriceLower);
}
return 0;
} else if (priceCurrent >= priceUpper) {
// Price above range - position holds only token1 (KRAIKEN)
// Calculate liquidity from KRAIKEN amount using the full price range
if (token1Amount > 0) {
return token1Amount / (sqrtPriceUpper - sqrtPriceLower);
}
return 0;
} else {
// Price within range - calculate from both tokens and take minimum
let liquidityFromToken0 = 0;
let liquidityFromToken1 = 0;
if (token0Amount > 0 && sqrtPriceUpper > sqrtPriceCurrent) {
liquidityFromToken0 = token0Amount * (sqrtPriceUpper * sqrtPriceCurrent) / (sqrtPriceUpper - sqrtPriceCurrent);
}
if (token1Amount > 0 && sqrtPriceCurrent > sqrtPriceLower) {
liquidityFromToken1 = token1Amount / (sqrtPriceCurrent - sqrtPriceLower);
}
// Return the non-zero value, or minimum if both are present
if (liquidityFromToken0 > 0 && liquidityFromToken1 > 0) {
return Math.min(liquidityFromToken0, liquidityFromToken1);
}
return Math.max(liquidityFromToken0, liquidityFromToken1);
}
}
function simulateEnhanced(precedingAction, currentTick,
floorTickLower, floorTickUpper, floorEth, floorKraiken, floorLiquidity,
anchorTickLower, anchorTickUpper, anchorEth, anchorKraiken, anchorLiquidity,
discoveryTickLower, discoveryTickUpper, discoveryEth, discoveryKraiken, discoveryLiquidity, token0isWeth, index, originalAction) {
// Position data structure with liquidity calculations
const positions = {
floor: {
tickLower: floorTickLower,
tickUpper: floorTickUpper,
eth: floorEth,
kraiken: floorKraiken,
name: 'Floor',
liquidity: floorLiquidity
},
anchor: {
tickLower: anchorTickLower,
tickUpper: anchorTickUpper,
eth: anchorEth,
kraiken: anchorKraiken,
name: 'Anchor (Shallow Pool)',
liquidity: anchorLiquidity
},
discovery: {
tickLower: discoveryTickLower,
tickUpper: discoveryTickUpper,
eth: discoveryEth,
kraiken: discoveryKraiken,
name: 'Discovery',
liquidity: discoveryLiquidity
}
};
// Calculate total active liquidity
const totalLiquidity = Object.values(positions).reduce((sum, pos) => sum + pos.liquidity, 0);
// Create container for this scenario
const scenarioContainer = document.createElement('div');
scenarioContainer.className = 'scenario-container';
// Create header
const header = document.createElement('div');
header.className = 'scenario-header';
header.innerHTML = `<h3>${precedingAction}</h3>`;
scenarioContainer.appendChild(header);
// Create charts container
const chartsContainer = document.createElement('div');
chartsContainer.className = 'charts-container';
// Create combined chart
const chartWrapper = document.createElement('div');
chartWrapper.className = 'chart-wrapper';
chartWrapper.style.width = '100%'; // Full width for single chart
const chartTitle = document.createElement('div');
chartTitle.className = 'chart-title';
chartTitle.textContent = 'Total Liquidity Distribution (L × Tick Range)';
const combinedChart = document.createElement('div');
combinedChart.className = 'chart-div';
combinedChart.id = `combined-chart-${Date.now()}-${Math.random()}`;
chartWrapper.appendChild(chartTitle);
chartWrapper.appendChild(combinedChart);
chartsContainer.appendChild(chartWrapper);
scenarioContainer.appendChild(chartsContainer);
// Create summary panel
const summaryPanel = createSummaryPanel(positions, currentTick, token0isWeth, originalAction || precedingAction, index);
scenarioContainer.appendChild(summaryPanel);
// Add to page
document.getElementById('simulations').appendChild(scenarioContainer);
// Create the combined chart
createCombinedChart(combinedChart, positions, currentTick, totalLiquidity, token0isWeth);
}
function createCombinedChart(chartDiv, positions, currentTick, totalLiquidity, token0isWeth) {
const positionKeys = ['floor', 'anchor', 'discovery'];
// Convert positions to price multiples
const priceMultiplePositions = {};
positionKeys.forEach(key => {
const pos = positions[key];
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
};
});
// 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 price multiple ranges)
const barPositions = positionKeys.map(key => {
const pos = priceMultiplePositions[key];
return pos.centerMultiple;
});
// ETH trace (left y-axis)
const ethTrace = {
x: barPositions,
y: positionKeys.map(key => {
const value = positions[key].eth;
// Add minimum height for zero values to make them visible
return value === 0 ? 0.01 : value;
}),
width: barWidths,
type: 'bar',
name: 'ETH',
yaxis: 'y',
marker: {
color: positionKeys.map(key => POSITION_COLORS[key]),
opacity: 0.7,
line: {
color: 'white',
width: 2
}
},
text: positionKeys.map(key => {
const pos = priceMultiplePositions[key];
return `${pos.name}<br>ETH: ${pos.eth.toFixed(6)}<br>Range: ${pos.lowerMultiple.toFixed(3)}x - ${pos.upperMultiple.toFixed(3)}x`;
}),
hoverinfo: 'text'
};
// KRAIKEN trace (right y-axis)
const kraikenTrace = {
x: barPositions, // Same position as ETH bars
y: positionKeys.map(key => {
const value = positions[key].kraiken;
// Add minimum height for zero values to make them visible
return value === 0 ? 0.01 : value;
}),
width: barWidths,
type: 'bar',
name: 'KRAIKEN',
yaxis: 'y2',
marker: {
color: positionKeys.map(key => POSITION_COLORS[key]),
opacity: 0.4,
pattern: {
shape: '/',
size: 8
},
line: {
color: 'white',
width: 2
}
},
text: positionKeys.map(key => {
const pos = priceMultiplePositions[key];
return `${pos.name}<br>KRAIKEN: ${pos.kraiken.toFixed(6)}<br>Range: ${pos.lowerMultiple.toFixed(3)}x - ${pos.upperMultiple.toFixed(3)}x`;
}),
hoverinfo: 'text'
};
// Calculate x-axis range based on position ranges with some padding
// Cap extreme values to keep chart readable
const MAX_REASONABLE_MULTIPLE = 50; // Cap at 50x for readability
const MIN_REASONABLE_MULTIPLE = 0.02; // Cap at 0.02x for readability
const allMultiples = positionKeys.flatMap(key => [priceMultiplePositions[key].lowerMultiple, priceMultiplePositions[key].upperMultiple]);
const cappedMultiples = allMultiples.map(m => Math.min(MAX_REASONABLE_MULTIPLE, Math.max(MIN_REASONABLE_MULTIPLE, m)));
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
// Calculate max values for proper y-axis alignment
const maxEth = Math.max(...positionKeys.map(key => positions[key].eth));
const maxKraiken = Math.max(...positionKeys.map(key => positions[key].kraiken));
const showPriceLine = true; // Always show price line at 1x
// Create liquidity × ticks traces for each position using shape/filled area approach
const liquidityTraces = [];
const shapes = [];
positionKeys.forEach((key, index) => {
const pos = priceMultiplePositions[key];
const tickRange = pos.tickUpper - pos.tickLower;
const totalLiquidity = pos.liquidity * tickRange;
// Skip positions with zero liquidity
if (pos.liquidity === 0 || totalLiquidity === 0) {
console.warn(`Skipping ${key} position: zero liquidity`);
return;
}
// 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;
// Ensure positions are visible even at extreme values
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));
}
// Add indicator if position extends beyond visible range
let rangeText = `Range: ${pos.lowerMultiple.toFixed(3)}x - ${pos.upperMultiple.toFixed(3)}x`;
let extendsBeyond = false;
if (pos.lowerMultiple < xAxisMin || pos.upperMultiple > xAxisMax) {
rangeText += ' <b>(extends beyond chart)</b>';
extendsBeyond = true;
}
// For extremely high or low multiples, show in scientific notation
if (pos.upperMultiple > 100 || pos.lowerMultiple < 0.01) {
const lowerStr = pos.lowerMultiple < 0.01 ? pos.lowerMultiple.toExponential(2) : pos.lowerMultiple.toFixed(3) + 'x';
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'
},
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',
showlegend: true
};
liquidityTraces.push(trace);
});
const data = liquidityTraces;
// Calculate max and min total liquidity (L × ticks) for y-axis scaling
const totalLiquidities = positionKeys.map(key => {
const pos = priceMultiplePositions[key];
const tickRange = pos.tickUpper - pos.tickLower;
return pos.liquidity * tickRange;
}).filter(l => l > 0); // Only consider non-zero liquidities
const maxTotalLiquidity = totalLiquidities.length > 0 ? Math.max(...totalLiquidities) : 1;
const minTotalLiquidity = totalLiquidities.length > 0 ? Math.min(...totalLiquidities) : 0.1;
// Ensure minimum is at least 1e-10 for log scale
const yMin = Math.max(1e-10, minTotalLiquidity / 100);
const yMax = maxTotalLiquidity * 10;
if (showPriceLine) {
const priceLineTrace = {
x: [1, 1], // Current price is always at 1x
y: [yMin, yMax], // Use calculated y range
mode: 'lines',
line: {
color: 'red',
width: 3,
dash: 'dash'
},
name: 'Current Price (1x)',
hoverinfo: 'name',
showlegend: true
};
data.push(priceLineTrace);
}
const layout = {
title: {
text: `Total Liquidity Distribution (L × Tick Range)`,
font: { size: 16 }
},
xaxis: {
title: 'Price Multiple (relative to current price)',
showgrid: true,
gridcolor: '#e0e0e0',
range: [Math.log10(xAxisMin), Math.log10(xAxisMax)],
tickformat: '.2f',
ticksuffix: 'x',
type: 'log' // Use log scale for better visualization of price multiples
},
yaxis: {
title: 'Total Liquidity (L × Ticks)',
type: 'log',
showgrid: true,
gridcolor: '#e0e0e0',
dtick: 1, // Major gridlines at powers of 10
tickformat: '.0e', // Scientific notation
range: [Math.log10(yMin), Math.log10(yMax)]
},
showlegend: true,
legend: {
x: 0.02,
y: 0.98,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#ccc',
borderwidth: 1
},
plot_bgcolor: 'white',
paper_bgcolor: 'white',
margin: { l: 60, r: 60, t: 60, b: 50 },
hovermode: 'closest'
};
Plotly.newPlot(chartDiv, data, layout, {responsive: true});
}
function createDualCharts(ethChartDiv, kraikenChartDiv, positions, currentTick, totalLiquidity) {
const positionKeys = ['floor', 'anchor', 'discovery'];
// Calculate bar widths proportional to actual Uniswap V3 liquidity
const baseWidth = 50; // Base width for tick ranges
const barWidths = positionKeys.map(key => {
const pos = positions[key];
const liquidityRatio = totalLiquidity > 0 ? pos.liquidity / totalLiquidity : 0;
return Math.max(baseWidth * 0.3, baseWidth * liquidityRatio * 3); // Scale for visibility
});
// Calculate bar positions (centered in tick ranges)
const barPositions = positionKeys.map(key => {
const pos = positions[key];
return pos.tickLower + (pos.tickUpper - pos.tickLower) / 2;
});
// ETH Chart Data
const ethData = [{
x: barPositions,
y: positionKeys.map(key => positions[key].eth),
width: barWidths,
type: 'bar',
marker: {
color: positionKeys.map(key => POSITION_COLORS[key]),
opacity: 0.8,
line: {
color: 'white',
width: 2
}
},
text: positionKeys.map(key => {
const pos = positions[key];
let tooltip = `${pos.name} Position<br>`;
// Show token amounts and actual Uniswap V3 liquidity
tooltip += `ETH: ${pos.eth.toFixed(6)}<br>KRAIKEN: ${pos.kraiken.toFixed(6)}`;
tooltip += `<br>Uniswap V3 Liquidity: ${pos.liquidity.toFixed(2)}`;
tooltip += `<br>Range: [${pos.tickLower}, ${pos.tickUpper}]`;
return tooltip;
}),
hoverinfo: 'text',
name: 'ETH Liquidity'
}];
// Kraiken Chart Data
const kraikenData = [{
x: barPositions,
y: positionKeys.map(key => positions[key].kraiken),
width: barWidths,
type: 'bar',
marker: {
color: positionKeys.map(key => POSITION_COLORS[key]),
opacity: 0.8,
line: {
color: 'white',
width: 2
}
},
text: positionKeys.map(key => {
const pos = positions[key];
let tooltip = `${pos.name} Position<br>`;
// Show token amounts and actual Uniswap V3 liquidity
tooltip += `ETH: ${pos.eth.toFixed(6)}<br>KRAIKEN: ${pos.kraiken.toFixed(6)}`;
tooltip += `<br>Uniswap V3 Liquidity: ${pos.liquidity.toFixed(2)}`;
tooltip += `<br>Range: [${pos.tickLower}, ${pos.tickUpper}]`;
return tooltip;
}),
hoverinfo: 'text',
name: 'KRAIKEN Liquidity'
}];
// Add current price line to both charts
const maxEth = Math.max(...positionKeys.map(key => positions[key].eth));
const maxKraiken = Math.max(...positionKeys.map(key => positions[key].kraiken));
const priceLineEth = {
x: [currentTick, currentTick],
y: [0, maxEth * 1.1],
mode: 'lines',
line: {
color: 'red',
width: 3,
dash: 'dash'
},
name: 'Current Price',
hoverinfo: 'x',
text: [`Current Price: ${currentTick}`]
};
const priceLineKraiken = {
x: [currentTick, currentTick],
y: [0, maxKraiken * 1.1],
mode: 'lines',
line: {
color: 'red',
width: 3,
dash: 'dash'
},
name: 'Current Price',
hoverinfo: 'x',
text: [`Current Price: ${currentTick}`]
};
ethData.push(priceLineEth);
kraikenData.push(priceLineKraiken);
// Create synchronized layouts
const ethLayout = {
title: {
text: 'ETH Liquidity by Position',
font: { size: 16 }
},
xaxis: {
title: 'Price Ticks',
showgrid: true,
gridcolor: '#e0e0e0'
},
yaxis: {
title: 'ETH Amount',
showgrid: true,
gridcolor: '#e0e0e0'
},
showlegend: false,
plot_bgcolor: 'white',
paper_bgcolor: 'white',
margin: { l: 60, r: 30, t: 60, b: 50 }
};
const kraikenLayout = {
title: {
text: 'KRAIKEN Liquidity by Position',
font: { size: 16 }
},
xaxis: {
title: 'Price Ticks',
showgrid: true,
gridcolor: '#e0e0e0'
},
yaxis: {
title: 'KRAIKEN Amount',
showgrid: true,
gridcolor: '#e0e0e0'
},
showlegend: false,
plot_bgcolor: 'white',
paper_bgcolor: 'white',
margin: { l: 60, r: 30, t: 60, b: 50 }
};
// Plot both charts
Plotly.newPlot(ethChartDiv, ethData, ethLayout, {responsive: true});
Plotly.newPlot(kraikenChartDiv, kraikenData, kraikenLayout, {responsive: true});
// Add synchronized interactions
synchronizeCharts(ethChartDiv, kraikenChartDiv);
}
function synchronizeCharts(chart1, chart2) {
// Synchronize hover events
chart1.on('plotly_hover', function(data) {
if (data.points && data.points[0] && data.points[0].pointNumber !== undefined) {
const pointIndex = data.points[0].pointNumber;
Plotly.Fx.hover(chart2, [{curveNumber: 0, pointNumber: pointIndex}]);
}
});
chart2.on('plotly_hover', function(data) {
if (data.points && data.points[0] && data.points[0].pointNumber !== undefined) {
const pointIndex = data.points[0].pointNumber;
Plotly.Fx.hover(chart1, [{curveNumber: 0, pointNumber: pointIndex}]);
}
});
// Synchronize unhover events
chart1.on('plotly_unhover', function() {
Plotly.Fx.unhover(chart2);
});
chart2.on('plotly_unhover', function() {
Plotly.Fx.unhover(chart1);
});
}
function createSummaryPanel(positions, currentTick, token0isWeth, precedingAction, index) {
const panel = document.createElement('div');
panel.className = 'summary-panel';
const title = document.createElement('h4');
title.textContent = 'Position Summary';
title.style.margin = '0 0 15px 0';
panel.appendChild(title);
const grid = document.createElement('div');
grid.className = 'summary-grid';
// Calculate totals
const totalEth = Object.values(positions).reduce((sum, pos) => sum + pos.eth, 0);
const totalKraiken = Object.values(positions).reduce((sum, pos) => sum + pos.kraiken, 0);
const totalUniV3Liquidity = Object.values(positions).reduce((sum, pos) => sum + pos.liquidity, 0);
// Add total summary
const totalItem = document.createElement('div');
totalItem.className = 'summary-item';
const totalHtml = `
<strong>Total Portfolio</strong><br>
ETH: ${totalEth.toLocaleString(undefined, {minimumFractionDigits: 6, maximumFractionDigits: 6})}<br>
KRAIKEN: ${totalKraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>
Uniswap V3 Liquidity: ${totalUniV3Liquidity.toExponential(2)}
`;
totalItem.innerHTML = totalHtml;
grid.appendChild(totalItem);
// Add position summaries
Object.entries(positions).forEach(([key, pos]) => {
const item = document.createElement('div');
item.className = `summary-item ${key}`;
// Calculate position-specific liquidity percentage
const liquidityPercent = totalUniV3Liquidity > 0 ? (pos.liquidity / totalUniV3Liquidity * 100).toFixed(1) : '0.0';
const tickRange = pos.tickUpper - pos.tickLower;
const lowerMultiple = tickToPriceMultiple(pos.tickLower, currentTick, token0isWeth);
const upperMultiple = tickToPriceMultiple(pos.tickUpper, currentTick, token0isWeth);
item.innerHTML = `
<strong>${pos.name} Position</strong><br>
ETH: ${pos.eth.toLocaleString(undefined, {minimumFractionDigits: 6, maximumFractionDigits: 6})}<br>
KRAIKEN: ${pos.kraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}<br>
Liquidity: ${pos.liquidity.toExponential(2)} (${liquidityPercent}%)<br>
Ticks: [${pos.tickLower.toLocaleString()}, ${pos.tickUpper.toLocaleString()}]
`;
grid.appendChild(item);
});
// Add current price info
const priceItem = document.createElement('div');
priceItem.className = 'summary-item';
// Calculate current price
const currentPrice = tickToPrice(currentTick);
let ethPriceInKraiken, kraikenPriceInEth;
if (token0isWeth) {
// price = KRAIKEN/ETH
ethPriceInKraiken = currentPrice;
kraikenPriceInEth = 1 / currentPrice;
} else {
// price = ETH/KRAIKEN
kraikenPriceInEth = currentPrice;
ethPriceInKraiken = 1 / currentPrice;
}
priceItem.innerHTML = `
<strong>Current Price</strong><br>
Tick: ${currentTick.toLocaleString()}<br>
1 ETH = ${ethPriceInKraiken.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} KRAIKEN<br>
1 KRAIKEN = ${kraikenPriceInEth.toExponential(3)} ETH
`;
grid.appendChild(priceItem);
panel.appendChild(grid);
return panel;
}
</script>
</body>
</html>