diff --git a/app/globals.css b/app/globals.css index 3891937..a6bf521 100644 --- a/app/globals.css +++ b/app/globals.css @@ -154,6 +154,38 @@ h3 { gap: 7px; } +.challenge-nav { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 3px; + background: linear-gradient(180deg, var(--panel-strong) 0%, var(--panel-soft) 100%); +} + +.challenge-link { + border: 1px solid transparent; + border-radius: 7px; + color: var(--ink-soft); + font-family: 'Space Mono', 'Courier New', monospace; + font-size: 0.69rem; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; + padding: 5px 8px; +} + +.challenge-link:hover { + color: var(--accent); +} + +.challenge-link.active { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent-soft) 52%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + .terminal-link, .theme-toggle { border: 1px solid var(--line); @@ -948,6 +980,20 @@ h3 { background: linear-gradient(180deg, var(--chart-bg-top) 0%, var(--chart-bg-bottom) 100%); } +html[data-theme='dark'] .prop-amm-page .chart-wrap { + padding: 10px; + border: 1px solid rgba(76, 96, 126, 0.52); + border-radius: 10px; + background: linear-gradient(180deg, rgba(18, 29, 44, 0.8) 0%, rgba(10, 16, 28, 0.86) 100%); +} + +html[data-theme='dark'] .prop-amm-page #curveChart { + border-radius: 8px; + background: + radial-gradient(circle at 30% 18%, rgba(42, 62, 89, 0.22) 0%, transparent 50%), + linear-gradient(180deg, #101b2c 0%, #0a131f 100%); +} + .market-bottom { flex: 0 0 auto; display: grid; @@ -1338,6 +1384,16 @@ h3 { gap: 6px; } + .challenge-nav { + grid-column: 1 / -1; + width: 100%; + } + + .challenge-link { + flex: 1 1 0; + text-align: center; + } + .terminal-link { justify-content: center; width: 100%; diff --git a/app/prop-amm/page.tsx b/app/prop-amm/page.tsx new file mode 100644 index 0000000..91848d7 --- /dev/null +++ b/app/prop-amm/page.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { HeaderActions } from '../../components/HeaderActions' +import { PropCodePanel } from '../../components/PropCodePanel' +import { PropMarketPanel } from '../../components/PropMarketPanel' +import { usePropSimulationWorker } from '../../hooks/usePropSimulationWorker' +import { useUiStore } from '../../store/useUiStore' +import type { PropStrategyRef, PropWorkerUiState } from '../../lib/prop-sim/types' +import { PROP_BUILTIN_STRATEGIES, getPropBuiltinStrategyById } from '../../lib/prop-strategies/builtins' + +function buildFallbackUiState(strategyRef: PropStrategyRef, playbackSpeed: number, maxTapeRows: number): PropWorkerUiState { + const builtin = getPropBuiltinStrategyById(strategyRef.id) ?? PROP_BUILTIN_STRATEGIES[0] + + const snapshot: PropWorkerUiState['snapshot'] = { + step: 0, + fairPrice: 100, + strategy: { + x: 100, + y: 10_000, + k: 1_000_000, + impliedBidBps: builtin.feeBps, + impliedAskBps: builtin.feeBps, + }, + normalizer: { + x: 100, + y: 10_000, + k: 1_000_000, + feeBps: 30, + liquidityMult: 1.0, + }, + edge: { total: 0, retail: 0, arb: 0 }, + simulationParams: { volatility: 0.003, arrivalRate: 0.8 }, + } + + return { + config: { + seed: 1337, + strategyRef: { kind: 'builtin', id: builtin.id }, + playbackSpeed, + maxTapeRows, + }, + currentStrategy: { + kind: 'builtin', + id: builtin.id, + name: builtin.name, + code: builtin.code, + feeBps: builtin.feeBps, + }, + isPlaying: false, + tradeCount: 0, + snapshot, + lastEvent: { + id: 0, + step: 0, + flow: 'system', + ammName: builtin.name, + isStrategyTrade: false, + trade: null, + order: null, + arbProfit: 0, + fairPrice: 100, + priceMove: { from: 100, to: 100 }, + edgeDelta: 0, + codeLines: [], + codeExplanation: 'Initializing simulation...', + stateBadge: `implied: ${builtin.feeBps}/${builtin.feeBps} bps`, + summary: 'Simulation worker is initializing.', + snapshot, + }, + history: [], + reserveTrail: [{ x: 100, y: 10_000 }], + viewWindow: null, + availableStrategies: PROP_BUILTIN_STRATEGIES.map((s) => ({ + kind: 'builtin' as const, + id: s.id, + name: s.name, + })), + normalizerConfig: { feeBps: 30, liquidityMult: 1.0 }, + } +} + +export default function PropAmmPage() { + const theme = useUiStore((state) => state.theme) + const playbackSpeed = useUiStore((state) => state.playbackSpeed) + const maxTapeRows = useUiStore((state) => state.maxTapeRows) + const showCodeExplanation = useUiStore((state) => state.showCodeExplanation) + const chartAutoZoom = useUiStore((state) => state.chartAutoZoom) + + const setTheme = useUiStore((state) => state.setTheme) + const setPlaybackSpeed = useUiStore((state) => state.setPlaybackSpeed) + const setShowCodeExplanation = useUiStore((state) => state.setShowCodeExplanation) + const setChartAutoZoom = useUiStore((state) => state.setChartAutoZoom) + + // Local strategy ref state for prop-amm + const [propStrategyRef, setPropStrategyRef] = useState({ + kind: 'builtin', + id: 'starter-500bps', + }) + + const { + ready, + workerState, + workerError, + controls, + } = usePropSimulationWorker({ + seed: 1337, + playbackSpeed, + maxTapeRows, + strategyRef: propStrategyRef, + }) + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme) + }, [theme]) + + const fallbackState = useMemo( + () => buildFallbackUiState(propStrategyRef, playbackSpeed, maxTapeRows), + [maxTapeRows, playbackSpeed, propStrategyRef], + ) + const effectiveState = workerState ?? fallbackState + const simulationLoading = !ready || !workerState + + return ( + <> +
+
+ setTheme(theme === 'dark' ? 'light' : 'dark')} + subtitle="Prop AMM Challenge" + subtitleLink="https://ammchallenge.com/prop-amm" + /> + + {workerError ?
Worker error: {workerError}
: null} + +
+ { + setPropStrategyRef(next) + controls.setStrategy(next) + }} + onToggleExplanationOverlay={() => setShowCodeExplanation(!showCodeExplanation)} + /> + + setChartAutoZoom(!chartAutoZoom)} + onPlayPause={() => { + if (!workerState) return + if (workerState.isPlaying) { + controls.pause() + return + } + controls.play() + }} + onStep={() => { + if (!workerState) return + controls.step() + }} + onReset={() => { + if (!workerState) return + controls.reset() + }} + /> +
+ +
+ + ) +} diff --git a/components/AmmChart.tsx b/components/AmmChart.tsx index da448ea..fce413b 100644 --- a/components/AmmChart.tsx +++ b/components/AmmChart.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useId, useMemo, useState } from 'react' -import { CHART_THEME } from '../lib/sim/constants' +import { CHART_THEME, PROP_CHART_THEME } from '../lib/sim/constants' import { buildArrowPath, buildCurvePath, @@ -20,10 +20,15 @@ interface AmmChartProps { viewWindow: { xMin: number; xMax: number; yMin: number; yMax: number } | null autoZoom: boolean chartSize?: { width: number; height: number } + variant?: 'classic' | 'prop' } -export function AmmChart({ snapshot, reserveTrail, lastEvent, theme, viewWindow, autoZoom, chartSize }: AmmChartProps) { - const palette = CHART_THEME[theme] +export function AmmChart({ snapshot, reserveTrail, lastEvent, theme, viewWindow, autoZoom, chartSize, variant = 'classic' }: AmmChartProps) { + const palette = variant === 'prop' + ? theme === 'dark' + ? PROP_CHART_THEME.dark + : CHART_THEME.light + : CHART_THEME[theme] const uid = useId().replace(/:/g, '') const clipId = `plotClip-${uid}` const markerId = `arrowHead-${uid}` @@ -114,6 +119,25 @@ export function AmmChart({ snapshot, reserveTrail, lastEvent, theme, viewWindow, } }, [autoZoom, baseView.liveWindow, baseView.targetX, baseView.targetY, frozenWindow, geometry.height, geometry.margin.bottom, geometry.margin.left, geometry.margin.right, geometry.margin.top, geometry.width, lastEvent, reserveTrail, snapshot]) + const titleLabel = variant === 'prop' ? 'compute_swap()' : 'x · y = k' + const subtitleLabel = variant === 'prop' ? 'output curve' : 'Δy / Δx' + const xAxisLabel = variant === 'prop' ? 'Input' : 'Reserve X' + const yAxisLabel = variant === 'prop' ? 'Output' : 'Reserve Y' + const strategyLabel = variant === 'prop' ? 'strategy curve' : 'strategy' + const normalizerLabel = variant === 'prop' ? 'normalizer ref' : 'normalizer' + const trailLabel = + variant === 'prop' + ? autoZoom + ? 'tracked reserves (auto)' + : 'tracked reserves (fixed)' + : autoZoom + ? 'recent trail (auto-zoom)' + : 'recent trail (fixed view)' + const titleFont = variant === 'prop' ? 'Space Mono' : 'Cormorant Garamond' + const titleStyle = variant === 'prop' ? 'normal' : 'italic' + const axisFont = variant === 'prop' ? 'IBM Plex Sans' : 'Cormorant Garamond' + const labelFont = variant === 'prop' ? 'Space Mono' : 'Space Mono' + return ( - - x · y = k + + {titleLabel} - - Δy / Δx + + {subtitleLabel} - - Reserve X + + {xAxisLabel} - Reserve Y + {yAxisLabel} - - strategy + + {strategyLabel} - - normalizer + + {normalizerLabel} - - {autoZoom ? 'recent trail (auto-zoom)' : 'recent trail (fixed view)'} + + {trailLabel} ) diff --git a/components/FooterLinks.tsx b/components/FooterLinks.tsx index 936890a..5be8919 100644 --- a/components/FooterLinks.tsx +++ b/components/FooterLinks.tsx @@ -11,7 +11,7 @@ export function FooterLinks() { aria-label="X profile for devrelius" > diff --git a/components/HeaderActions.tsx b/components/HeaderActions.tsx index 5bfb80d..dcb36ca 100644 --- a/components/HeaderActions.tsx +++ b/components/HeaderActions.tsx @@ -1,10 +1,15 @@ 'use client' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + import type { ThemeMode } from '../lib/sim/types' interface HeaderActionsProps { theme: ThemeMode onToggleTheme: () => void + subtitle?: string + subtitleLink?: string } function XIcon() { @@ -23,22 +28,35 @@ function GitHubIcon() { ) } -export function HeaderActions({ theme, onToggleTheme }: HeaderActionsProps) { +export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink }: HeaderActionsProps) { + const pathname = usePathname() + const isPropPage = pathname?.startsWith('/prop-amm') ?? false const toggleLabel = theme === 'dark' ? 'Light Theme' : 'Dark Theme' + const title = subtitle ? `AMM Strategy Visualizer — ${subtitle}` : 'AMM Strategy Visualizer' + const linkHref = subtitleLink ?? 'https://ammchallenge.com' + const linkText = subtitle ? subtitle.toLowerCase() : 'ammchallenge.com' return (
-

AMM Strategy Visualizer

+

{title}

Step-by-step Automated Market Maker (AMM) strategy visualizer. Learn more at{' '} - - ammchallenge.com + + {linkText}

+ devrelius diff --git a/components/PropCodePanel.tsx b/components/PropCodePanel.tsx new file mode 100644 index 0000000..129f401 --- /dev/null +++ b/components/PropCodePanel.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useEffect, useMemo, useRef } from 'react' +import Prism from 'prismjs' +import 'prismjs/components/prism-rust' +import type { PropStrategyRef } from '../lib/prop-sim/types' + +interface PropCodePanelProps { + availableStrategies: Array<{ kind: 'builtin'; id: string; name: string }> + selectedStrategy: PropStrategyRef + code: string + highlightedLines: number[] + codeExplanation: string + showExplanationOverlay: boolean + onSelectStrategy: (ref: PropStrategyRef) => void + onToggleExplanationOverlay: () => void +} + +export function PropCodePanel({ + availableStrategies, + selectedStrategy, + code, + highlightedLines, + codeExplanation, + showExplanationOverlay, + onSelectStrategy, + onToggleExplanationOverlay, +}: PropCodePanelProps) { + const containerRef = useRef(null) + const lineSet = useMemo(() => new Set(highlightedLines), [highlightedLines]) + const firstHighlightedLine = highlightedLines[0] ?? null + const lines = useMemo(() => code.replace(/\t/g, ' ').split('\n'), [code]) + const highlightedRustLines = useMemo( + () => lines.map((line) => Prism.highlight(line || ' ', Prism.languages.rust, 'rust')), + [lines], + ) + + useEffect(() => { + if (!containerRef.current || firstHighlightedLine === null) return + + const target = containerRef.current.querySelector(`.code-line[data-line='${firstHighlightedLine}']`) + if (target) { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + }, [firstHighlightedLine]) + + return ( +
+
+
+
+

Strategy

+
+ +
+ +
+
+
+ +
+ {lines.map((line, index) => { + const lineNumber = index + 1 + const active = lineSet.has(lineNumber) + + return ( +
+ {String(lineNumber).padStart(2, '0')} + +
+ ) + })} +
+ +
+ + +
+
+ ) +} diff --git a/components/PropMarketPanel.tsx b/components/PropMarketPanel.tsx new file mode 100644 index 0000000..d215245 --- /dev/null +++ b/components/PropMarketPanel.tsx @@ -0,0 +1,419 @@ +'use client' + +import { useLayoutEffect, useRef, useState } from 'react' +import { PROP_SPEED_PROFILE } from '../lib/prop-sim/constants' +import { buildPropDepthStats, fromScaledBigInt, propAmmSpot, toScaledBigInt } from '../lib/prop-sim/math' +import type { PropTradeEvent, PropWorkerUiState } from '../lib/prop-sim/types' +import type { ThemeMode } from '../lib/sim/types' +import { AmmChart } from './AmmChart' + +interface PropMarketPanelProps { + state: PropWorkerUiState + theme: ThemeMode + playbackSpeed: number + autoZoom: boolean + isInitializing?: boolean + onPlaybackSpeedChange: (value: number) => void + onToggleAutoZoom: () => void + onPlayPause: () => void + onStep: () => void + onReset: () => void +} + +function formatNum(value: number, decimals: number): string { + return value.toFixed(decimals) +} + +function formatSigned(value: number, decimals: number = 2): string { + const sign = value >= 0 ? '+' : '' + return `${sign}${value.toFixed(decimals)}` +} + +export function PropMarketPanel({ + state, + theme, + playbackSpeed, + autoZoom, + isInitializing = false, + onPlaybackSpeedChange, + onToggleAutoZoom, + onPlayPause, + onStep, + onReset, +}: PropMarketPanelProps) { + const snapshot = state.snapshot + const strategySpot = snapshot.strategy.y / snapshot.strategy.x + const chartHostRef = useRef(null) + const [chartSize, setChartSize] = useState({ width: 760, height: 320 }) + + useLayoutEffect(() => { + const host = chartHostRef.current + if (!host || typeof ResizeObserver === 'undefined') return + + const measure = () => { + const rect = host.getBoundingClientRect() + const width = Math.max(320, Math.round(rect.width)) + const height = Math.max(220, Math.round(rect.height)) + setChartSize((prev) => { + if (prev.width === width && prev.height === height) return prev + return { width, height } + }) + } + + const observer = new ResizeObserver((entries) => { + const next = entries[0]?.contentRect + if (!next) return + + const width = Math.max(320, Math.round(next.width)) + const height = Math.max(220, Math.round(next.height)) + + setChartSize((prev) => { + if (prev.width === width && prev.height === height) return prev + return { width, height } + }) + }) + + measure() + observer.observe(host) + return () => observer.disconnect() + }, []) + + // Create quote function for depth stats + const strategyQuoteFn = (side: 0 | 1, input: number): number => { + const feeBps = state.currentStrategy.feeBps + const gamma = 1 - feeBps / 10000 + const k = snapshot.strategy.k + + if (side === 0) { + // Buy X: input Y + const netY = input * gamma + const newY = snapshot.strategy.y + netY + return Math.max(0, snapshot.strategy.x - k / newY) + } else { + // Sell X: input X + const netX = input * gamma + const newX = snapshot.strategy.x + netX + return Math.max(0, snapshot.strategy.y - k / newX) + } + } + + const normalizerQuoteFn = (side: 0 | 1, input: number): number => { + const feeBps = snapshot.normalizer.feeBps + const gamma = 1 - feeBps / 10000 + const k = snapshot.normalizer.k + + if (side === 0) { + const netY = input * gamma + const newY = snapshot.normalizer.y + netY + return Math.max(0, snapshot.normalizer.x - k / newY) + } else { + const netX = input * gamma + const newX = snapshot.normalizer.x + netX + return Math.max(0, snapshot.normalizer.y - k / newX) + } + } + + const strategyDepth = buildPropDepthStats( + { name: 'Strategy', reserveX: snapshot.strategy.x, reserveY: snapshot.strategy.y, isStrategy: true }, + strategyQuoteFn, + ) + + const normalizerDepth = buildPropDepthStats( + { name: 'Normalizer', reserveX: snapshot.normalizer.x, reserveY: snapshot.normalizer.y, isStrategy: false }, + normalizerQuoteFn, + ) + + const maxBuy5 = Math.max(strategyDepth.buyDepth5, normalizerDepth.buyDepth5, 1e-9) + const maxSell5 = Math.max(strategyDepth.sellDepth5, normalizerDepth.sellDepth5, 1e-9) + + // Adapt snapshot for AmmChart (expects original format) + const chartSnapshot = { + step: snapshot.step, + fairPrice: snapshot.fairPrice, + strategy: { + x: snapshot.strategy.x, + y: snapshot.strategy.y, + bid: snapshot.strategy.impliedBidBps, + ask: snapshot.strategy.impliedAskBps, + k: snapshot.strategy.k, + }, + normalizer: { + x: snapshot.normalizer.x, + y: snapshot.normalizer.y, + bid: snapshot.normalizer.feeBps, + ask: snapshot.normalizer.feeBps, + k: snapshot.normalizer.k, + }, + edge: snapshot.edge, + } + + return ( +
+
+

Simulated Market (Prop AMM)

+ + Step {snapshot.step} | Trade {state.tradeCount} + {isInitializing ? ' | Loading' : ''} + +
+ +
+
+
+
+ + + +
+ +
+ + + +
+
+ +
+
+ [0]['lastEvent']} + theme={theme} + viewWindow={state.viewWindow} + autoZoom={autoZoom} + chartSize={chartSize} + variant="prop" + /> +
+
+ +
+
+
+
+ Fair Price + {formatNum(snapshot.fairPrice, 4)} Y/X +
+
+ Strategy Spot + {formatNum(strategySpot, 4)} Y/X +
+
+ Implied Fees + + ~{formatNum(snapshot.strategy.impliedBidBps, 0)}/{formatNum(snapshot.strategy.impliedAskBps, 0)} bps + +
+
+ Normalizer + + {snapshot.normalizer.feeBps} bps @ {snapshot.normalizer.liquidityMult.toFixed(2)}x liq + +
+
+ Volatility + {(snapshot.simulationParams.volatility * 100).toFixed(3)}%/step +
+
+ Cumulative Edge + + {formatSigned(snapshot.edge.total)} (retail {formatSigned(snapshot.edge.retail)}, arb {formatSigned(snapshot.edge.arb)}) + +
+
+
+ +
+
+

Per-Pool Depth

+ + to 1% and 5% price impact + +
+ +
+ + +
+
+
+
+ + +
+
+ ) +} + +function ControlIcon({ kind }: { kind: 'play' | 'pause' | 'step' | 'reset' }) { + if (kind === 'pause') { + return ( + + ) + } + + if (kind === 'step') { + return ( + + ) + } + + if (kind === 'reset') { + return ( + + ) + } + + return ( + + ) +} + +function DepthCard({ + poolLabel, + poolClass, + feeLabel, + stats, + buyMax, + sellMax, +}: { + poolLabel: string + poolClass: string + feeLabel: string + stats: { buyDepth1: number; buyDepth5: number; sellDepth1: number; sellDepth5: number; buyOneXCostY: number; sellOneXPayoutY: number } + buyMax: number + sellMax: number +}) { + const buyWidth = Math.max(3, Math.min(100, (stats.buyDepth5 / buyMax) * 100)) + const sellWidth = Math.max(3, Math.min(100, (stats.sellDepth5 / sellMax) * 100)) + + return ( +
+
+

{poolLabel}

+ {feeLabel} +
+ +
+ Buy-side depth (+1% / +5%) + + {formatNum(stats.buyDepth1, 3)} X / {formatNum(stats.buyDepth5, 3)} X + +
+
+
+
+ +
+ Sell-side depth (-1% / -5%) + + {formatNum(stats.sellDepth1, 3)} X / {formatNum(stats.sellDepth5, 3)} X + +
+
+
+
+ +
+ Cost to buy 1 X: {formatNum(stats.buyOneXCostY, 3)} Y + Payout for sell 1 X: {formatNum(stats.sellOneXPayoutY, 3)} Y +
+
+ ) +} + +function PropTradeTapeRow({ event }: { event: PropTradeEvent }) { + const flowClass = event.flow === 'arbitrage' ? 'arb' : event.flow === 'retail' ? 'retail' : 'system' + const flowLabel = event.flow === 'arbitrage' ? 'Arb' : event.flow === 'retail' ? 'Retail' : 'System' + const edgeClass = event.edgeDelta >= 0 ? 'good' : 'bad' + + return ( +
  • +
    + {flowLabel} + + t{event.step} | {event.ammName} + +
    +

    {event.summary}

    + {event.isStrategyTrade ? ( +
    strategy edge delta: {formatSigned(event.edgeDelta)}
    + ) : ( +
    normalizer trade
    + )} +
  • + ) +} diff --git a/hooks/usePropSimulationWorker.ts b/hooks/usePropSimulationWorker.ts new file mode 100644 index 0000000..0dc25bf --- /dev/null +++ b/hooks/usePropSimulationWorker.ts @@ -0,0 +1,120 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { PropSimulationConfig, PropStrategyRef, PropWorkerUiState } from '../lib/prop-sim/types' + +interface UsePropSimulationWorkerOptions { + seed: number + playbackSpeed: number + maxTapeRows: number + strategyRef: PropStrategyRef +} + +interface UsePropSimulationWorkerResult { + ready: boolean + workerState: PropWorkerUiState | null + workerError: string | null + controls: { + play: () => void + pause: () => void + step: () => void + reset: () => void + setStrategy: (ref: PropStrategyRef) => void + } +} + +export function usePropSimulationWorker( + options: UsePropSimulationWorkerOptions, +): UsePropSimulationWorkerResult { + const workerRef = useRef(null) + const [ready, setReady] = useState(false) + const [workerState, setWorkerState] = useState(null) + const [workerError, setWorkerError] = useState(null) + + // Initialize worker + useEffect(() => { + const worker = new Worker( + new URL('../workers/prop-simulation.worker.ts', import.meta.url), + { type: 'module' }, + ) + + worker.onmessage = (event) => { + const msg = event.data + if (msg.type === 'ready') { + // Send init + const config: PropSimulationConfig = { + seed: options.seed, + strategyRef: options.strategyRef, + playbackSpeed: options.playbackSpeed, + maxTapeRows: options.maxTapeRows, + } + worker.postMessage({ type: 'init', config }) + } else if (msg.type === 'state') { + setWorkerState(msg.state) + if (!ready) { + setReady(true) + } + } + } + + worker.onerror = (event) => { + setWorkerError(event.message || 'Worker error') + } + + workerRef.current = worker + + return () => { + worker.terminate() + workerRef.current = null + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Update config when options change + useEffect(() => { + if (!workerRef.current || !ready) return + + const config: PropSimulationConfig = { + seed: options.seed, + strategyRef: options.strategyRef, + playbackSpeed: options.playbackSpeed, + maxTapeRows: options.maxTapeRows, + } + workerRef.current.postMessage({ type: 'setConfig', config }) + }, [ready, options.seed, options.playbackSpeed, options.maxTapeRows, options.strategyRef]) + + // Controls + const play = useCallback(() => { + workerRef.current?.postMessage({ type: 'play' }) + }, []) + + const pause = useCallback(() => { + workerRef.current?.postMessage({ type: 'pause' }) + }, []) + + const step = useCallback(() => { + workerRef.current?.postMessage({ type: 'step' }) + }, []) + + const reset = useCallback(() => { + workerRef.current?.postMessage({ type: 'reset' }) + }, []) + + const setStrategy = useCallback((ref: PropStrategyRef) => { + workerRef.current?.postMessage({ type: 'setStrategy', strategyRef: ref }) + }, []) + + return { + ready, + workerState, + workerError, + controls: { + play, + pause, + step, + reset, + setStrategy, + }, + } +} diff --git a/lib/prop-sim/constants.ts b/lib/prop-sim/constants.ts new file mode 100644 index 0000000..8b328e8 --- /dev/null +++ b/lib/prop-sim/constants.ts @@ -0,0 +1,61 @@ +/** + * Prop AMM Challenge simulation parameters + * Based on: https://github.com/benedictbrady/prop-amm-challenge + */ + +// Initial reserves +export const PROP_INITIAL_RESERVE_X = 100 +export const PROP_INITIAL_RESERVE_Y = 10_000 +export const PROP_INITIAL_PRICE = 100 + +// Price process (geometric Brownian motion) +export const PROP_VOLATILITY_MIN = 0.0001 // 0.01% per step +export const PROP_VOLATILITY_MAX = 0.007 // 0.70% per step + +// Retail flow +export const PROP_ARRIVAL_RATE_MIN = 0.4 +export const PROP_ARRIVAL_RATE_MAX = 1.2 +export const PROP_ORDER_SIZE_MEAN_MIN = 12 +export const PROP_ORDER_SIZE_MEAN_MAX = 28 +export const PROP_ORDER_SIZE_SIGMA = 1.2 // Log-normal sigma + +// Normalizer parameters (sampled per simulation) +export const PROP_NORMALIZER_FEE_MIN = 30 // bps +export const PROP_NORMALIZER_FEE_MAX = 80 // bps +export const PROP_NORMALIZER_LIQ_MIN = 0.4 // multiplier +export const PROP_NORMALIZER_LIQ_MAX = 2.0 // multiplier + +// Arbitrage parameters +export const PROP_ARB_MIN_PROFIT = 0.01 // Y units (1 cent) +export const PROP_ARB_BRACKET_TOLERANCE = 0.01 // 1% relative + +// Order routing parameters +export const PROP_ROUTE_BRACKET_TOLERANCE = 0.01 // 1% relative +export const PROP_ROUTE_OBJECTIVE_GAP = 0.01 // 1% objective gap stop + +// Golden ratio for golden-section search +export const PHI = (1 + Math.sqrt(5)) / 2 +export const GOLDEN_RATIO = 1 / PHI // ≈ 0.618 + +// Scale factor for bigint conversions (1e9) +export const PROP_SCALE = 1_000_000_000n +export const PROP_SCALE_NUM = 1_000_000_000 + +// Storage size +export const PROP_STORAGE_SIZE = 1024 + +// Playback speed profiles (shared with original) +export const PROP_SPEED_PROFILE: Record = { + 1: { ms: 1000, label: '1x' }, + 2: { ms: 500, label: '2x' }, + 3: { ms: 250, label: '4x' }, + 4: { ms: 100, label: '10x' }, + 5: { ms: 50, label: '20x' }, + 6: { ms: 10, label: '100x' }, +} + +// Maximum steps per simulation (for guard rails) +export const PROP_MAX_STEPS = 10_000 + +// Chart parameters +export const PROP_CURVE_SAMPLE_POINTS = 60 diff --git a/lib/prop-sim/engine.ts b/lib/prop-sim/engine.ts new file mode 100644 index 0000000..0366df7 --- /dev/null +++ b/lib/prop-sim/engine.ts @@ -0,0 +1,590 @@ +import { + createPropAmm, + executePropBuyX, + executePropSellX, + findPropArbOpportunity, + formatNum, + formatSigned, + fromScaledBigInt, + normalizerQuoteBuyX, + normalizerQuoteSellX, + propAmmK, + propAmmSpot, + routePropRetailOrder, + toScaledBigInt, + type PropQuoteFn, +} from './math' +import type { + PropActiveStrategyRuntime, + PropAmmState, + PropNormalizerConfig, + PropSimulationConfig, + PropSnapshot, + PropStorageChange, + PropTrade, + PropTradeEvent, + PropWorkerUiState, +} from './types' +import { + PROP_INITIAL_RESERVE_X, + PROP_INITIAL_RESERVE_Y, + PROP_NORMALIZER_FEE_MAX, + PROP_NORMALIZER_FEE_MIN, + PROP_NORMALIZER_LIQ_MAX, + PROP_NORMALIZER_LIQ_MIN, + PROP_STORAGE_SIZE, + PROP_VOLATILITY_MAX, + PROP_VOLATILITY_MIN, + PROP_ARRIVAL_RATE_MAX, + PROP_ARRIVAL_RATE_MIN, + PROP_ORDER_SIZE_MEAN_MAX, + PROP_ORDER_SIZE_MEAN_MIN, + PROP_ORDER_SIZE_SIGMA, +} from './constants' +import { getChartViewWindow, trackReservePoint, type ChartWindow } from '../sim/chart' + +interface PropEngineState { + config: PropSimulationConfig + strategy: PropActiveStrategyRuntime + step: number + tradeCount: number + eventSeq: number + fairPrice: number + prevFairPrice: number + storage: Uint8Array + strategyAmm: PropAmmState | null + normalizerAmm: PropAmmState | null + normalizerConfig: PropNormalizerConfig + simulationParams: { + volatility: number + arrivalRate: number + orderSizeMean: number + } + edge: { + total: number + retail: number + arb: number + } + impliedFees: { + bidBps: number + askBps: number + } + pendingEvents: PropTradeEvent[] + history: PropTradeEvent[] + currentSnapshot: PropSnapshot | null + lastEvent: PropTradeEvent | null + lastBadge: string + reserveTrail: Array<{ x: number; y: number }> + viewWindow: ChartWindow | null +} + +interface PropTradeEventInput { + flow: PropTradeEvent['flow'] + amm: PropAmmState + trade: PropTrade + order: PropTradeEvent['order'] + arbProfit: number + priceMove: { from: number; to: number } +} + +export class PropSimulationEngine { + private readonly state: PropEngineState + + constructor(config: PropSimulationConfig, strategy: PropActiveStrategyRuntime) { + this.state = { + config, + strategy, + step: 0, + tradeCount: 0, + eventSeq: 0, + fairPrice: 100, + prevFairPrice: 100, + storage: new Uint8Array(PROP_STORAGE_SIZE), + strategyAmm: null, + normalizerAmm: null, + normalizerConfig: { feeBps: 30, liquidityMult: 1.0 }, + simulationParams: { + volatility: 0.003, + arrivalRate: 0.8, + orderSizeMean: 20, + }, + edge: { total: 0, retail: 0, arb: 0 }, + impliedFees: { bidBps: 0, askBps: 0 }, + pendingEvents: [], + history: [], + currentSnapshot: null, + lastEvent: null, + lastBadge: '', + reserveTrail: [], + viewWindow: null, + } + } + + public setConfig(config: PropSimulationConfig): void { + this.state.config = config + if (this.state.history.length > config.maxTapeRows) { + this.state.history = this.state.history.slice(0, config.maxTapeRows) + } + } + + public setStrategy(strategy: PropActiveStrategyRuntime): void { + this.state.strategy = strategy + } + + public reset( + randomBetween: (min: number, max: number) => number, + ): void { + this.state.step = 0 + this.state.tradeCount = 0 + this.state.eventSeq = 0 + this.state.fairPrice = 100 + this.state.prevFairPrice = 100 + this.state.pendingEvents = [] + this.state.history = [] + this.state.storage = new Uint8Array(PROP_STORAGE_SIZE) + this.state.edge = { total: 0, retail: 0, arb: 0 } + this.state.viewWindow = null + + // Sample simulation parameters + this.state.simulationParams = { + volatility: randomBetween(PROP_VOLATILITY_MIN, PROP_VOLATILITY_MAX), + arrivalRate: randomBetween(PROP_ARRIVAL_RATE_MIN, PROP_ARRIVAL_RATE_MAX), + orderSizeMean: randomBetween(PROP_ORDER_SIZE_MEAN_MIN, PROP_ORDER_SIZE_MEAN_MAX), + } + + // Sample normalizer config + this.state.normalizerConfig = { + feeBps: Math.round(randomBetween(PROP_NORMALIZER_FEE_MIN, PROP_NORMALIZER_FEE_MAX)), + liquidityMult: randomBetween(PROP_NORMALIZER_LIQ_MIN, PROP_NORMALIZER_LIQ_MAX), + } + + // Create AMMs + this.state.strategyAmm = createPropAmm( + this.state.strategy.name, + PROP_INITIAL_RESERVE_X, + PROP_INITIAL_RESERVE_Y, + true, + ) + + const normX = PROP_INITIAL_RESERVE_X * this.state.normalizerConfig.liquidityMult + const normY = PROP_INITIAL_RESERVE_Y * this.state.normalizerConfig.liquidityMult + this.state.normalizerAmm = createPropAmm( + `Normalizer (${this.state.normalizerConfig.feeBps} bps)`, + normX, + normY, + false, + ) + + // Initialize implied fees from strategy + this.state.impliedFees = { + bidBps: this.state.strategy.feeBps, + askBps: this.state.strategy.feeBps, + } + + this.state.reserveTrail = [{ x: this.state.strategyAmm.reserveX, y: this.state.strategyAmm.reserveY }] + this.state.lastBadge = this.formatFeeBadge() + this.state.currentSnapshot = this.snapshotState() + + this.state.lastEvent = { + id: 0, + step: 0, + flow: 'system', + ammName: this.state.strategyAmm.name, + isStrategyTrade: false, + codeLines: [], + codeExplanation: `Simulation initialized. Normalizer: ${this.state.normalizerConfig.feeBps} bps @ ${this.state.normalizerConfig.liquidityMult.toFixed(2)}x liquidity. Volatility: ${(this.state.simulationParams.volatility * 100).toFixed(3)}%/step.`, + stateBadge: this.state.lastBadge, + summary: 'Simulation initialized.', + edgeDelta: 0, + trade: null, + order: null, + arbProfit: 0, + fairPrice: this.state.fairPrice, + priceMove: { from: this.state.fairPrice, to: this.state.fairPrice }, + snapshot: this.state.currentSnapshot, + } + + this.refreshViewWindow() + } + + public stepOne( + randomBetween: (min: number, max: number) => number, + gaussianRandom: () => number, + ): boolean { + this.ensurePendingEvents(randomBetween, gaussianRandom) + if (!this.state.pendingEvents.length) { + return false + } + + const event = this.state.pendingEvents.shift() + if (!event) { + return false + } + + this.state.tradeCount += 1 + this.state.lastEvent = event + this.state.currentSnapshot = event.snapshot + + this.state.reserveTrail = trackReservePoint( + this.state.reserveTrail, + event.snapshot as unknown as Parameters[1], + ) + this.refreshViewWindow() + + this.state.history.unshift(event) + if (this.state.history.length > this.state.config.maxTapeRows) { + this.state.history.pop() + } + + return true + } + + private refreshViewWindow(): void { + if (!this.state.currentSnapshot) return + + const targetX = Math.sqrt(this.state.currentSnapshot.strategy.k / this.state.currentSnapshot.fairPrice) + const targetY = this.state.currentSnapshot.strategy.k / targetX + + this.state.viewWindow = getChartViewWindow( + this.state.currentSnapshot as unknown as Parameters[0], + targetX, + targetY, + this.state.reserveTrail, + this.state.viewWindow, + ) + } + + private ensurePendingEvents( + randomBetween: (min: number, max: number) => number, + gaussianRandom: () => number, + ): void { + let guard = 0 + while (this.state.pendingEvents.length === 0 && guard < 8) { + this.generateNextStep(randomBetween, gaussianRandom) + guard += 1 + } + } + + private generateNextStep( + randomBetween: (min: number, max: number) => number, + gaussianRandom: () => number, + ): void { + const strategyAmm = this.requireAmm(this.state.strategyAmm) + const normalizerAmm = this.requireAmm(this.state.normalizerAmm) + + this.state.step += 1 + + // Price move (GBM) + const oldPrice = this.state.fairPrice + const sigma = this.state.simulationParams.volatility + const shock = gaussianRandom() + this.state.fairPrice = Math.max(1, oldPrice * Math.exp(-0.5 * sigma * sigma + sigma * shock)) + this.state.prevFairPrice = oldPrice + + const priceMove = { from: oldPrice, to: this.state.fairPrice } + + // Run arbitrage on both AMMs + this.runArbitrageForAmm(strategyAmm, this.makeStrategyQuoteFn(), priceMove, true) + this.runArbitrageForAmm(normalizerAmm, this.makeNormalizerQuoteFn(), priceMove, false) + + // Route retail order (Poisson arrival) + if (randomBetween(0, 1) < this.state.simulationParams.arrivalRate) { + const order = this.generateRetailOrder(randomBetween, gaussianRandom) + this.routeRetailOrder(order, priceMove) + } + } + + private makeStrategyQuoteFn(): PropQuoteFn { + const strategy = this.state.strategy + const storage = this.state.storage + const amm = this.requireAmm(this.state.strategyAmm) + + return (side: 0 | 1, inputAmount: number): number => { + return strategy.computeSwap(amm.reserveX, amm.reserveY, side, inputAmount, storage) + } + } + + private makeNormalizerQuoteFn(): PropQuoteFn { + const amm = this.requireAmm(this.state.normalizerAmm) + const feeBps = this.state.normalizerConfig.feeBps + + return (side: 0 | 1, inputAmount: number): number => { + if (side === 0) { + return normalizerQuoteBuyX(amm.reserveX, amm.reserveY, feeBps, inputAmount) + } else { + return normalizerQuoteSellX(amm.reserveX, amm.reserveY, feeBps, inputAmount) + } + } + } + + private runArbitrageForAmm( + amm: PropAmmState, + quoteFn: PropQuoteFn, + priceMove: { from: number; to: number }, + isStrategy: boolean, + ): void { + const arb = findPropArbOpportunity(amm, this.state.fairPrice, quoteFn) + if (!arb || arb.inputAmount <= 0.00000001) { + return + } + + let trade: PropTrade | null = null + + if (arb.side === 'buy') { + // Arb buys X from AMM (inputs Y) + trade = executePropBuyX(amm, quoteFn, arb.inputAmount, this.state.step) + } else { + // Arb sells X to AMM (inputs X) + trade = executePropSellX(amm, quoteFn, arb.inputAmount, this.state.step) + } + + if (!trade) { + return + } + + this.enqueueTradeEvent({ + flow: 'arbitrage', + amm, + trade, + order: null, + arbProfit: arb.expectedProfit, + priceMove, + }, isStrategy, quoteFn) + } + + private routeRetailOrder( + order: { side: 'buy' | 'sell'; sizeY: number }, + priceMove: { from: number; to: number }, + ): void { + const strategyAmm = this.requireAmm(this.state.strategyAmm) + const normalizerAmm = this.requireAmm(this.state.normalizerAmm) + + const strategyQuote = this.makeStrategyQuoteFn() + const normalizerQuote = this.makeNormalizerQuoteFn() + + const splits = routePropRetailOrder( + strategyAmm, + normalizerAmm, + strategyQuote, + normalizerQuote, + order, + ) + + for (const [amm, amount, quoteFn] of splits) { + const isStrategy = amm.isStrategy + let trade: PropTrade | null = null + + if (order.side === 'buy') { + // Retail buys X (inputs Y) + trade = executePropBuyX(amm, quoteFn, amount, this.state.step) + } else { + // Retail sells X (inputs X) + trade = executePropSellX(amm, quoteFn, amount, this.state.step) + } + + if (trade) { + this.enqueueTradeEvent({ + flow: 'retail', + amm, + trade, + order, + arbProfit: 0, + priceMove, + }, isStrategy, quoteFn) + } + } + } + + private enqueueTradeEvent( + input: PropTradeEventInput, + isStrategy: boolean, + quoteFn: PropQuoteFn, + ): void { + const { flow, amm, trade, order, arbProfit, priceMove } = input + + let edgeDelta = 0 + if (isStrategy) { + if (flow === 'arbitrage') { + edgeDelta = -arbProfit + this.state.edge.arb += edgeDelta + } else { + // Retail edge calculation + if (trade.side === 'buy') { + // AMM bought X: edge = outputY - inputX * fairPrice + edgeDelta = trade.outputAmount - trade.inputAmount * this.state.fairPrice + } else { + // AMM sold X: edge = inputY - outputX * fairPrice + edgeDelta = trade.inputAmount - trade.outputAmount * this.state.fairPrice + } + this.state.edge.retail += edgeDelta + } + this.state.edge.total += edgeDelta + + // Update implied fees from last trade + this.state.impliedFees = { + bidBps: trade.side === 'buy' ? trade.impliedFeeBps : this.state.impliedFees.bidBps, + askBps: trade.side === 'sell' ? trade.impliedFeeBps : this.state.impliedFees.askBps, + } + + // Call afterSwap + const ctx = { + side: (trade.side === 'buy' ? 1 : 0) as 0 | 1, + inputAmount: trade.inputAmount, + outputAmount: trade.outputAmount, + reserveX: trade.reserveX, + reserveY: trade.reserveY, + step: this.state.step, + flowType: flow, + fairPrice: this.state.fairPrice, + edgeDelta, + } + this.state.storage = this.state.strategy.afterSwap(ctx, this.state.storage) + } + + const codeExplanation = isStrategy + ? this.describeStrategyExecution(trade, flow, edgeDelta) + : 'Trade hit the normalizer AMM.' + + const stateBadge = this.formatFeeBadge() + this.state.lastBadge = stateBadge + + const event: PropTradeEvent = { + id: ++this.state.eventSeq, + step: this.state.step, + flow, + ammName: amm.name, + isStrategyTrade: isStrategy, + trade, + order, + arbProfit, + fairPrice: this.state.fairPrice, + priceMove, + edgeDelta, + codeLines: isStrategy ? [1] : [], + codeExplanation, + stateBadge, + summary: this.describeTrade(flow, amm, trade, order), + snapshot: this.snapshotState(), + strategyExecution: isStrategy ? { + outputAmount: trade.outputAmount, + storageChanges: [], + } : undefined, + } + + this.state.pendingEvents.push(event) + } + + private describeStrategyExecution( + trade: PropTrade, + flow: PropTradeEvent['flow'], + edgeDelta: number, + ): string { + const side = trade.side === 'buy' ? 'sold X' : 'bought X' + const input = formatNum(trade.inputAmount, 4) + const output = formatNum(trade.outputAmount, 4) + const implied = trade.impliedFeeBps + const edge = formatSigned(edgeDelta) + + return `compute_swap: AMM ${side}. Input: ${input}, Output: ${output}. Implied fee: ~${implied} bps. Edge delta: ${edge}.` + } + + private describeTrade( + flow: PropTradeEvent['flow'], + amm: PropAmmState, + trade: PropTrade, + order: { side: 'buy' | 'sell'; sizeY: number } | null, + ): string { + const move = trade.side === 'buy' ? 'bought X (sold Y)' : 'sold X (bought Y)' + const base = `${amm.name}: ${move} | in=${formatNum(trade.inputAmount, 4)} | out=${formatNum(trade.outputAmount, 4)}` + + if (flow === 'arbitrage') { + return `${base} | arb vs fair ${formatNum(this.state.fairPrice, 2)}` + } + + const orderLabel = order ? `${order.side} ${formatNum(order.sizeY, 2)} Y` : 'retail' + return `${base} | routed from ${orderLabel}` + } + + private generateRetailOrder( + randomBetween: (min: number, max: number) => number, + gaussianRandom: () => number, + ): { side: 'buy' | 'sell'; sizeY: number } { + const side: 'buy' | 'sell' = randomBetween(0, 1) < 0.5 ? 'buy' : 'sell' + const mu = Math.log(this.state.simulationParams.orderSizeMean) - 0.5 * PROP_ORDER_SIZE_SIGMA * PROP_ORDER_SIZE_SIGMA + const sample = Math.exp(mu + PROP_ORDER_SIZE_SIGMA * gaussianRandom()) + const sizeY = Math.max(4, Math.min(100, sample)) + return { side, sizeY } + } + + private snapshotState(): PropSnapshot { + const strategyAmm = this.requireAmm(this.state.strategyAmm) + const normalizerAmm = this.requireAmm(this.state.normalizerAmm) + + return { + step: this.state.step, + fairPrice: this.state.fairPrice, + strategy: { + x: strategyAmm.reserveX, + y: strategyAmm.reserveY, + k: propAmmK(strategyAmm), + impliedBidBps: this.state.impliedFees.bidBps, + impliedAskBps: this.state.impliedFees.askBps, + }, + normalizer: { + x: normalizerAmm.reserveX, + y: normalizerAmm.reserveY, + k: propAmmK(normalizerAmm), + feeBps: this.state.normalizerConfig.feeBps, + liquidityMult: this.state.normalizerConfig.liquidityMult, + }, + edge: { ...this.state.edge }, + simulationParams: { + volatility: this.state.simulationParams.volatility, + arrivalRate: this.state.simulationParams.arrivalRate, + }, + } + } + + private formatFeeBadge(): string { + const bid = this.state.impliedFees.bidBps + const ask = this.state.impliedFees.askBps + const norm = this.state.normalizerConfig + return `implied: ${bid}/${ask} bps | norm: ${norm.feeBps} bps @ ${norm.liquidityMult.toFixed(2)}x` + } + + private requireAmm(amm: PropAmmState | null): PropAmmState { + if (!amm) { + throw new Error('AMM state not initialized') + } + return amm + } + + public toUiState( + availableStrategies: Array<{ kind: 'builtin'; id: string; name: string }>, + isPlaying: boolean, + ): PropWorkerUiState { + if (!this.state.currentSnapshot || !this.state.lastEvent) { + throw new Error('Simulation is not initialized') + } + + return { + config: this.state.config, + currentStrategy: { + kind: this.state.strategy.ref.kind, + id: this.state.strategy.ref.id, + name: this.state.strategy.name, + code: this.state.strategy.code, + feeBps: this.state.strategy.feeBps, + }, + isPlaying, + tradeCount: this.state.tradeCount, + snapshot: this.state.currentSnapshot, + lastEvent: this.state.lastEvent, + history: this.state.history, + reserveTrail: this.state.reserveTrail, + viewWindow: this.state.viewWindow, + availableStrategies, + normalizerConfig: this.state.normalizerConfig, + } + } +} diff --git a/lib/prop-sim/index.ts b/lib/prop-sim/index.ts new file mode 100644 index 0000000..f8ea742 --- /dev/null +++ b/lib/prop-sim/index.ts @@ -0,0 +1,4 @@ +export * from './types' +export * from './constants' +export * from './math' +export * from './engine' diff --git a/lib/prop-sim/math.ts b/lib/prop-sim/math.ts new file mode 100644 index 0000000..9ad2b28 --- /dev/null +++ b/lib/prop-sim/math.ts @@ -0,0 +1,511 @@ +import type { PropAmmState, PropDepthStats, PropTrade } from './types' +import { + GOLDEN_RATIO, + PROP_ARB_BRACKET_TOLERANCE, + PROP_ARB_MIN_PROFIT, + PROP_ROUTE_BRACKET_TOLERANCE, + PROP_SCALE, + PROP_SCALE_NUM, +} from './constants' + +// ============================================================================ +// AMM State Helpers +// ============================================================================ + +export function createPropAmm( + name: string, + reserveX: number, + reserveY: number, + isStrategy: boolean, +): PropAmmState { + return { name, reserveX, reserveY, isStrategy } +} + +export function propAmmK(amm: PropAmmState): number { + return amm.reserveX * amm.reserveY +} + +export function propAmmSpot(amm: PropAmmState): number { + return amm.reserveY / Math.max(amm.reserveX, 1e-12) +} + +// ============================================================================ +// Constant-Product Normalizer Quotes +// ============================================================================ + +/** + * Quote output for buying X (input Y) from constant-product normalizer + */ +export function normalizerQuoteBuyX( + reserveX: number, + reserveY: number, + feeBps: number, + inputY: number, +): number { + if (inputY <= 0) return 0 + const gamma = 1 - feeBps / 10000 + if (gamma <= 0) return 0 + + const k = reserveX * reserveY + const netY = inputY * gamma + const newY = reserveY + netY + const newX = k / newY + const outputX = reserveX - newX + + return Math.max(0, outputX) +} + +/** + * Quote output for selling X (input X) to constant-product normalizer + */ +export function normalizerQuoteSellX( + reserveX: number, + reserveY: number, + feeBps: number, + inputX: number, +): number { + if (inputX <= 0) return 0 + const gamma = 1 - feeBps / 10000 + if (gamma <= 0) return 0 + + const k = reserveX * reserveY + const netX = inputX * gamma + const newX = reserveX + netX + const newY = k / newX + const outputY = reserveY - newY + + return Math.max(0, outputY) +} + +// ============================================================================ +// Trade Execution +// ============================================================================ + +export type PropQuoteFn = (side: 0 | 1, inputAmount: number) => number + +/** + * Execute a buy X trade (input Y, output X) using a quote function + */ +export function executePropBuyX( + amm: PropAmmState, + quoteFn: PropQuoteFn, + inputY: number, + timestamp: number, +): PropTrade | null { + if (inputY <= 0) return null + + const outputX = quoteFn(0, inputY) + if (outputX <= 0 || outputX >= amm.reserveX) return null + + const beforeX = amm.reserveX + const beforeY = amm.reserveY + const spotBefore = beforeY / beforeX + + amm.reserveX = beforeX - outputX + amm.reserveY = beforeY + inputY + + const spotAfter = amm.reserveY / amm.reserveX + + // Back-calculate implied fee + const theoreticalOutputNoFee = (beforeX * inputY) / (beforeY + inputY) + const impliedFeeBps = Math.max(0, Math.round((1 - outputX / theoreticalOutputNoFee) * 10000)) + + return { + side: 'sell', // AMM sells X + inputAmount: inputY, + outputAmount: outputX, + timestamp, + reserveX: amm.reserveX, + reserveY: amm.reserveY, + beforeX, + beforeY, + spotBefore, + spotAfter, + impliedFeeBps, + } +} + +/** + * Execute a sell X trade (input X, output Y) using a quote function + */ +export function executePropSellX( + amm: PropAmmState, + quoteFn: PropQuoteFn, + inputX: number, + timestamp: number, +): PropTrade | null { + if (inputX <= 0) return null + + const outputY = quoteFn(1, inputX) + if (outputY <= 0 || outputY >= amm.reserveY) return null + + const beforeX = amm.reserveX + const beforeY = amm.reserveY + const spotBefore = beforeY / beforeX + + amm.reserveX = beforeX + inputX + amm.reserveY = beforeY - outputY + + const spotAfter = amm.reserveY / amm.reserveX + + // Back-calculate implied fee + const theoreticalOutputNoFee = (beforeY * inputX) / (beforeX + inputX) + const impliedFeeBps = Math.max(0, Math.round((1 - outputY / theoreticalOutputNoFee) * 10000)) + + return { + side: 'buy', // AMM buys X + inputAmount: inputX, + outputAmount: outputY, + timestamp, + reserveX: amm.reserveX, + reserveY: amm.reserveY, + beforeX, + beforeY, + spotBefore, + spotAfter, + impliedFeeBps, + } +} + +// ============================================================================ +// Golden-Section Search +// ============================================================================ + +/** + * Golden-section search to maximize a unimodal function f(x) on [lo, hi] + */ +export function goldenSectionMaximize( + f: (x: number) => number, + lo: number, + hi: number, + tolerance: number, + maxIter: number = 50, +): { x: number; fx: number } { + let a = lo + let b = hi + + let c = b - GOLDEN_RATIO * (b - a) + let d = a + GOLDEN_RATIO * (b - a) + let fc = f(c) + let fd = f(d) + + for (let i = 0; i < maxIter; i++) { + const width = b - a + if (width < tolerance * Math.max(Math.abs(a), Math.abs(b), 1)) { + break + } + + if (fc > fd) { + b = d + d = c + fd = fc + c = b - GOLDEN_RATIO * (b - a) + fc = f(c) + } else { + a = c + c = d + fc = fd + d = a + GOLDEN_RATIO * (b - a) + fd = f(d) + } + } + + const x = (a + b) / 2 + return { x, fx: f(x) } +} + +// ============================================================================ +// Arbitrage Solver (Golden-Section) +// ============================================================================ + +export interface PropArbResult { + side: 'buy' | 'sell' + inputAmount: number + expectedProfit: number +} + +/** + * Find optimal arbitrage opportunity using golden-section search + * + * @param amm Current AMM state + * @param fairPrice Fair market price (Y/X) + * @param quoteFn Strategy's quote function + * @param minProfit Minimum profit threshold in Y + * @param tolerance Relative bracket tolerance for early stopping + */ +export function findPropArbOpportunity( + amm: PropAmmState, + fairPrice: number, + quoteFn: PropQuoteFn, + minProfit: number = PROP_ARB_MIN_PROFIT, + tolerance: number = PROP_ARB_BRACKET_TOLERANCE, +): PropArbResult | null { + const spot = propAmmSpot(amm) + + if (Math.abs(spot - fairPrice) / fairPrice < 0.0001) { + return null // Spot is at fair price, no arb + } + + if (spot < fairPrice) { + // AMM underprices X: buy X from AMM (input Y), sell at fair price + // Profit = outputX * fairPrice - inputY + const maxInputY = amm.reserveY * 0.5 // Don't drain more than half + + const profitFn = (inputY: number): number => { + if (inputY <= 0) return 0 + const outputX = quoteFn(0, inputY) + if (outputX <= 0) return -1e9 + return outputX * fairPrice - inputY + } + + const result = goldenSectionMaximize(profitFn, 0, maxInputY, tolerance) + + if (result.fx >= minProfit) { + return { + side: 'buy', // Arb buys X from AMM + inputAmount: result.x, + expectedProfit: result.fx, + } + } + } else { + // AMM overprices X: sell X to AMM (input X), buy at fair price + // Profit = outputY - inputX * fairPrice + const maxInputX = amm.reserveX * 0.5 + + const profitFn = (inputX: number): number => { + if (inputX <= 0) return 0 + const outputY = quoteFn(1, inputX) + if (outputY <= 0) return -1e9 + return outputY - inputX * fairPrice + } + + const result = goldenSectionMaximize(profitFn, 0, maxInputX, tolerance) + + if (result.fx >= minProfit) { + return { + side: 'sell', // Arb sells X to AMM + inputAmount: result.x, + expectedProfit: result.fx, + } + } + } + + return null +} + +// ============================================================================ +// Order Routing (Golden-Section) +// ============================================================================ + +/** + * Route a retail order between strategy and normalizer AMMs + * Uses golden-section search to find optimal split + * + * @returns Array of [amm, amount] pairs + */ +export function routePropRetailOrder( + strategyAmm: PropAmmState, + normalizerAmm: PropAmmState, + strategyQuote: PropQuoteFn, + normalizerQuote: PropQuoteFn, + order: { side: 'buy' | 'sell'; sizeY: number }, + tolerance: number = PROP_ROUTE_BRACKET_TOLERANCE, +): Array<[PropAmmState, number, PropQuoteFn]> { + const totalSize = order.sizeY + if (totalSize <= 0) return [] + + if (order.side === 'buy') { + // Buying X: input is Y, split Y between AMMs, maximize total X output + const outputFn = (alpha: number): number => { + const strategyY = alpha * totalSize + const normalizerY = (1 - alpha) * totalSize + + const strategyX = strategyY > 0.01 ? strategyQuote(0, strategyY) : 0 + const normalizerX = normalizerY > 0.01 ? normalizerQuote(0, normalizerY) : 0 + + return strategyX + normalizerX + } + + const result = goldenSectionMaximize(outputFn, 0, 1, tolerance) + const alpha = result.x + + const strategyY = alpha * totalSize + const normalizerY = (1 - alpha) * totalSize + + const splits: Array<[PropAmmState, number, PropQuoteFn]> = [] + if (strategyY > 0.01) splits.push([strategyAmm, strategyY, strategyQuote]) + if (normalizerY > 0.01) splits.push([normalizerAmm, normalizerY, normalizerQuote]) + + return splits + } else { + // Selling X: convert sizeY to X using fair price estimate, split X + const spotAvg = (propAmmSpot(strategyAmm) + propAmmSpot(normalizerAmm)) / 2 + const totalX = totalSize / spotAvg + + const outputFn = (alpha: number): number => { + const strategyX = alpha * totalX + const normalizerX = (1 - alpha) * totalX + + const strategyYOut = strategyX > 0.0001 ? strategyQuote(1, strategyX) : 0 + const normalizerYOut = normalizerX > 0.0001 ? normalizerQuote(1, normalizerX) : 0 + + return strategyYOut + normalizerYOut + } + + const result = goldenSectionMaximize(outputFn, 0, 1, tolerance) + const alpha = result.x + + const strategyX = alpha * totalX + const normalizerX = (1 - alpha) * totalX + + const splits: Array<[PropAmmState, number, PropQuoteFn]> = [] + if (strategyX > 0.0001) splits.push([strategyAmm, strategyX, strategyQuote]) + if (normalizerX > 0.0001) splits.push([normalizerAmm, normalizerX, normalizerQuote]) + + return splits + } +} + +// ============================================================================ +// Depth Stats +// ============================================================================ + +export function buildPropDepthStats( + amm: PropAmmState, + quoteFn: PropQuoteFn, +): PropDepthStats { + const spot = propAmmSpot(amm) + + // Buy-side depth: how much X can you buy to move price up by 1%/5%? + const buyDepth1 = findDepthForImpact(amm, quoteFn, 0.01, 'buy') + const buyDepth5 = findDepthForImpact(amm, quoteFn, 0.05, 'buy') + + // Sell-side depth: how much X can you sell to move price down by 1%/5%? + const sellDepth1 = findDepthForImpact(amm, quoteFn, 0.01, 'sell') + const sellDepth5 = findDepthForImpact(amm, quoteFn, 0.05, 'sell') + + // Cost to buy/sell 1 X + const buyOneXCostY = findInputForOutput(quoteFn, 0, 1, amm.reserveY * 0.5) + const sellOneXPayoutY = quoteFn(1, 1) + + return { + buyDepth1, + buyDepth5, + sellDepth1, + sellDepth5, + buyOneXCostY, + sellOneXPayoutY, + } +} + +function findDepthForImpact( + amm: PropAmmState, + quoteFn: PropQuoteFn, + targetImpact: number, + direction: 'buy' | 'sell', +): number { + const spot = propAmmSpot(amm) + const targetSpot = direction === 'buy' + ? spot * (1 + targetImpact) + : spot * (1 - targetImpact) + + // Binary search for the trade size that achieves target impact + let lo = 0 + let hi = direction === 'buy' ? amm.reserveY * 0.9 : amm.reserveX * 0.9 + + for (let i = 0; i < 30; i++) { + const mid = (lo + hi) / 2 + + // Simulate the trade + const testAmm = { ...amm } + let newSpot: number + + if (direction === 'buy') { + const outputX = quoteFn(0, mid) + if (outputX <= 0 || outputX >= testAmm.reserveX) { + hi = mid + continue + } + testAmm.reserveX -= outputX + testAmm.reserveY += mid + newSpot = testAmm.reserveY / testAmm.reserveX + } else { + const outputY = quoteFn(1, mid) + if (outputY <= 0 || outputY >= testAmm.reserveY) { + hi = mid + continue + } + testAmm.reserveX += mid + testAmm.reserveY -= outputY + newSpot = testAmm.reserveY / testAmm.reserveX + } + + const achievedImpact = Math.abs(newSpot - spot) / spot + + if (Math.abs(achievedImpact - targetImpact) < 0.001) { + return direction === 'buy' ? quoteFn(0, mid) : mid // Return X amount + } + + if (achievedImpact < targetImpact) { + lo = mid + } else { + hi = mid + } + } + + // Return approximate result + const finalSize = (lo + hi) / 2 + return direction === 'buy' ? quoteFn(0, finalSize) : finalSize +} + +function findInputForOutput( + quoteFn: PropQuoteFn, + side: 0 | 1, + targetOutput: number, + maxInput: number, +): number { + let lo = 0 + let hi = maxInput + + for (let i = 0; i < 30; i++) { + const mid = (lo + hi) / 2 + const output = quoteFn(side, mid) + + if (Math.abs(output - targetOutput) < 0.0001) { + return mid + } + + if (output < targetOutput) { + lo = mid + } else { + hi = mid + } + } + + return (lo + hi) / 2 +} + +// ============================================================================ +// Utility Conversions +// ============================================================================ + +export function toScaledBigInt(value: number): bigint { + return BigInt(Math.round(Math.max(0, value) * PROP_SCALE_NUM)) +} + +export function fromScaledBigInt(value: bigint): number { + return Number(value) / PROP_SCALE_NUM +} + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +export function formatNum(value: number, decimals: number): string { + return value.toFixed(decimals) +} + +export function formatSigned(value: number, decimals: number = 2): string { + const sign = value >= 0 ? '+' : '' + return `${sign}${value.toFixed(decimals)}` +} diff --git a/lib/prop-sim/types.ts b/lib/prop-sim/types.ts new file mode 100644 index 0000000..b9168b3 --- /dev/null +++ b/lib/prop-sim/types.ts @@ -0,0 +1,181 @@ +export type PropFlowType = 'arbitrage' | 'retail' | 'system' + +export type PropStrategyKind = 'builtin' + +export interface PropStrategyRef { + kind: PropStrategyKind + id: string +} + +export interface PropSimulationConfig { + seed: number + strategyRef: PropStrategyRef + playbackSpeed: number + maxTapeRows: number +} + +export interface PropAmmState { + name: string + reserveX: number + reserveY: number + isStrategy: boolean +} + +export interface PropNormalizerConfig { + feeBps: number // Sampled per simulation: 30-80 + liquidityMult: number // Sampled per simulation: 0.4-2.0 +} + +export interface PropTrade { + side: 'buy' | 'sell' // buy = AMM buys X (receives X, pays Y), sell = AMM sells X + inputAmount: number + outputAmount: number + timestamp: number + reserveX: number // Post-trade + reserveY: number + beforeX: number + beforeY: number + spotBefore: number + spotAfter: number + impliedFeeBps: number // Back-calculated from trade +} + +export interface PropSnapshotAmm { + x: number + y: number + k: number // x * y for reference + impliedBidBps: number // Last trade implied fee + impliedAskBps: number +} + +export interface PropSnapshotNormalizer { + x: number + y: number + k: number + feeBps: number + liquidityMult: number +} + +export interface PropSnapshot { + step: number + fairPrice: number + strategy: PropSnapshotAmm + normalizer: PropSnapshotNormalizer + edge: { + total: number + retail: number + arb: number + } + simulationParams: { + volatility: number + arrivalRate: number + } +} + +export interface PropStorageChange { + offset: number + before: number + after: number +} + +export interface PropStrategyExecution { + outputAmount: number + storageChanges: PropStorageChange[] +} + +export interface PropTradeEvent { + id: number + step: number + flow: PropFlowType + ammName: string + isStrategyTrade: boolean + trade: PropTrade | null + order: { side: 'buy' | 'sell'; sizeY: number } | null + arbProfit: number + fairPrice: number + priceMove: { from: number; to: number } + edgeDelta: number + codeLines: number[] + codeExplanation: string + stateBadge: string + summary: string + snapshot: PropSnapshot + strategyExecution?: PropStrategyExecution +} + +export interface PropComputeSwapInput { + side: 0 | 1 // 0 = buy X (Y input), 1 = sell X (X input) + inputAmount: bigint // 1e9 scale + reserveX: bigint + reserveY: bigint + storage: Uint8Array // 1024 bytes +} + +export interface PropAfterSwapInput { + side: 0 | 1 + inputAmount: bigint + outputAmount: bigint + reserveX: bigint // Post-trade + reserveY: bigint + step: bigint + storage: Uint8Array // 1024 bytes, mutable +} + +export interface PropStrategyCallbackContext { + side: 0 | 1 + inputAmount: number + outputAmount: number + reserveX: number + reserveY: number + step: number + flowType: PropFlowType + fairPrice: number + edgeDelta: number +} + +export interface PropBuiltinStrategy { + id: string + name: string + code: string + feeBps: number // For explanation purposes + computeSwap: (input: PropComputeSwapInput) => bigint + afterSwap?: (input: PropAfterSwapInput, storage: Uint8Array) => Uint8Array +} + +export interface PropActiveStrategyRuntime { + ref: PropStrategyRef + name: string + code: string + feeBps: number + computeSwap: (reserveX: number, reserveY: number, side: 0 | 1, inputAmount: number, storage: Uint8Array) => number + afterSwap: (ctx: PropStrategyCallbackContext, storage: Uint8Array) => Uint8Array +} + +export interface PropWorkerUiState { + config: PropSimulationConfig + currentStrategy: { + kind: PropStrategyKind + id: string + name: string + code: string + feeBps: number + } + isPlaying: boolean + tradeCount: number + snapshot: PropSnapshot + lastEvent: PropTradeEvent + history: PropTradeEvent[] + reserveTrail: Array<{ x: number; y: number }> + viewWindow: { xMin: number; xMax: number; yMin: number; yMax: number } | null + availableStrategies: Array<{ kind: PropStrategyKind; id: string; name: string }> + normalizerConfig: PropNormalizerConfig +} + +export interface PropDepthStats { + buyDepth1: number + buyDepth5: number + sellDepth1: number + sellDepth5: number + buyOneXCostY: number + sellOneXPayoutY: number +} diff --git a/lib/prop-strategies/builtins.ts b/lib/prop-strategies/builtins.ts new file mode 100644 index 0000000..572bd92 --- /dev/null +++ b/lib/prop-strategies/builtins.ts @@ -0,0 +1,231 @@ +import type { PropBuiltinStrategy, PropComputeSwapInput } from '../prop-sim/types' + +const STARTER_RUST_SOURCE = `use pinocchio::{account_info::AccountInfo, entrypoint, pubkey::Pubkey, ProgramResult}; +use prop_amm_submission_sdk::{set_return_data_bytes, set_return_data_u64}; + +/// Required: displayed on the leaderboard. +const NAME: &str = "Starter (500 bps)"; +const MODEL_USED: &str = "None"; + +const FEE_NUMERATOR: u128 = 950; +const FEE_DENOMINATOR: u128 = 1000; +const STORAGE_SIZE: usize = 1024; + +#[derive(wincode::SchemaRead)] +struct ComputeSwapInstruction { + side: u8, + input_amount: u64, + reserve_x: u64, + reserve_y: u64, + _storage: [u8; STORAGE_SIZE], +} + +#[cfg(not(feature = "no-entrypoint"))] +entrypoint!(process_instruction); + +pub fn process_instruction( + _program_id: &Pubkey, _accounts: &[AccountInfo], instruction_data: &[u8], +) -> ProgramResult { + if instruction_data.is_empty() { + return Ok(()); + } + + match instruction_data[0] { + 0 | 1 => { + let output = compute_swap(instruction_data); + set_return_data_u64(output); + } + 2 => { /* afterSwap - no-op for starter */ } + 3 => set_return_data_bytes(NAME.as_bytes()), + 4 => set_return_data_bytes(get_model_used().as_bytes()), + _ => {} + } + Ok(()) +} + +pub fn get_model_used() -> &'static str { + MODEL_USED +} + +pub fn compute_swap(data: &[u8]) -> u64 { + let decoded: ComputeSwapInstruction = match wincode::deserialize(data) { + Ok(decoded) => decoded, + Err(_) => return 0, + }; + + let side = decoded.side; + let input_amount = decoded.input_amount as u128; + let reserve_x = decoded.reserve_x as u128; + let reserve_y = decoded.reserve_y as u128; + + if reserve_x == 0 || reserve_y == 0 { + return 0; + } + + let k = reserve_x * reserve_y; + + match side { + 0 => { + // Buy X: input is Y, output is X + let net_y = input_amount * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_ry = reserve_y + net_y; + let k_div = (k + new_ry - 1) / new_ry; // ceil div + reserve_x.saturating_sub(k_div) as u64 + } + 1 => { + // Sell X: input is X, output is Y + let net_x = input_amount * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_rx = reserve_x + net_x; + let k_div = (k + new_rx - 1) / new_rx; // ceil div + reserve_y.saturating_sub(k_div) as u64 + } + _ => 0, + } +} + +/// Optional native hook for local testing. +pub fn after_swap(_data: &[u8], _storage: &mut [u8]) { + // No-op for starter +}` + +const BASELINE_30BPS_SOURCE = `// Constant-product AMM with 30 basis points fee +// This matches the normalizer's behavior when fee=30bps + +const NAME: &str = "Baseline (30 bps)"; +const FEE_NUMERATOR: u128 = 9970; // 100% - 0.30% +const FEE_DENOMINATOR: u128 = 10000; + +pub fn compute_swap(side: u8, input: u128, rx: u128, ry: u128) -> u64 { + if rx == 0 || ry == 0 { return 0; } + let k = rx * ry; + + match side { + 0 => { // Buy X (input Y) + let net_y = input * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_ry = ry + net_y; + rx.saturating_sub((k + new_ry - 1) / new_ry) as u64 + } + 1 => { // Sell X (input X) + let net_x = input * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_rx = rx + net_x; + ry.saturating_sub((k + new_rx - 1) / new_rx) as u64 + } + _ => 0, + } +}` + +const TIGHT_10BPS_SOURCE = `// Aggressive constant-product AMM with 10 basis points fee +// Tighter spread attracts more flow but more arb exposure + +const NAME: &str = "Tight (10 bps)"; +const FEE_NUMERATOR: u128 = 9990; // 100% - 0.10% +const FEE_DENOMINATOR: u128 = 10000; + +pub fn compute_swap(side: u8, input: u128, rx: u128, ry: u128) -> u64 { + if rx == 0 || ry == 0 { return 0; } + let k = rx * ry; + + match side { + 0 => { // Buy X (input Y) + let net_y = input * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_ry = ry + net_y; + rx.saturating_sub((k + new_ry - 1) / new_ry) as u64 + } + 1 => { // Sell X (input X) + let net_x = input * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_rx = rx + net_x; + ry.saturating_sub((k + new_rx - 1) / new_rx) as u64 + } + _ => 0, + } +}` + +const WIDE_100BPS_SOURCE = `// Wide constant-product AMM with 100 basis points fee +// Wider spread = more profit per trade but less flow + +const NAME: &str = "Wide (100 bps)"; +const FEE_NUMERATOR: u128 = 9900; // 100% - 1.00% +const FEE_DENOMINATOR: u128 = 10000; + +pub fn compute_swap(side: u8, input: u128, rx: u128, ry: u128) -> u64 { + if rx == 0 || ry == 0 { return 0; } + let k = rx * ry; + + match side { + 0 => { // Buy X (input Y) + let net_y = input * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_ry = ry + net_y; + rx.saturating_sub((k + new_ry - 1) / new_ry) as u64 + } + 1 => { // Sell X (input X) + let net_x = input * FEE_NUMERATOR / FEE_DENOMINATOR; + let new_rx = rx + net_x; + ry.saturating_sub((k + new_rx - 1) / new_rx) as u64 + } + _ => 0, + } +}` + +/** + * Helper to create a constant-product compute_swap function with given fee + */ +function makeConstantProductSwap(feeNumerator: bigint, feeDenominator: bigint) { + return (input: PropComputeSwapInput): bigint => { + const { side, inputAmount, reserveX, reserveY } = input + + if (reserveX === 0n || reserveY === 0n) return 0n + + const k = reserveX * reserveY + + if (side === 0) { + // Buy X: input Y, output X + const netY = (inputAmount * feeNumerator) / feeDenominator + const newRY = reserveY + netY + const kDiv = (k + newRY - 1n) / newRY // ceil div + const output = reserveX - kDiv + return output > 0n ? output : 0n + } else { + // Sell X: input X, output Y + const netX = (inputAmount * feeNumerator) / feeDenominator + const newRX = reserveX + netX + const kDiv = (k + newRX - 1n) / newRX // ceil div + const output = reserveY - kDiv + return output > 0n ? output : 0n + } + } +} + +export const PROP_BUILTIN_STRATEGIES: PropBuiltinStrategy[] = [ + { + id: 'starter-500bps', + name: 'Starter (500 bps)', + code: STARTER_RUST_SOURCE, + feeBps: 500, + computeSwap: makeConstantProductSwap(950n, 1000n), // 5% fee = 950/1000 + }, + { + id: 'baseline-30bps', + name: 'Baseline (30 bps)', + code: BASELINE_30BPS_SOURCE, + feeBps: 30, + computeSwap: makeConstantProductSwap(9970n, 10000n), // 0.30% fee + }, + { + id: 'tight-10bps', + name: 'Tight (10 bps)', + code: TIGHT_10BPS_SOURCE, + feeBps: 10, + computeSwap: makeConstantProductSwap(9990n, 10000n), // 0.10% fee + }, + { + id: 'wide-100bps', + name: 'Wide (100 bps)', + code: WIDE_100BPS_SOURCE, + feeBps: 100, + computeSwap: makeConstantProductSwap(9900n, 10000n), // 1.00% fee + }, +] + +export function getPropBuiltinStrategyById(id: string): PropBuiltinStrategy | undefined { + return PROP_BUILTIN_STRATEGIES.find((s) => s.id === id) +} diff --git a/lib/sim/constants.ts b/lib/sim/constants.ts index 6bd4b03..4b5aff8 100644 --- a/lib/sim/constants.ts +++ b/lib/sim/constants.ts @@ -53,3 +53,48 @@ export const CHART_THEME: Record = { legendTrail: '#f1c8a5', }, } + +export const PROP_CHART_THEME: Record = { + light: { + grid: '#2a3446', + axis: '#495a74', + strategyCurve: '#a8c3ff', + normalizerCurve: '#3a475a', + trail: '#5f7190', + strategyDot: '#97b3ff', + strategyRing: '#25354d', + normalizerDotFill: '#4e617f', + normalizerDotStroke: '#89a0c7', + targetDot: '#5f789d', + arrowStrategy: '#adc8ff', + arrowOther: '#7789a6', + arrowHead: '#adc8ff', + labelMain: '#6a80a6', + labelSoft: '#516789', + axisLabel: '#8ea1c2', + legendStrategy: '#a8c3ff', + legendNormalizer: '#7a8eaf', + legendTrail: '#6b7fa1', + }, + dark: { + grid: '#263245', + axis: '#42556f', + strategyCurve: '#9db9ff', + normalizerCurve: '#334154', + trail: '#556a87', + strategyDot: '#8faeff', + strategyRing: '#22344d', + normalizerDotFill: '#465b7a', + normalizerDotStroke: '#7f98c1', + targetDot: '#58729a', + arrowStrategy: '#a9c2ff', + arrowOther: '#6f84a4', + arrowHead: '#a9c2ff', + labelMain: '#5f7398', + labelSoft: '#4d6286', + axisLabel: '#8698b9', + legendStrategy: '#9db9ff', + legendNormalizer: '#7488ab', + legendTrail: '#62779a', + }, +} diff --git a/workers/prop-simulation.worker.ts b/workers/prop-simulation.worker.ts new file mode 100644 index 0000000..bfe2891 --- /dev/null +++ b/workers/prop-simulation.worker.ts @@ -0,0 +1,240 @@ +import { PropSimulationEngine } from '../lib/prop-sim/engine' +import { PROP_SPEED_PROFILE, PROP_STORAGE_SIZE } from '../lib/prop-sim/constants' +import { fromScaledBigInt, toScaledBigInt } from '../lib/prop-sim/math' +import type { + PropActiveStrategyRuntime, + PropSimulationConfig, + PropStrategyCallbackContext, + PropStrategyRef, + PropWorkerUiState, +} from '../lib/prop-sim/types' +import { getPropBuiltinStrategyById, PROP_BUILTIN_STRATEGIES } from '../lib/prop-strategies/builtins' + +// ============================================================================ +// Worker State +// ============================================================================ + +let engine: PropSimulationEngine | null = null +let isPlaying = false +let playbackInterval: ReturnType | null = null +let rngSeed = 1337 + +// ============================================================================ +// PRNG (mulberry32) +// ============================================================================ + +function mulberry32(seed: number): () => number { + let s = seed >>> 0 + return () => { + s = (s + 0x6d2b79f5) >>> 0 + let t = s + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +let rng = mulberry32(rngSeed) + +function seedRng(seed: number): void { + rngSeed = seed + rng = mulberry32(seed) +} + +function randomBetween(min: number, max: number): number { + return min + rng() * (max - min) +} + +function gaussianRandom(): number { + let u = 0 + let v = 0 + while (u === 0) u = rng() + while (v === 0) v = rng() + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v) +} + +// ============================================================================ +// Strategy Runtime Factory +// ============================================================================ + +function createActiveStrategyRuntime(ref: PropStrategyRef): PropActiveStrategyRuntime { + if (ref.kind !== 'builtin') { + throw new Error('Only builtin strategies are supported') + } + + const builtin = getPropBuiltinStrategyById(ref.id) + if (!builtin) { + throw new Error(`Unknown builtin strategy: ${ref.id}`) + } + + return { + ref, + name: builtin.name, + code: builtin.code, + feeBps: builtin.feeBps, + computeSwap: (reserveX: number, reserveY: number, side: 0 | 1, inputAmount: number, storage: Uint8Array): number => { + const output = builtin.computeSwap({ + side, + inputAmount: toScaledBigInt(inputAmount), + reserveX: toScaledBigInt(reserveX), + reserveY: toScaledBigInt(reserveY), + storage, + }) + return fromScaledBigInt(output) + }, + afterSwap: (ctx: PropStrategyCallbackContext, storage: Uint8Array): Uint8Array => { + if (!builtin.afterSwap) { + return storage + } + return builtin.afterSwap({ + side: ctx.side, + inputAmount: toScaledBigInt(ctx.inputAmount), + outputAmount: toScaledBigInt(ctx.outputAmount), + reserveX: toScaledBigInt(ctx.reserveX), + reserveY: toScaledBigInt(ctx.reserveY), + step: BigInt(ctx.step), + storage, + }, storage) + }, + } +} + +// ============================================================================ +// Message Handlers +// ============================================================================ + +type WorkerMessage = + | { type: 'init'; config: PropSimulationConfig } + | { type: 'setConfig'; config: PropSimulationConfig } + | { type: 'setStrategy'; strategyRef: PropStrategyRef } + | { type: 'play' } + | { type: 'pause' } + | { type: 'step' } + | { type: 'reset' } + | { type: 'getState' } + +function handleMessage(msg: WorkerMessage): void { + switch (msg.type) { + case 'init': + handleInit(msg.config) + break + case 'setConfig': + handleSetConfig(msg.config) + break + case 'setStrategy': + handleSetStrategy(msg.strategyRef) + break + case 'play': + handlePlay() + break + case 'pause': + handlePause() + break + case 'step': + handleStep() + break + case 'reset': + handleReset() + break + case 'getState': + postState() + break + } +} + +function handleInit(config: PropSimulationConfig): void { + seedRng(config.seed) + const strategy = createActiveStrategyRuntime(config.strategyRef) + engine = new PropSimulationEngine(config, strategy) + engine.reset(randomBetween) + postState() +} + +function handleSetConfig(config: PropSimulationConfig): void { + if (!engine) return + engine.setConfig(config) + + // Update playback speed if playing + if (isPlaying && playbackInterval) { + clearInterval(playbackInterval) + const speed = PROP_SPEED_PROFILE[config.playbackSpeed] ?? PROP_SPEED_PROFILE[3] + playbackInterval = setInterval(tick, speed.ms) + } + + postState() +} + +function handleSetStrategy(strategyRef: PropStrategyRef): void { + if (!engine) return + const strategy = createActiveStrategyRuntime(strategyRef) + engine.setStrategy(strategy) + engine.reset(randomBetween) + postState() +} + +function handlePlay(): void { + if (!engine || isPlaying) return + isPlaying = true + + const config = engine['state'].config + const speed = PROP_SPEED_PROFILE[config.playbackSpeed] ?? PROP_SPEED_PROFILE[3] + playbackInterval = setInterval(tick, speed.ms) + + postState() +} + +function handlePause(): void { + isPlaying = false + if (playbackInterval) { + clearInterval(playbackInterval) + playbackInterval = null + } + postState() +} + +function handleStep(): void { + if (!engine) return + if (isPlaying) { + handlePause() + } + engine.stepOne(randomBetween, gaussianRandom) + postState() +} + +function handleReset(): void { + if (!engine) return + handlePause() + seedRng(engine['state'].config.seed) + engine.reset(randomBetween) + postState() +} + +function tick(): void { + if (!engine || !isPlaying) return + engine.stepOne(randomBetween, gaussianRandom) + postState() +} + +function postState(): void { + if (!engine) return + + const availableStrategies = PROP_BUILTIN_STRATEGIES.map((s) => ({ + kind: 'builtin' as const, + id: s.id, + name: s.name, + })) + + const state = engine.toUiState(availableStrategies, isPlaying) + self.postMessage({ type: 'state', state }) +} + +// ============================================================================ +// Worker Entry +// ============================================================================ + +self.onmessage = (event: MessageEvent) => { + handleMessage(event.data) +} + +// Signal ready +self.postMessage({ type: 'ready' })