diff --git a/common/calculate-swap3.ts b/common/calculate-swap3.ts new file mode 100644 index 00000000..4e648d75 --- /dev/null +++ b/common/calculate-swap3.ts @@ -0,0 +1,260 @@ +type Swap3LiquidityPosition = { + // TODO: Record who added this stuff? + + // Not sure if this is needed; maybe YES and NO left + // amount: number // M$ quantity + + // For now, only support YES and NO outcome tokens + // TODO: replace with Outcome + // Hm, is this... + // 1. Number of shares left in this particular pool? + // 2. Fixed at injection time? + pool: { YES: number; NO: number } + + // Uniswap uses 0.01, 0.003, 0.0005. Let's stick with 0.003 for now. + // fee: number + + // Min/max is expressed as a odds ratio of cost of YES to cost of NO + // E.g. ratio of 1 = 1:1 = 50%; ratio of 3 = 3:1 = 75% + // minRatio: number + // maxRatio: number + minTick: number + // minTick = loq_sqrt_1.0001(sqrtRatio) + // sqrt(1.0001)^(minTick) = sqrtRatio + // minRatio = 1.0001^minTick + // e.g. minTick = 20k => 7.3883 + maxTick: number +} + +type TickState = { + tick: number + + // Amount of liquidity added when crossing this tick from left to right + // Negative if we should remove liquidity + liquidityNet: number + + // Total liquidity referencing this pool + liquidityGross: number +} + +// From https://uniswap.org/whitepaper-v3.pdf +export type Swap3Pool = { + // id: string + // userId: string + // contractId: string + // createdTime: number + + // 6.2 Global State + liquidity: number // = sqrt(NY) + // sqrtRatio: number // = sqrt(N / Y); N = # NO shares in pool + // So N = liquidity * sqrtRatio; Y = liquidity / sqrtRatio + + // Current tick number. + // Stored as optimization. equal to floor(log_sqrt_1.0001(sqrtRatio)) + tick: number + // TODO add fees? + + // Mapping of tick indices to tick values. + tickStates: TickState[] +} + +export function noShares(pool: Swap3Pool) { + return pool.liquidity * toRatio(pool.tick) ** 0.5 +} + +export function yesShares(pool: Swap3Pool) { + return pool.liquidity / toRatio(pool.tick) ** 0.5 +} + +export function getSwap3Probability(pool: Swap3Pool) { + // Probability is given by N / (N + Y) + // const N = pool.liquidity * pool.sqrtRatio + // const Y = pool.liquidity / pool.sqrtRatio + // return N / (N + Y) + + // To check: this should be equal to toProb(pool.tick)? + return toProb(pool.tick) +} + +function calculatePurchase( + pool: Swap3Pool, + amount: number, // In M$ + outcome: 'YES' | 'NO' +) {} + +export function calculateLPCost( + curTick: number, + minTick: number, + maxTick: number, + deltaL: number +) { + // TODO: this is subtly wrong, because of rounding between curTick and sqrtPrice + // Also below in buyYES + const upperTick = Math.min(maxTick, Math.max(minTick, curTick)) + const costN = toRatio(upperTick) ** 0.5 - toRatio(minTick) ** 0.5 + + const lowerTick = Math.max(minTick, Math.min(maxTick, curTick)) + const costY = 1 / toRatio(lowerTick) ** 0.5 - 1 / toRatio(maxTick) ** 0.5 + + return { + requiredN: deltaL * costN, + requiredY: deltaL * costY, + } +} + +// Returns a preview of the new pool + number of YES shares purchased. +// Does NOT modify the pool +// Hm, logic is pretty complicated. Let's see if we can simplify this. +export function buyYes( + pool: Swap3Pool, + amount: number // In M$ +) { + const tickStates = sortedTickStates(pool) + let tick = pool.tick + let stateIndex = 0 + let amountLeft = amount + let yesPurchased = 0 + // Stop if there's epsilon M$ left, due to rounding issues + while (amountLeft > 1e-6) { + // Find the current & next states for this tick + while (tick >= tickStates[stateIndex + 1].tick) { + stateIndex++ + if (stateIndex > tickStates.length - 2) { + // We've reached the end of the tick states... + throw new Error('Ran out of tick states') + } + } + const state = tickStates[stateIndex] + const nextState = tickStates[stateIndex + 1] + + // nextState.tick purchases through the bucket; fullTick uses the remaining amountLeft + const fullCostN = amountLeft / state.liquidityGross + // Note: fullTick is NOT floored here; it's for the sqrtPrice to buy up to + const fullTick = fromRatioUnfloored((fullCostN + toRatio(tick) ** 0.5) ** 2) + const nextTick = Math.min(nextState.tick, fullTick) + + // Copied from above; TODO extract to common function? + const noCost = toRatio(nextTick) ** 0.5 - toRatio(tick) ** 0.5 + const yesCost = 1 / toRatio(tick) ** 0.5 - 1 / toRatio(nextTick) ** 0.5 + + amountLeft -= noCost * state.liquidityGross + yesPurchased += yesCost * state.liquidityGross + tick = Math.floor(nextTick) + } + + // Right now we eat the epsilon amounntLeft as a fee. Could return it, shrug. + return { + newPoolTick: tick, + yesPurchased, + } +} + +// Currently, this mutates the pool. Should it return a new object instead? +export function addPosition( + pool: Swap3Pool, + minTick: number, + maxTick: number, + deltaL: number +) { + const { requiredN, requiredY } = calculateLPCost( + pool.tick, + minTick, + maxTick, + deltaL + ) + // console.log(`Deducting required N: ${requiredN} and required Y: ${requiredY}`) + + // Add liquidity as we pass through the smaller tick + const minTickState = pool.tickStates[minTick] || { + tick: minTick, + liquidityNet: 0, + liquidityGross: 0, + } + + minTickState.liquidityNet += deltaL + pool.tickStates[minTick] = minTickState + + // And remove it as we pass through the larger one + const maxTickState = pool.tickStates[maxTick] || { + tick: maxTick, + liquidityNet: 0, + liquidityGross: 0, + } + + maxTickState.liquidityNet -= deltaL + pool.tickStates[maxTick] = maxTickState + + return pool +} + +export function addBalancer(pool: Swap3Pool, p: number, deltaL: number) { + // TODO: math is borked, shouldn't be returning infinity + function tickL(tick: number) { + const q = 1 - p + return deltaL * 2 * p ** q * q ** p * 1.0001 ** ((p - 0.5) * tick) + } + + // See how much liquidity is provided at +/- 5pp around p + // const minTick = fromProb(p - 0.1) + // const maxTick = fromProb(p + 0.05) + const minTick = fromProb(0.000001) + const maxTick = fromProb(0.999999) + let totalN = 0 + let totalY = 0 + const stride = 300 + for (let t = minTick; t <= maxTick; t += stride) { + // console.log('liquidity at tick ', t, toProb(t), tickL(t)) + const { requiredN, requiredY } = calculateLPCost( + fromProb(p), + t, + t + stride, + tickL(t) + ) + totalN += requiredN + totalY += requiredY + // Add liquidity + addPosition(pool, t, t + stride, tickL(t)) + } + + console.log('rough number of ticks', (maxTick - minTick) / stride) + console.log(`Total N: ${totalN} and total Y: ${totalY}`) + grossLiquidity(pool) + return pool +} + +// This also mutates the pool directly +export function grossLiquidity(pool: Swap3Pool) { + let liquidityGross = 0 + for (const tickState of sortedTickStates(pool)) { + liquidityGross += tickState.liquidityNet + tickState.liquidityGross = liquidityGross + } + return pool +} + +export function sortedTickStates(pool: Swap3Pool) { + return Object.values(pool.tickStates).sort((a, b) => a.tick - b.tick) +} + +function toRatio(tick: number) { + return 1.0001 ** tick +} + +export function toProb(tick: number) { + const ratio = toRatio(tick) + return ratio / (ratio + 1) +} + +// Returns the tick for a given probability from 0 to 1 +export function fromProb(prob: number) { + const ratio = prob / (1 - prob) + return fromRatio(ratio) +} + +function fromRatio(ratio: number) { + return Math.floor(Math.log(ratio) / Math.log(1.0001)) +} + +function fromRatioUnfloored(ratio: number) { + return Math.log(ratio) / Math.log(1.0001) +} diff --git a/web/components/contract/liquidity-graph.tsx b/web/components/contract/liquidity-graph.tsx new file mode 100644 index 00000000..38f422ad --- /dev/null +++ b/web/components/contract/liquidity-graph.tsx @@ -0,0 +1,121 @@ +import { CartesianMarkerProps, DatumValue } from '@nivo/core' +import { Point, ResponsiveLine } from '@nivo/line' +import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' +import { memo } from 'react' +import { range } from 'lodash' +import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm' +import { NumericContract } from '../../../common/contract' +import { useWindowSize } from '../../hooks/use-window-size' +import { Col } from '../layout/col' +import { formatLargeNumber, formatPercent } from 'common/util/format' + +export type GraphPoint = { + // A probability between 0 and 1 + x: number + // Amount of liquidity + y: number +} + +export const LiquidityGraph = memo(function NumericGraph(props: { + min?: 0 + max?: 1 + points: GraphPoint[] + height?: number + marker?: number // Value between min and max to highlight on x-axis + previewMarker?: number +}) { + const { height, min, max, points, marker, previewMarker } = props + + // Really maxLiquidity + const maxLiquidity = 500 + const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }] + + const yTickValues = [ + 0, + 0.25 * maxLiquidity, + 0.5 * maxLiquidity, + 0.75 * maxLiquidity, + maxLiquidity, + ] + + const { width } = useWindowSize() + + const numXTickValues = !width || width < 800 ? 2 : 5 + + const markers: CartesianMarkerProps<DatumValue>[] = [] + if (marker) { + markers.push({ + axis: 'x', + value: marker, + lineStyle: { stroke: '#000', strokeWidth: 2 }, + legend: `Implied: ${formatPercent(marker)}`, + }) + } + if (previewMarker) { + markers.push({ + axis: 'x', + value: previewMarker, + lineStyle: { stroke: '#8888', strokeWidth: 2 }, + }) + } + + return ( + <div + className="w-full overflow-hidden" + style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }} + > + <ResponsiveLine + data={data} + yScale={{ min: 0, max: maxLiquidity, type: 'linear' }} + yFormat={formatLiquidity} + axisLeft={{ + tickValues: yTickValues, + format: formatLiquidity, + }} + xScale={{ + type: 'linear', + min: min, + max: max, + }} + xFormat={(d) => `${formatLargeNumber(+d, 3)}`} + axisBottom={{ + tickValues: numXTickValues, + format: (d) => `${formatLargeNumber(+d, 3)}`, + }} + colors={{ datum: 'color' }} + pointSize={0} + enableSlices="x" + sliceTooltip={({ slice }) => { + const point = slice.points[0] + return <Tooltip point={point} /> + }} + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 50 }} + markers={markers} + /> + </div> + ) +}) + +function formatLiquidity(y: DatumValue) { + const p = Math.round(+y * 100) / 100 + return `${p}L` +} + +function Tooltip(props: { point: Point }) { + const { point } = props + return ( + <Col className="border border-gray-300 bg-white py-2 px-3"> + <div + className="pb-1" + style={{ + color: point.serieColor, + }} + > + <strong>{point.serieId}</strong> {point.data.yFormatted} + </div> + <div>{formatLargeNumber(+point.data.x)}</div> + </Col> + ) +} diff --git a/web/pages/swap.tsx b/web/pages/swap.tsx new file mode 100644 index 00000000..e0158f67 --- /dev/null +++ b/web/pages/swap.tsx @@ -0,0 +1,269 @@ +import { + addBalancer, + addPosition, + buyYes, + calculateLPCost, + fromProb, + getSwap3Probability, + grossLiquidity, + noShares, + sortedTickStates, + Swap3Pool, + toProb, + yesShares, +} from 'common/calculate-swap3' +import { formatPercent } from 'common/util/format' +import { useState } from 'react' +import { LiquidityGraph } from 'web/components/contract/liquidity-graph' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { addLiquidity } from 'web/lib/firebase/fn-call' + +const users: Record<string, any> = { + alice: { + M: 100, + YES: 0, + NO: 0, + }, + bob: { + M: 200, + YES: 0, + NO: 0, + }, + kipply: { + M: 300, + YES: 0, + NO: 0, + }, +} + +function BalanceTable() { + /* Display all users current M, YES, and NO in a table */ + return ( + <table className="w-full"> + <thead> + <tr> + <th className="px-4 py-2">User</th> + <th className="px-4 py-2">M</th> + <th className="px-4 py-2">YES</th> + <th className="px-4 py-2">NO</th> + </tr> + </thead> + <tbody> + {Object.keys(users).map((user) => ( + <tr key={user}> + <td className="px-4 py-2">{user}</td> + <td className="px-4 py-2">{users[user].M}</td> + <td className="px-4 py-2">{users[user].YES}</td> + <td className="px-4 py-2">{users[user].NO}</td> + </tr> + ))} + </tbody> + </table> + ) +} + +/* Show the values in pool */ +function PoolTable(props: { pool: Swap3Pool }) { + const { pool } = props + return ( + <> + <Row className="gap-4"> + <div> + <label>Implied: </label> + {formatPercent(getSwap3Probability(pool))} + </div> + + <div> + <label>Liquidity: </label> + {pool.liquidity} + </div> + <div> + <label>Tick: </label> + {pool.tick} + </div> + <div> + <label>Pool YES: </label> + {yesShares(pool).toFixed(2)} + </div> + <div> + <label>Pool NO: </label> + {noShares(pool).toFixed(2)} + </div> + </Row> + {/* Render each tickState as another row in a table */} + <table className="w-full"> + <thead> + <tr> + <th className="px-4 py-2">Tick</th> + <th className="px-4 py-2">Prob</th> + <th className="px-4 py-2">Net Liquidity</th> + <th className="px-4 py-2">Gross Liquidity</th> + </tr> + </thead> + {sortedTickStates(pool).map((tickState, i) => ( + <tr key={i}> + <td className="px-4 py-2">{tickState.tick}</td> + <td className="px-4 py-2"> + {formatPercent(toProb(tickState.tick))} + </td> + <td className="px-4 py-2">{tickState.liquidityNet}</td> + <td className="px-4 py-2">{tickState.liquidityGross}</td> + </tr> + ))} + </table> + </> + ) +} + +function Graph(props: { pool: Swap3Pool; previewMarker?: number }) { + const { pool, previewMarker } = props + const points = [] + let lastGross = 0 + for (const tickState of sortedTickStates(pool)) { + const { tick, liquidityGross } = tickState + points.push({ x: toProb(tick), y: lastGross }) + points.push({ x: toProb(tick), y: liquidityGross }) + lastGross = liquidityGross + } + return ( + <LiquidityGraph + points={points} + marker={toProb(pool.tick)} + previewMarker={previewMarker} + /> + ) +} + +function LiquidityPanel(props: { + pool: Swap3Pool + setPool: (pool: Swap3Pool) => void +}) { + const { pool, setPool } = props + const [minTick, setMinTick] = useState(0) + const [maxTick, setMaxTick] = useState(0) + const [deltaL, setDeltaL] = useState(100) + + const { requiredN, requiredY } = calculateLPCost( + pool.tick, + minTick, + maxTick, + deltaL + ) + + return ( + <Col> + <h2 className="my-2 text-xl">Add liquidity</h2> + {/* <input className="input" placeholder="Amount" type="number" /> */} + <Row className="gap-2"> + <input + className="input" + placeholder="Min%" + type="number" + onChange={(e) => setMinTick(inputPercentToTick(e))} + /> + {/* Min Tick: {minTick} */} + <input + className="input" + placeholder="Max%" + type="number" + onChange={(e) => setMaxTick(inputPercentToTick(e))} + /> + {/* Max Tick: {maxTick} */} + <input + className="input" + placeholder="delta Liquidity" + type="number" + value={deltaL} + onChange={(e) => setDeltaL(parseFloat(e.target.value))} + /> + </Row> + <Row className="gap-2 py-2"> + <div>Y required: {requiredY.toFixed(2)}</div> + <div>N required: {requiredN.toFixed(2)}</div>{' '} + </Row> + <button + className="btn" + onClick={() => { + addPosition(pool, minTick, maxTick, deltaL) + grossLiquidity(pool) + setPool({ ...pool }) + }} + > + Create pool + </button> + </Col> + ) +} + +export default function Swap() { + // Set up an initial pool with 100 liquidity from 0% to 100% + // TODO: Not sure why maxTick of 2**23 breaks it, but 2**20 is okay... + let INIT_POOL: Swap3Pool = { + liquidity: 0, + tick: fromProb(0.3), + tickStates: [], + } + INIT_POOL = addPosition(INIT_POOL, -(2 ** 23), 2 ** 20, 1) + // INIT_POOL = addPosition(INIT_POOL, fromProb(0.32), fromProb(0.35), 100) + INIT_POOL = addBalancer(INIT_POOL, 0.3, 100) + INIT_POOL = grossLiquidity(INIT_POOL) + + const [pool, setPool] = useState(INIT_POOL) + const [buyAmount, setBuyAmount] = useState(0) + + const { newPoolTick, yesPurchased } = buyYes(pool, buyAmount) + + return ( + <Col className="mx-auto max-w-2xl gap-10 p-4"> + {/* <BalanceTable /> */} + {/* <PoolTable pool={pool} /> */} + <Graph + pool={pool} + previewMarker={ + newPoolTick === pool.tick ? undefined : toProb(newPoolTick) + } + /> + <input + className="input" + placeholder="Current%" + type="number" + onChange={(e) => { + pool.tick = inputPercentToTick(e) + setPool({ ...pool }) + }} + /> + + <LiquidityPanel pool={pool} setPool={setPool} /> + + <Col> + <h2 className="my-2 text-xl">Limit Order</h2> + TODO + </Col> + + <Col> + <h2 className="my-2 text-xl">Buy Shares</h2> + {/* <input className="input" placeholder="User" type="text" /> */} + <input + className="input" + placeholder="Amount" + type="number" + onChange={(e) => setBuyAmount(parseFloat(e.target.value))} + /> + <Row className="gap-2 py-2"> + <div>Y shares purchaseable: {yesPurchased.toFixed(2)}</div> + <div>New Tick: {newPoolTick}</div> + <div>New prob: {formatPercent(toProb(newPoolTick))}</div> + </Row> + <Row className="gap-2"> + <button className="btn">Buy YES</button> + {/* <button className="btn">Buy NO</button> */} + </Row> + </Col> + </Col> + ) +} + +function inputPercentToTick(event: React.ChangeEvent<HTMLInputElement>) { + return fromProb(parseFloat(event.target.value) / 100) +}