From 7ee7d7117da86916da229b22de92f0ead2f12312 Mon Sep 17 00:00:00 2001 From: Cesare Date: Sun, 15 Feb 2026 16:57:47 +0000 Subject: [PATCH 1/7] feat: Add Prop AMM Challenge visualizer - Add /prop-amm route with new simulation engine - Implement golden-section arbitrage and order routing - Add 4 built-in strategies (500, 100, 30, 10 bps) - Create PropCodePanel and PropMarketPanel components - Add variable normalizer (30-80 bps, 0.4-2.0x liquidity) - Support wider volatility range (0.01-0.70% per step) - Track implied fees from trades instead of explicit fees --- app/prop-amm/page.tsx | 184 ++++++++++ components/HeaderActions.tsx | 13 +- components/PropCodePanel.tsx | 96 +++++ components/PropMarketPanel.tsx | 418 +++++++++++++++++++++ hooks/usePropSimulationWorker.ts | 120 ++++++ lib/prop-sim/constants.ts | 61 ++++ lib/prop-sim/engine.ts | 587 ++++++++++++++++++++++++++++++ lib/prop-sim/index.ts | 4 + lib/prop-sim/math.ts | 511 ++++++++++++++++++++++++++ lib/prop-sim/types.ts | 181 +++++++++ lib/prop-strategies/builtins.ts | 231 ++++++++++++ workers/prop-simulation.worker.ts | 240 ++++++++++++ 12 files changed, 2642 insertions(+), 4 deletions(-) create mode 100644 app/prop-amm/page.tsx create mode 100644 components/PropCodePanel.tsx create mode 100644 components/PropMarketPanel.tsx create mode 100644 hooks/usePropSimulationWorker.ts create mode 100644 lib/prop-sim/constants.ts create mode 100644 lib/prop-sim/engine.ts create mode 100644 lib/prop-sim/index.ts create mode 100644 lib/prop-sim/math.ts create mode 100644 lib/prop-sim/types.ts create mode 100644 lib/prop-strategies/builtins.ts create mode 100644 workers/prop-simulation.worker.ts diff --git a/app/prop-amm/page.tsx b/app/prop-amm/page.tsx new file mode 100644 index 0000000..f25218e --- /dev/null +++ b/app/prop-amm/page.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { HeaderActions } from '../../components/HeaderActions' +import { FooterLinks } from '../../components/FooterLinks' +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/HeaderActions.tsx b/components/HeaderActions.tsx index 5bfb80d..93e254c 100644 --- a/components/HeaderActions.tsx +++ b/components/HeaderActions.tsx @@ -5,6 +5,8 @@ import type { ThemeMode } from '../lib/sim/types' interface HeaderActionsProps { theme: ThemeMode onToggleTheme: () => void + subtitle?: string + subtitleLink?: string } function XIcon() { @@ -23,17 +25,20 @@ function GitHubIcon() { ) } -export function HeaderActions({ theme, onToggleTheme }: HeaderActionsProps) { +export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink }: HeaderActionsProps) { 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}

diff --git a/components/PropCodePanel.tsx b/components/PropCodePanel.tsx new file mode 100644 index 0000000..70dd348 --- /dev/null +++ b/components/PropCodePanel.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useMemo } from 'react' +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 lines = useMemo(() => code.split('\n'), [code]) + const highlightSet = useMemo(() => new Set(highlightedLines), [highlightedLines]) + + return ( +
+
+

Strategy Code (Rust)

+
+ + +
+
+ +
+
+          
+            {lines.map((line, i) => {
+              const lineNum = i + 1
+              const isHighlighted = highlightSet.has(lineNum)
+              return (
+                
+ {lineNum} + {line || ' '} +
+ ) + })} +
+
+
+ + {showExplanationOverlay ? ( +
+

What the code is doing

+

{codeExplanation}

+
+ Note: Prop AMM strategies define a custom compute_swap function that returns{' '} + output_amount directly, rather than just adjusting fees on a constant-product curve. +
+
+ ) : null} +
+ ) +} diff --git a/components/PropMarketPanel.tsx b/components/PropMarketPanel.tsx new file mode 100644 index 0000000..25e512a --- /dev/null +++ b/components/PropMarketPanel.tsx @@ -0,0 +1,418 @@ +'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} + /> +
+
+ +
+
+
+
+ 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..7ebba69 --- /dev/null +++ b/lib/prop-sim/engine.ts @@ -0,0 +1,587 @@ +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) + 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/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' }) From 3fdb7ea7e323c3ddce05d721a4c8c871e441ff98 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sun, 15 Feb 2026 18:11:44 -0500 Subject: [PATCH 2/7] feat: implement prop amm visualizer runtime and themed chart --- PROP-AMM-VISUALIZER-SPEC.md | 655 +++++++++++----------- app/globals.css | 118 ++++ app/page.tsx | 1 + app/prop-amm/page.tsx | 146 ++--- components/HeaderActions.tsx | 11 +- components/PropCodePanel.tsx | 96 ---- components/PropMarketPanel.tsx | 418 -------------- components/prop/PropAmmChart.tsx | 304 ++++++++++ components/prop/PropCodePanel.tsx | 130 +++++ components/prop/PropMarketPanel.tsx | 250 +++++++++ hooks/usePropSimulationWorker.ts | 166 +++--- lib/prop-sim/amm.ts | 194 +++++++ lib/prop-sim/arbitrage.ts | 291 ++++++++++ lib/prop-sim/constants.ts | 76 ++- lib/prop-sim/engine.ts | 775 +++++++++++++++----------- lib/prop-sim/index.ts | 10 +- lib/prop-sim/math.ts | 511 ----------------- lib/prop-sim/nano.ts | 77 +++ lib/prop-sim/priceProcess.ts | 22 + lib/prop-sim/retail.ts | 49 ++ lib/prop-sim/rng.ts | 25 + lib/prop-sim/router.ts | 172 ++++++ lib/prop-sim/types.ts | 175 +++--- lib/prop-strategies/builtins.ts | 279 +++------- lib/prop-strategies/starterSource.ts | 94 ++++ store/usePropPlaybackStore.ts | 18 + store/usePropUiStore.ts | 54 ++ tests/prop-engine-determinism.test.ts | 80 +++ tests/prop-nano.test.ts | 35 ++ tests/prop-starter-runtime.test.ts | 74 +++ workers/prop-messages.ts | 41 ++ workers/prop-simulation.worker.ts | 383 ++++++------- 32 files changed, 3354 insertions(+), 2376 deletions(-) delete mode 100644 components/PropCodePanel.tsx delete mode 100644 components/PropMarketPanel.tsx create mode 100644 components/prop/PropAmmChart.tsx create mode 100644 components/prop/PropCodePanel.tsx create mode 100644 components/prop/PropMarketPanel.tsx create mode 100644 lib/prop-sim/amm.ts create mode 100644 lib/prop-sim/arbitrage.ts delete mode 100644 lib/prop-sim/math.ts create mode 100644 lib/prop-sim/nano.ts create mode 100644 lib/prop-sim/priceProcess.ts create mode 100644 lib/prop-sim/retail.ts create mode 100644 lib/prop-sim/rng.ts create mode 100644 lib/prop-sim/router.ts create mode 100644 lib/prop-strategies/starterSource.ts create mode 100644 store/usePropPlaybackStore.ts create mode 100644 store/usePropUiStore.ts create mode 100644 tests/prop-engine-determinism.test.ts create mode 100644 tests/prop-nano.test.ts create mode 100644 tests/prop-starter-runtime.test.ts create mode 100644 workers/prop-messages.ts diff --git a/PROP-AMM-VISUALIZER-SPEC.md b/PROP-AMM-VISUALIZER-SPEC.md index 2f73163..d955889 100644 --- a/PROP-AMM-VISUALIZER-SPEC.md +++ b/PROP-AMM-VISUALIZER-SPEC.md @@ -1,403 +1,422 @@ # Prop AMM Visualizer Spec -**Author:** Cesare +**Author:** Codex **Date:** 2026-02-15 -**Status:** Draft +**Status:** Definitive implementation plan (starter strategy section) --- -## Overview +## 1. Objective -This document specifies a new section of the AMM Visualizer to support the **Prop AMM Challenge** — a custom price function competition using Rust/Solana-style programs. Unlike the original AMM Challenge (dynamic fees on constant-product), Prop AMM lets participants define the *entire* output calculation for swaps. +Add a new section to the AMM Visualizer for the **Prop AMM Challenge** that lets users step through the **built-in starter strategy** under the real Prop AMM simulation mechanics. -**Goal:** Enable visual debugging and intuition-building for Prop AMM strategies, mirroring the same step-through experience the current visualizer provides for Solidity fee strategies. +Primary outcome: + +- A `/prop-amm` route with side-by-side strategy code + market simulation. +- Behavior aligned with `prop-amm-challenge` runtime semantics (not approximate EVM-fee semantics). +- MVP limited to built-in starter strategy + normalizer (no custom Rust compilation in browser). + +--- + +## 2. Source of Truth + +This plan is grounded in: + +- `https://github.com/benedictbrady/prop-amm-challenge` (README + runtime crates) +- `https://github.com/muttoni/ammvisualizer/blob/main/PROP-AMM-VISUALIZER-SPEC.md` (draft to sense-check and refine) + +Critical mechanics from the challenge codebase: + +- 10,000 simulation steps per run. +- Per simulation, sample: + - `gbm_sigma ~ U[0.0001, 0.007]` + - `retail_arrival_rate ~ U[0.4, 1.2]` + - `retail_mean_size ~ U[12, 28]` + - `norm_fee_bps ~ U{30..80}` + - `norm_liquidity_mult ~ U[0.4, 2.0]` +- Step order: + 1. Fair price GBM update + 2. Arbitrage on submission AMM + 3. Arbitrage on normalizer AMM + 4. Poisson retail arrivals routed across both AMMs +- Starter strategy: + - Constant product + - 5% fee (`FEE_NUMERATOR=950`, `FEE_DENOMINATOR=1000`) + - `after_swap` no-op +- Instruction model: + - `compute_swap` payload includes 1024-byte read-only storage + - `after_swap` payload includes post-trade reserves + step + mutable storage +- Arithmetic path: + - Quotes/execution use `u64` nano units (1e9 scaling), then convert back to `f64` for simulator reserves. --- -## Key Differences from Original Challenge +## 3. Scope -| Aspect | Original AMM Challenge | Prop AMM Challenge | -|--------|------------------------|-------------------| -| **Language** | Solidity | Rust | -| **Core Interface** | `afterSwap() → (bidFee, askFee)` | `compute_swap() → output_amount` | -| **Pricing Model** | Constant-product + dynamic fees | Custom price function (any curve) | -| **Storage** | 32 uint256 slots | 1024-byte buffer | -| **Normalizer** | Fixed 30 bps | Variable: 30-80 bps fee, 0.4-2.0x liquidity | -| **Volatility** | σ ~ U[0.088%, 0.101%] | σ ~ U[0.01%, 0.70%] | -| **Arbitrage** | Closed-form optimal | Golden-section search | -| **Requirements** | None | Monotonic, concave, <100k CU | +### In scope (MVP) + +- New `/prop-amm` section. +- Built-in starter strategy visualization. +- Normalizer with sampled fee/liquidity regime. +- Trade-by-trade playback controls (play/pause/step/reset). +- Trade tape with edge deltas and flow type. +- Prop-specific metrics panel. +- Read-only Rust code panel for starter strategy. +- Storage panel (1024-byte view), even if unchanged for starter. + +### Out of scope (deferred) + +- User-authored Rust strategy upload/compile/execute. +- Browser-side Rust toolchain/WASM compiler integration. +- Full BPF runtime emulation in browser. +- Leaderboard submission flow. --- -## Architecture Changes +## 4. Sense Check vs Existing Draft Spec -### 1. New Route Structure +The referenced draft is directionally correct. The following changes are required for parity and clarity: -``` -/ → Original AMM Challenge Visualizer (existing) -/prop-amm → Prop AMM Challenge Visualizer (new) -``` +1. Event order must match Rust engine exactly: `price -> submission arb -> normalizer arb -> retail`. +2. Normalizer fee handling must match native normalizer path: fee read from storage bytes `[0..2]` and initialized from sampled `norm_fee_bps`. +3. Runtime math must preserve nano-unit conversion and integer rounding behavior (`u64`, ceil-div) before reserve updates. +4. `after_swap` must run only after executed trades, never during quote search. +5. Arbitrage logic is asymmetric: + - submission side uses bracket + golden search over quote surface + - normalizer side uses closed-form candidate sizing then quote check +6. MVP built-ins should be starter-first. Additional synthetic curves are optional future work, not baseline requirements. +7. Storage support is still required in UI and engine even if starter does not mutate storage. + +--- + +## 5. Product Design + +### Routes and navigation + +- Keep current page unchanged at `/`. +- Add new page at `/prop-amm`. +- Add explicit navigation in header between `AMM Challenge` and `Prop AMM`. + +### Page layout + +- Keep the same two-panel mental model: + - left: code panel + - right: market panel with chart, metrics, trade tape +- Reuse existing shell/theming styles where possible. + +### Code panel (Prop) + +- Read-only Rust source for starter strategy. +- Rust syntax highlighting. +- No compile/edit actions in MVP. +- "What this code is doing" panel with deterministic explanation templates for: + - buy branch + - sell branch + - invalid side / zero reserve fallback +- Metadata strip: + - strategy name + - model-used string from source + - storage usage: `No-op` for starter + +### Market panel (Prop) + +Displayed metrics: + +- Step index and trade count +- Fair price +- Submission spot price +- Normalizer spot price +- Submission cumulative edge: + - total + - retail component + - arbitrage component +- Sampled regime (fixed for simulation): + - sigma + - retail arrival rate + - retail mean size + - normalizer fee bps + - normalizer liquidity multiplier +- Storage summary: + - changed byte count + - last write step + +Trade tape row fields: + +- Flow: `system | arbitrage | retail` +- Pool: `submission | normalizer` +- Direction: AMM buys X vs sells X +- Input/output amounts (human + nano) +- Fair price at execution +- Edge delta (submission trades only) +- Router split context for retail events (submission share) + +Chart behavior: + +- Show both AMM reserve states and reserve trails. +- Show fair price target point for each pool. +- Keep existing curve visuals for starter/normalizer (hyperbolic), but structure chart API to allow sampled custom curves later. + +--- + +## 6. Technical Architecture + +### New files -Both share layout chrome (header, footer, theme) but have separate simulation engines and strategy systems. +```text +app/prop-amm/page.tsx +hooks/usePropSimulationWorker.ts +workers/prop-simulation.worker.ts +workers/prop-messages.ts -### 2. New Module Structure +components/prop/PropCodePanel.tsx +components/prop/PropMarketPanel.tsx +components/prop/PropAmmChart.tsx +lib/prop-sim/constants.ts +lib/prop-sim/types.ts +lib/prop-sim/nano.ts +lib/prop-sim/rng.ts +lib/prop-sim/priceProcess.ts +lib/prop-sim/amm.ts +lib/prop-sim/arbitrage.ts +lib/prop-sim/router.ts +lib/prop-sim/retail.ts +lib/prop-sim/engine.ts + +lib/prop-strategies/builtins.ts +lib/prop-strategies/starterSource.ts + +store/usePropUiStore.ts +store/usePropPlaybackStore.ts ``` -lib/ -├── sim/ # Original engine (unchanged) -│ ├── engine.ts -│ ├── math.ts -│ ├── types.ts -│ └── ... -└── prop-sim/ # NEW: Prop AMM engine - ├── engine.ts # PropSimulationEngine class - ├── math.ts # Custom curve math, golden-section search - ├── types.ts # PropAmmState, PropSnapshot, etc. - ├── constants.ts # Parameter ranges, defaults - ├── normalizer.ts # Variable normalizer logic - └── arbitrage.ts # Golden-section arb solver - -workers/ -├── simulation.worker.ts # Original worker (unchanged) -└── prop-simulation.worker.ts # NEW: Prop AMM worker - -components/ -├── MarketPanel.tsx # Shared (parameterized) -├── CodePanel.tsx # Shared (language-aware) -├── PropCodePanel.tsx # NEW: Rust-specific code display -└── PropMarketPanel.tsx # NEW: Prop-specific metrics - -lib/ -└── prop-strategies/ - ├── builtins.ts # Built-in Rust strategy definitions - └── starter.rs # Embedded starter strategy source + +### Existing files to modify + +```text +components/HeaderActions.tsx (add nav link) +app/globals.css (prop panel classes) ``` -### 3. Simulation Engine (PropSimulationEngine) +### Deliberate isolation -#### State Shape +- Do not modify `lib/sim/*`, `workers/simulation.worker.ts`, or existing EVM strategy runtime. +- Prop simulator and worker remain independent to reduce regression risk. -```typescript -interface PropAmmState { - name: string - reserveX: number // 1e9 scale internally - reserveY: number - isStrategy: boolean - // No explicit fees — pricing determined by compute_swap +--- + +## 7. Data Model + +```ts +type PropFlowType = 'system' | 'arbitrage' | 'retail' + +interface PropSimulationConfig { + seed: number + playbackSpeed: number + maxTapeRows: number + nSteps: number // default 10_000 } -interface PropNormalizerConfig { - feeBps: number // Sampled per simulation: U{30..80} - liquidityMult: number // Sampled per simulation: U[0.4, 2.0] +interface PropSampledRegime { + gbmSigma: number + retailArrivalRate: number + retailMeanSize: number + normFeeBps: number + normLiquidityMult: number +} + +interface PropAmmState { + name: 'submission' | 'normalizer' + reserveX: number + reserveY: number + storage: Uint8Array // 1024 bytes } interface PropSnapshot { step: number fairPrice: number - strategy: { - x: number - y: number - k: number // Effective k for reference - impliedBid: number // Back-calculated from last trade - impliedAsk: number - } - normalizer: { - x: number - y: number - k: number - feeBps: number - liquidityMult: number - } - edge: { - total: number - retail: number - arb: number - } + submission: { x: number; y: number; spot: number } + normalizer: { x: number; y: number; spot: number } + edge: { total: number; retail: number; arb: number } + regime: PropSampledRegime } -``` -#### Parameter Ranges (from spec) - -```typescript -const PROP_PARAMS = { - // Price process - volatility: { min: 0.0001, max: 0.007 }, // 0.01% to 0.70% per step - - // Retail flow - arrivalRate: { min: 0.4, max: 1.2 }, - orderSizeMean: { min: 12, max: 28 }, // Y terms - - // Normalizer - normalizerFee: { min: 30, max: 80 }, // bps, integer - normalizerLiquidity: { min: 0.4, max: 2.0 }, - - // Initial reserves - initialX: 100, - initialY: 10_000, - initialPrice: 100, - - // Arbitrage thresholds - arbMinProfit: 0.01, // Y units - arbBracketTolerance: 0.01, // 1% relative +interface PropTradeEvent { + id: number + step: number + flow: PropFlowType + amm: 'submission' | 'normalizer' + side: 'buy_x' | 'sell_x' + inputAmount: number + outputAmount: number + fairPrice: number + edgeDelta: number + codeLines: number[] + codeExplanation: string + storageChangedBytes: number + snapshot: PropSnapshot } ``` -### 4. Custom Price Function Interface +--- -Instead of returning fees, Prop AMM strategies return `output_amount` directly: +## 8. Runtime Semantics (must match challenge behavior) -```typescript -interface PropComputeSwapInput { - side: 0 | 1 // 0 = buy X (Y in), 1 = sell X (X in) - inputAmount: bigint // 1e9 scale - reserveX: bigint - reserveY: bigint - storage: Uint8Array // 1024 bytes, read-only during quote -} +### Swap interface semantics -interface PropComputeSwapOutput { - outputAmount: bigint // 1e9 scale -} +- `side=0`: buy X with Y input. +- `side=1`: sell X for Y output. +- Strategy receives reserves + storage in nano-unit instruction payload. +- Return `u64` output amount in nano units. -interface PropAfterSwapInput { - tag: 2 - side: 0 | 1 - inputAmount: bigint - outputAmount: bigint - reserveX: bigint // Post-trade - reserveY: bigint - step: bigint - storage: Uint8Array // 1024 bytes, read/write -} -``` +### Starter strategy implementation -### 5. Built-in Strategies - -The visualizer will include TypeScript implementations that mirror the behavior of Rust starter strategies: - -```typescript -// lib/prop-strategies/builtins.ts - -export const PROP_BUILTIN_STRATEGIES: PropBuiltinStrategy[] = [ - { - id: 'starter-500bps', - name: 'Starter (500 bps)', - code: STARTER_RUST_SOURCE, - computeSwap: (input) => { - // Constant-product with 5% fee (500 bps) - const FEE_NUM = 950n - const FEE_DENOM = 1000n - const k = input.reserveX * input.reserveY - - if (input.side === 0) { - // Buy X: input Y, output X - const netY = (input.inputAmount * FEE_NUM) / FEE_DENOM - const newY = input.reserveY + netY - const newX = (k + newY - 1n) / newY // ceil div - return { outputAmount: input.reserveX - newX } - } else { - // Sell X: input X, output Y - const netX = (input.inputAmount * FEE_NUM) / FEE_DENOM - const newX = input.reserveX + netX - const newY = (k + newX - 1n) / newX - return { outputAmount: input.reserveY - newY } - } - }, - afterSwap: (input, storage) => { - // No-op for starter - return storage - }, - }, - { - id: 'constant-product-30bps', - name: 'Constant Product (30 bps)', - // ... similar implementation with 30 bps - }, - { - id: 'linear-invariant', - name: 'Linear Invariant (Stable)', - // ... x + y = k style pricing - }, -] -``` +- Implement TypeScript mirror of `programs/starter/src/lib.rs`. +- Preserve integer path: + - `k = reserve_x * reserve_y` + - `net = input * 950 / 1000` + - `new reserve` via ceil division + - saturating subtraction behavior -### 6. Golden-Section Arbitrage Solver +### Normalizer implementation -Unlike the original closed-form arb calculation, Prop AMM uses golden-section search: +- Implement TypeScript mirror of `crates/shared/src/normalizer.rs`. +- Fee bps read from storage bytes `[0..2]`, fallback to 30 if zero. +- Initialize normalizer storage with sampled `norm_fee_bps` LE bytes. -```typescript -// lib/prop-sim/arbitrage.ts +### after_swap behavior -interface ArbResult { - side: 'buy' | 'sell' - inputAmount: number - expectedProfit: number -} +- Called after every executed trade on each AMM. +- Not called during arbitrage/router quote evaluations. +- Starter `after_swap` is no-op, but engine must support future storage updates. -export function findPropArbOpportunity( - amm: PropAmmState, - fairPrice: number, - computeSwap: (side: 0 | 1, input: bigint) => bigint, - minProfit: number = 0.01, - tolerance: number = 0.01, -): ArbResult | null { - // Golden-section search for optimal trade size - // Early-stop when bracket width < tolerance - // Skip if expected profit < minProfit - - const PHI = (1 + Math.sqrt(5)) / 2 - // ... implementation -} -``` - -### 7. Order Routing with Golden-Section - -```typescript -// lib/prop-sim/math.ts - -export function routeRetailOrderProp( - strategy: PropAmmState, - normalizer: PropAmmState, - strategyQuote: (side: 0 | 1, input: bigint) => bigint, - normalizerQuote: (side: 0 | 1, input: bigint) => bigint, - order: { side: 'buy' | 'sell'; sizeY: number }, - tolerance: number = 0.01, -): Array<[PropAmmState, number]> { - // Golden-section search over split ratio α ∈ [0, 1] - // Maximize total output - // Early-stop when submission trade < 1% bracket or 1% objective gap -} -``` +### Arbitrage behavior ---- +- Submission AMM: + - evaluate both sides via quote functions + - bracket maximum with growth 2.0 up to 24 steps + - golden search up to 12 iterations + - stop when input bracket width <= 1% relative +- Normalizer AMM: + - closed-form candidate sizing per side using fee-adjusted CP formulas + - evaluate both sides, execute better profitable candidate +- Global thresholds: + - minimum arb profit: `0.01 Y` + - minimum arb notional floor: `0.01 Y` -## UI Changes +### Retail routing behavior -### 1. New Page: `/prop-amm` +- Sample `n ~ Poisson(lambda)` orders per step. +- Each order size from log-normal with sampled mean and fixed sigma. +- Buy/sell side is Bernoulli `p=0.5`. +- Router split uses golden-section search over alpha in `[0,1]`: + - max 14 iterations + - alpha tolerance 1e-3 + - submission amount rel tolerance 1e-2 + - objective gap early-stop tolerance 1e-2 -``` -app/ -├── page.tsx # Original (unchanged) -└── prop-amm/ - └── page.tsx # Prop AMM visualizer -``` +### Edge accounting -### 2. Code Panel Differences +For submission trades only: -| Feature | Original | Prop AMM | -|---------|----------|----------| -| Language | Solidity | Rust | -| Syntax highlighting | solidity | rust | -| Line explanation | Fee return values | Output amount calculation | -| Storage display | slots[0..31] | 1024-byte hex view | -| Compiler | In-browser solc | N/A (builtins only for MVP) | +- AMM buys X (sell-X flow): `edge = amount_x * fair_price - amount_y` +- AMM sells X (buy-X flow): `edge = amount_y - amount_x * fair_price` -**MVP Scope:** For the initial release, only built-in strategies are supported. Custom Rust compilation would require WASM tooling and is deferred to a future iteration. +Total edge is cumulative sum; also track `retail` and `arb` components separately. -### 3. Market Panel Differences +--- -| Metric | Original | Prop AMM | -|--------|----------|----------| -| "Strategy Fees" | bid/ask bps | Implied bid/ask (back-calculated) | -| "Slot[0] Fee" | Direct read | Storage byte view | -| Normalizer info | Fixed 30/30 bps | Variable fee + liquidity mult | -| Curve shape | Always hyperbolic | Varies by strategy | +## 9. UI/Worker Contract -**New Metrics for Prop AMM:** +`workers/prop-messages.ts` should mirror current architecture with Prop-specific payloads: -``` -┌─────────────────────────────────────────────────────────────┐ -│ Fair Price: 101.234 Y/X │ Strategy Spot: 100.891 Y/X │ -│ Implied Fees: ~47/52 bps │ Normalizer: 45 bps @ 1.3x liq │ -│ Curve Type: Concave ✓ │ Monotonic ✓ │ -│ Cumulative Edge: +12.34 (retail +45.67, arb -33.33) │ -└─────────────────────────────────────────────────────────────┘ -``` +- inbound: + - `INIT_PROP_SIM` + - `SET_PROP_CONFIG` + - `STEP_PROP_ONE` + - `PLAY_PROP` + - `PAUSE_PROP` + - `RESET_PROP` +- outbound: + - `PROP_STATE` + - `PROP_ERROR` -### 4. Chart Adaptations +No compile/library message types in MVP. -The reserve curve chart needs to handle non-hyperbolic curves: +--- -- **Current:** Draws `xy = k` hyperbola -- **Prop AMM:** Sample the actual pricing function to draw effective curve +## 10. Testing and Validation -```typescript -function samplePriceCurve( - computeSwap: (side: 0 | 1, input: bigint) => bigint, - reserveX: number, - reserveY: number, - steps: number = 50, -): Array<{ x: number; y: number }> { - // Sample buy/sell at various sizes to trace the effective curve -} -``` +### Unit tests -### 5. Trade Tape Differences +- Nano conversion helpers (`toNano`, `fromNano`, saturating bounds). +- Starter `computeSwap` parity cases against Rust logic. +- Normalizer dynamic fee decoding from storage. +- Arbitrage threshold behavior (`min_arb_profit`, notional floor). +- Router split behavior and early-stop criteria. -Add normalizer config display per simulation: +### Integration tests -``` -┌──────────────────────────────────────────────────────────────┐ -│ [System] Simulation started │ -│ Normalizer config: 45 bps fee, 1.32x liquidity │ -│ Volatility regime: 0.34% per step │ -├──────────────────────────────────────────────────────────────┤ -│ [Arb] t=1 | Strategy: sold 0.234 X for 23.12 Y │ -│ fair=101.2, implied spread ~48 bps | edge delta: -0.12 │ -└──────────────────────────────────────────────────────────────┘ -``` +- Deterministic seed replay snapshots for: + - first N events + - cumulative edge + - regime sampling +- Validate invariants on each event: + - reserves remain finite and positive + - spot price finite + - no negative outputs + - after_swap called only after execution ---- +### Manual acceptance checklist -## Implementation Phases +- `/prop-amm` loads with starter code and active simulation controls. +- Initial system event displays sampled regime and initial reserves. +- Stepping produces arbitrage and retail events with sensible deltas. +- Trade tape and metrics remain consistent after reset. +- Existing `/` route remains unchanged. -### Phase 1: Core Engine (Week 1) +--- -- [ ] Create `lib/prop-sim/` module structure -- [ ] Implement `PropSimulationEngine` class -- [ ] Implement golden-section arbitrage solver -- [ ] Implement golden-section order router -- [ ] Add 3 built-in strategies (starter, 30bps, linear) -- [ ] Create `prop-simulation.worker.ts` +## 11. Implementation Plan -### Phase 2: UI Integration (Week 2) +### Phase 1: Simulation core -- [ ] Create `/prop-amm/page.tsx` route -- [ ] Adapt `CodePanel` for Rust syntax -- [ ] Create `PropMarketPanel` with new metrics -- [ ] Update chart to sample custom curves -- [ ] Add normalizer config display +- Build `lib/prop-sim/*` engine and math modules. +- Implement starter and normalizer runtime mirrors. +- Implement Prop worker and hook. +- Add engine-level tests for parity-critical behavior. -### Phase 3: Polish & Testing (Week 3) +### Phase 2: Prop UI -- [ ] Add strategy explanation system for Prop -- [ ] Cross-check edge calculation against reference -- [ ] Add curve shape validation display -- [ ] Performance optimization -- [ ] Documentation +- Add `/prop-amm` page. +- Build `PropCodePanel`, `PropMarketPanel`, and chart component. +- Wire playback controls and tape rendering. +- Add header navigation and styling. -### Future (Deferred) +### Phase 3: QA and stabilization -- Custom Rust strategy compilation (WASM toolchain) -- Side-by-side comparison mode -- Export simulation traces +- Add deterministic integration fixtures. +- Verify no regression in existing EVM visualizer path. +- Tune rendering performance for long simulations. +- Final doc pass in README and this spec. --- -## Open Questions - -1. **WASM Compilation:** Should we support custom Rust strategies via in-browser compilation? This requires bundling Rust/WASM tooling and significantly increases complexity. Recommend deferring to v2. +## 12. Risks and Mitigations -2. **Shared vs Separate Workers:** Should Prop AMM share the simulation worker with original, or use a completely separate worker? Recommend separate for clarity. +- Risk: drift from Rust semantics due numeric differences. + Mitigation: enforce nano/int-first quote path + fixture-based parity tests. -3. **Curve Visualization:** For non-constant-product curves, how many sample points are needed for smooth visualization? Recommend 50-100 points with adaptive sampling near current reserves. +- Risk: UI complexity from adding second simulator mode. + Mitigation: strict module isolation and route-level separation. -4. **Storage View:** How to display 1024 bytes usefully? Recommend collapsible hex view with "changed bytes" highlighting. +- Risk: storage view adds complexity without starter value. + Mitigation: keep minimal, read-only summary in MVP and expand later. --- -## References +## 13. Deferred Extensions -- [Prop AMM Challenge Spec](https://github.com/benedictbrady/prop-amm-challenge) -- [Original AMM Challenge](https://github.com/benedictbrady/amm-challenge) -- [Current Visualizer Repo](https://github.com/muttoni/ammvisualizer) +- Built-in adaptive storage strategy for demonstrating `after_swap`. +- Upload custom `lib.rs` and run in remote sandbox. +- Optional BPF parity mode badge in UI. +- Batch score distribution panel (1,000-sim summary). diff --git a/app/globals.css b/app/globals.css index 3891937..95c8581 100644 --- a/app/globals.css +++ b/app/globals.css @@ -74,6 +74,14 @@ body { opacity: 0.42; } +.prop-backdrop { + background-image: + linear-gradient(to right, rgba(54, 76, 108, 0.26) 1px, transparent 1px), + linear-gradient(to bottom, rgba(54, 76, 108, 0.2) 1px, transparent 1px); + background-size: 52px 52px; + opacity: 0.33; +} + h1, h2, h3, @@ -119,6 +127,49 @@ h3 { overflow: hidden; } +.prop-app-shell { + background: + radial-gradient(circle at 20% -24%, rgba(34, 56, 86, 0.95) 0%, rgba(18, 29, 45, 0.95) 46%, rgba(10, 19, 31, 0.98) 100%); +} + +.prop-app-shell .topbar p, +.prop-app-shell .clock, +.prop-app-shell .metric-card span, +.prop-app-shell .depth-legend, +.prop-app-shell .trade-column-head span { + color: #8194b3; +} + +.prop-app-shell .topbar p a, +.prop-app-shell .challenge-link.active, +.prop-app-shell .speed-inner strong { + color: #9bb1d8; +} + +.prop-app-shell .layout, +.prop-app-shell .code-panel, +.prop-app-shell .market-panel, +.prop-app-shell .panel-head, +.prop-app-shell .market-controls, +.prop-app-shell .chart-wrap, +.prop-app-shell .metrics-panel, +.prop-app-shell .trade-column { + border-color: rgba(72, 96, 130, 0.5); +} + +.prop-app-shell .metric-card, +.prop-app-shell .trade-row, +.prop-app-shell .code-line { + background: rgba(13, 23, 38, 0.66); + border-color: rgba(60, 84, 116, 0.56); +} + +.prop-app-shell .metric-card strong, +.prop-app-shell .trade-text, +.prop-app-shell .trade-edge { + color: #a3b7d8; +} + .topbar { display: flex; align-items: flex-end; @@ -148,6 +199,36 @@ h3 { border-bottom-color: var(--accent-soft); } +.challenge-nav { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; +} + +.challenge-link { + border: 1px solid var(--line); + border-radius: 7px; + background: linear-gradient(180deg, var(--panel-strong) 0%, var(--panel-soft) 100%); + color: var(--ink-soft); + font-family: 'Space Mono', 'Courier New', monospace; + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + padding: 5px 8px; +} + +.challenge-link:hover { + color: var(--accent); + border-color: var(--accent-soft); +} + +.challenge-link.active { + color: var(--accent); + border-color: var(--accent-soft); +} + .top-actions { display: flex; align-items: center; @@ -357,6 +438,17 @@ h3 { font-size: 0.96rem; } +.prop-meta-row { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + color: var(--ink-soft); + font-size: 0.72rem; + font-family: 'Space Mono', 'Courier New', monospace; + letter-spacing: 0.03em; + text-transform: uppercase; +} + .strategy-picker select, .editor-field input, .editor-field textarea, @@ -948,6 +1040,28 @@ h3 { background: linear-gradient(180deg, var(--chart-bg-top) 0%, var(--chart-bg-bottom) 100%); } +.prop-chart-wrap { + border-bottom: 1px solid rgba(69, 92, 126, 0.46); + padding: 7px 8px; + background: transparent; +} + +.prop-chart-host { + height: 100%; +} + +#propCurveChart { + display: block; + width: 100%; + height: 100%; + min-height: 0; + border-radius: 14px; + border: 1px solid rgba(69, 92, 126, 0.55); + box-shadow: + inset 0 0 0 1px rgba(34, 51, 74, 0.4), + 0 10px 20px rgba(3, 8, 15, 0.35); +} + .market-bottom { flex: 0 0 auto; display: grid; @@ -1437,6 +1551,10 @@ h3 { min-height: 220px; } + #propCurveChart { + min-height: 220px; + } + .trade-tape { max-height: 34svh; overflow: auto; diff --git a/app/page.tsx b/app/page.tsx index 2b44bc8..9e4413c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -176,6 +176,7 @@ export default function Page() { setTheme(theme === 'dark' ? 'light' : 'dark')} + currentView="amm" /> {workerError ?
    Worker error: {workerError}
    : null} diff --git a/app/prop-amm/page.tsx b/app/prop-amm/page.tsx index f25218e..8ee8d67 100644 --- a/app/prop-amm/page.tsx +++ b/app/prop-amm/page.tsx @@ -1,52 +1,67 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo } from 'react' import { HeaderActions } from '../../components/HeaderActions' -import { FooterLinks } from '../../components/FooterLinks' -import { PropCodePanel } from '../../components/PropCodePanel' -import { PropMarketPanel } from '../../components/PropMarketPanel' +import { PropCodePanel } from '../../components/prop/PropCodePanel' +import { PropMarketPanel } from '../../components/prop/PropMarketPanel' +import { PROP_DEFAULT_STEPS } from '../../lib/prop-sim/constants' +import type { PropSimulationConfig, PropStrategyRef, PropWorkerUiState } from '../../lib/prop-sim/types' +import { PROP_BUILTIN_STRATEGIES, getPropBuiltinStrategy } from '../../lib/prop-strategies/builtins' import { usePropSimulationWorker } from '../../hooks/usePropSimulationWorker' +import { usePropUiStore } from '../../store/usePropUiStore' 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] +function buildFallbackUiState(strategyRef: PropStrategyRef, config: Omit): PropWorkerUiState { + const requested = PROP_BUILTIN_STRATEGIES.find((strategy) => strategy.id === strategyRef.id) + const selected = requested ?? PROP_BUILTIN_STRATEGIES[0] + const runtime = getPropBuiltinStrategy({ kind: 'builtin', id: selected.id }) const snapshot: PropWorkerUiState['snapshot'] = { step: 0, fairPrice: 100, - strategy: { + submission: { x: 100, y: 10_000, + spot: 100, k: 1_000_000, - impliedBidBps: builtin.feeBps, - impliedAskBps: builtin.feeBps, }, normalizer: { x: 100, y: 10_000, + spot: 100, k: 1_000_000, feeBps: 30, - liquidityMult: 1.0, + liquidityMult: 1, + }, + edge: { + total: 0, + retail: 0, + arb: 0, + }, + regime: { + gbmSigma: 0.001, + retailArrivalRate: 0.8, + retailMeanSize: 20, + normFeeBps: 30, + normLiquidityMult: 1, + }, + storage: { + lastChangedBytes: 0, + lastWriteStep: null, }, - 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, + ...config, + strategyRef: runtime.ref, }, currentStrategy: { - kind: 'builtin', - id: builtin.id, - name: builtin.name, - code: builtin.code, - feeBps: builtin.feeBps, + kind: runtime.ref.kind, + id: runtime.ref.id, + name: runtime.name, + code: runtime.code, + modelUsed: runtime.modelUsed, }, isPlaying: false, tradeCount: 0, @@ -55,60 +70,52 @@ function buildFallbackUiState(strategyRef: PropStrategyRef, playbackSpeed: numbe id: 0, step: 0, flow: 'system', - ammName: builtin.name, - isStrategyTrade: false, + pool: 'submission', + poolName: 'Submission', + isSubmissionTrade: false, trade: null, order: null, + routerSplit: 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.', + codeLines: [66, 67], + codeExplanation: 'Simulation worker is initializing in the background.', + stateBadge: 'storage Δ=0 bytes | last write: n/a', + summary: 'Simulation initialized.', + storageChangedBytes: 0, 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 }, + availableStrategies: PROP_BUILTIN_STRATEGIES, } } 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({ + const playbackSpeed = usePropUiStore((state) => state.playbackSpeed) + const maxTapeRows = usePropUiStore((state) => state.maxTapeRows) + const nSteps = usePropUiStore((state) => state.nSteps) + const strategyRef = usePropUiStore((state) => state.strategyRef) + const showCodeExplanation = usePropUiStore((state) => state.showCodeExplanation) + const chartAutoZoom = usePropUiStore((state) => state.chartAutoZoom) + + const setPlaybackSpeed = usePropUiStore((state) => state.setPlaybackSpeed) + const setStrategyRef = usePropUiStore((state) => state.setStrategyRef) + const setShowCodeExplanation = usePropUiStore((state) => state.setShowCodeExplanation) + const setChartAutoZoom = usePropUiStore((state) => state.setChartAutoZoom) + + const { ready, workerState, workerError, controls } = usePropSimulationWorker({ seed: 1337, playbackSpeed, maxTapeRows, - strategyRef: propStrategyRef, + nSteps, + strategyRef, }) useEffect(() => { @@ -116,21 +123,29 @@ export default function PropAmmPage() { }, [theme]) const fallbackState = useMemo( - () => buildFallbackUiState(propStrategyRef, playbackSpeed, maxTapeRows), - [maxTapeRows, playbackSpeed, propStrategyRef], + () => + buildFallbackUiState(strategyRef, { + seed: 1337, + playbackSpeed, + maxTapeRows, + nSteps: nSteps || PROP_DEFAULT_STEPS, + }), + [maxTapeRows, nSteps, playbackSpeed, strategyRef], ) + const effectiveState = workerState ?? fallbackState const simulationLoading = !ready || !workerState return ( <> -
    -
    +
    +
    setTheme(theme === 'dark' ? 'light' : 'dark')} subtitle="Prop AMM Challenge" subtitleLink="https://ammchallenge.com/prop-amm" + currentView="prop" /> {workerError ?
    Worker error: {workerError}
    : null} @@ -138,15 +153,13 @@ export default function PropAmmPage() {
    { - setPropStrategyRef(next) - controls.setStrategy(next) - }} + onSelectStrategy={setStrategyRef} onToggleExplanationOverlay={() => setShowCodeExplanation(!showCodeExplanation)} /> @@ -164,6 +177,7 @@ export default function PropAmmPage() { controls.pause() return } + controls.play() }} onStep={() => { @@ -176,8 +190,6 @@ export default function PropAmmPage() { }} />
    - -
    ) diff --git a/components/HeaderActions.tsx b/components/HeaderActions.tsx index 93e254c..e4dd120 100644 --- a/components/HeaderActions.tsx +++ b/components/HeaderActions.tsx @@ -7,6 +7,7 @@ interface HeaderActionsProps { onToggleTheme: () => void subtitle?: string subtitleLink?: string + currentView?: 'amm' | 'prop' } function XIcon() { @@ -25,7 +26,7 @@ function GitHubIcon() { ) } -export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink }: HeaderActionsProps) { +export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink, currentView = 'amm' }: HeaderActionsProps) { const toggleLabel = theme === 'dark' ? 'Light Theme' : 'Dark Theme' const title = subtitle ? `AMM Strategy Visualizer — ${subtitle}` : 'AMM Strategy Visualizer' const linkHref = subtitleLink ?? 'https://ammchallenge.com' @@ -41,6 +42,14 @@ export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink }: {linkText}

    +
    diff --git a/components/PropCodePanel.tsx b/components/PropCodePanel.tsx deleted file mode 100644 index 70dd348..0000000 --- a/components/PropCodePanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client' - -import { useMemo } from 'react' -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 lines = useMemo(() => code.split('\n'), [code]) - const highlightSet = useMemo(() => new Set(highlightedLines), [highlightedLines]) - - return ( -
    -
    -

    Strategy Code (Rust)

    -
    - - -
    -
    - -
    -
    -          
    -            {lines.map((line, i) => {
    -              const lineNum = i + 1
    -              const isHighlighted = highlightSet.has(lineNum)
    -              return (
    -                
    - {lineNum} - {line || ' '} -
    - ) - })} -
    -
    -
    - - {showExplanationOverlay ? ( -
    -

    What the code is doing

    -

    {codeExplanation}

    -
    - Note: Prop AMM strategies define a custom compute_swap function that returns{' '} - output_amount directly, rather than just adjusting fees on a constant-product curve. -
    -
    - ) : null} -
    - ) -} diff --git a/components/PropMarketPanel.tsx b/components/PropMarketPanel.tsx deleted file mode 100644 index 25e512a..0000000 --- a/components/PropMarketPanel.tsx +++ /dev/null @@ -1,418 +0,0 @@ -'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} - /> -
    -
    - -
    -
    -
    -
    - 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/components/prop/PropAmmChart.tsx b/components/prop/PropAmmChart.tsx new file mode 100644 index 0000000..3afce74 --- /dev/null +++ b/components/prop/PropAmmChart.tsx @@ -0,0 +1,304 @@ +'use client' + +import { useMemo } from 'react' +import { buildCurvePath, buildTrailPath } from '../../lib/sim/chart' +import type { ThemeMode } from '../../lib/sim/types' +import type { PropTradeEvent, PropSnapshot } from '../../lib/prop-sim/types' + +interface PropAmmChartProps { + snapshot: PropSnapshot + reserveTrail: Array<{ x: number; y: number }> + lastEvent: PropTradeEvent | null + theme: ThemeMode + viewWindow: { xMin: number; xMax: number; yMin: number; yMax: number } | null + autoZoom: boolean + chartSize: { width: number; height: number } +} + +const PROP_CHART_PALETTE = { + canvas: '#0b1422', + canvasGlow: '#101f34', + grid: '#22344f', + axis: '#4b5f7d', + strategyCurve: '#8ea6d5', + normalizerCurve: '#34465d', + trail: '#6f87b5', + strategyDot: '#9fb4de', + strategyRing: '#4f6285', + normalizerDot: '#5e708d', + targetDot: '#7f95be', + helper: '#334b6a', + annotation: '#617395', + axisLabel: '#8395b1', +} + +function buildFallbackWindow(snapshot: PropSnapshot): { xMin: number; xMax: number; yMin: number; yMax: number } { + const xMin = Math.min(snapshot.submission.x, snapshot.normalizer.x) * 0.6 + const xMax = Math.max(snapshot.submission.x, snapshot.normalizer.x) * 1.25 + const yMin = Math.min(snapshot.submission.y, snapshot.normalizer.y) * 0.55 + const yMax = Math.max(snapshot.submission.y, snapshot.normalizer.y) * 1.2 + + return { + xMin: Math.max(1e-6, xMin), + xMax: Math.max(xMin + 1, xMax), + yMin: Math.max(1e-6, yMin), + yMax: Math.max(yMin + 1, yMax), + } +} + +export function PropAmmChart({ + snapshot, + reserveTrail, + lastEvent, + viewWindow, + autoZoom, + chartSize, +}: PropAmmChartProps) { + const geometry = useMemo(() => { + const width = Math.max(320, Math.round(chartSize.width)) + const height = Math.max(220, Math.round(chartSize.height)) + return { + width, + height, + margin: { + left: Math.max(56, Math.min(84, width * 0.1)), + right: Math.max(16, Math.min(34, width * 0.04)), + top: Math.max(16, Math.min(30, height * 0.09)), + bottom: Math.max(44, Math.min(64, height * 0.2)), + }, + } + }, [chartSize.height, chartSize.width]) + + const chart = useMemo(() => { + const activeWindow = viewWindow ?? buildFallbackWindow(snapshot) + const xMin = activeWindow.xMin + const xMax = activeWindow.xMax + const yMin = activeWindow.yMin + const yMax = activeWindow.yMax + + const innerW = geometry.width - geometry.margin.left - geometry.margin.right + const innerH = geometry.height - geometry.margin.top - geometry.margin.bottom + + const xToPx = (x: number) => geometry.margin.left + ((x - xMin) / (xMax - xMin)) * innerW + const yToPx = (y: number) => geometry.margin.top + (1 - (y - yMin) / (yMax - yMin)) * innerH + + const strategyPath = buildCurvePath(snapshot.submission.k, xMin, xMax, xToPx, yToPx) + const normalizerPath = buildCurvePath(snapshot.normalizer.k, xMin, xMax, xToPx, yToPx) + const trailPath = buildTrailPath(reserveTrail.slice(-120), xToPx, yToPx) + + const submissionPoint = { + x: xToPx(snapshot.submission.x), + y: yToPx(snapshot.submission.y), + } + + const normalizerPoint = { + x: xToPx(snapshot.normalizer.x), + y: yToPx(snapshot.normalizer.y), + } + + const targetX = Math.sqrt(snapshot.submission.k / Math.max(snapshot.fairPrice, 1e-9)) + const targetY = snapshot.submission.k / Math.max(targetX, 1e-9) + + const targetPoint = { + x: xToPx(targetX), + y: yToPx(targetY), + } + + const xAxisY = geometry.height - geometry.margin.bottom + const yAxisX = geometry.margin.left + + const tradeArrow = + lastEvent?.trade && lastEvent.isSubmissionTrade + ? { + fromX: xToPx(lastEvent.trade.beforeX), + fromY: yToPx(lastEvent.trade.beforeY), + toX: xToPx(lastEvent.trade.reserveX), + toY: yToPx(lastEvent.trade.reserveY), + } + : null + + return { + innerW, + innerH, + xToPx, + yToPx, + xAxisY, + yAxisX, + strategyPath, + normalizerPath, + trailPath, + submissionPoint, + normalizerPoint, + targetPoint, + tradeArrow, + } + }, [geometry.height, geometry.margin.bottom, geometry.margin.left, geometry.margin.right, geometry.margin.top, geometry.width, lastEvent, reserveTrail, snapshot, viewWindow]) + + const gridColumns = 8 + const gridRows = 6 + + return ( + + + + + + + + + + + {Array.from({ length: gridColumns + 1 }).map((_, index) => { + const x = geometry.margin.left + (chart.innerW * index) / gridColumns + return ( + + ) + })} + + {Array.from({ length: gridRows + 1 }).map((_, index) => { + const y = geometry.margin.top + (chart.innerH * index) / gridRows + return ( + + ) + })} + + + + + + + + + + + + {chart.tradeArrow ? ( + + ) : null} + + + + + + + + + + + + input + + + + + output + + + + compute_swap() + + + + + + + + Input + + + Output + + + ) +} diff --git a/components/prop/PropCodePanel.tsx b/components/prop/PropCodePanel.tsx new file mode 100644 index 0000000..cc6e45e --- /dev/null +++ b/components/prop/PropCodePanel.tsx @@ -0,0 +1,130 @@ +'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 + modelUsed: string + highlightedLines: number[] + codeExplanation: string + showExplanationOverlay: boolean + onSelectStrategy: (strategy: PropStrategyRef) => void + onToggleExplanationOverlay: () => void +} + +function encodeStrategyRef(strategy: PropStrategyRef): string { + return `${strategy.kind}:${strategy.id}` +} + +function decodeStrategyRef(value: string): PropStrategyRef { + const [kind, ...idParts] = value.split(':') + return { + kind: kind === 'builtin' ? 'builtin' : 'builtin', + id: idParts.join(':'), + } +} + +export function PropCodePanel({ + availableStrategies, + selectedStrategy, + code, + modelUsed, + 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 highlightedCodeLines = 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 ( +
    +
    +
    +
    +

    Starter Strategy (Rust)

    +
    + +
    + +
    + +
    + Model used: {modelUsed} + Storage behavior: No-op afterSwap +
    +
    +
    + +
    + {lines.map((line, index) => { + const lineNumber = index + 1 + const active = lineSet.has(lineNumber) + + return ( +
    + {String(lineNumber).padStart(2, '0')} + +
    + ) + })} +
    + +
    + + +
    +
    + ) +} diff --git a/components/prop/PropMarketPanel.tsx b/components/prop/PropMarketPanel.tsx new file mode 100644 index 0000000..bf10c3a --- /dev/null +++ b/components/prop/PropMarketPanel.tsx @@ -0,0 +1,250 @@ +'use client' + +import { useLayoutEffect, useRef, useState } from 'react' +import { PROP_SPEED_PROFILE } from '../../lib/prop-sim/constants' +import type { PropTradeEvent, PropWorkerUiState } from '../../lib/prop-sim/types' +import type { ThemeMode } from '../../lib/sim/types' +import { formatNum, formatSigned } from '../../lib/sim/utils' +import { PropAmmChart } from './PropAmmChart' + +interface PropMarketPanelProps { + state: PropWorkerUiState + theme: ThemeMode + playbackSpeed: number + autoZoom: boolean + isInitializing?: boolean + onPlaybackSpeedChange: (value: number) => void + onToggleAutoZoom: () => void + onPlayPause: () => void + onStep: () => void + onReset: () => void +} + +export function PropMarketPanel({ + state, + theme, + playbackSpeed, + autoZoom, + isInitializing = false, + onPlaybackSpeedChange, + onToggleAutoZoom, + onPlayPause, + onStep, + onReset, +}: PropMarketPanelProps) { + const snapshot = state.snapshot + 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) => (prev.width === width && prev.height === height ? prev : { 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) => (prev.width === width && prev.height === height ? prev : { width, height })) + }) + + measure() + observer.observe(host) + return () => observer.disconnect() + }, []) + + return ( +
    +
    +

    Simulated Market (Prop AMM)

    + + Step {snapshot.step} | Trade {state.tradeCount} + {isInitializing ? ' | Loading' : ''} + +
    + +
    +
    +
    +
    + + + +
    + +
    + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + + + + + + + + + +
    +
    +
    +
    + + +
    +
    + ) +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
    + {label} + {value} +
    + ) +} + +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' + const alphaLabel = event.routerSplit ? `α=${formatNum(event.routerSplit.alpha, 3)}` : null + + return ( +
  • +
    + {flowLabel} + + t{event.step} | {event.poolName} + +
    +

    {event.summary}

    + {alphaLabel ?

    router split {alphaLabel}

    : null} + {event.isSubmissionTrade ? ( +
    submission edge delta: {formatSigned(event.edgeDelta)}
    + ) : ( +
    normalizer trade
    + )} +
  • + ) +} + +function ControlIcon({ kind }: { kind: 'play' | 'pause' | 'step' | 'reset' }) { + if (kind === 'pause') { + return ( + + ) + } + + if (kind === 'step') { + return ( + + ) + } + + if (kind === 'reset') { + return ( + + ) + } + + return ( + + ) +} diff --git a/hooks/usePropSimulationWorker.ts b/hooks/usePropSimulationWorker.ts index 0dc25bf..8208566 100644 --- a/hooks/usePropSimulationWorker.ts +++ b/hooks/usePropSimulationWorker.ts @@ -1,120 +1,114 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' -import type { PropSimulationConfig, PropStrategyRef, PropWorkerUiState } from '../lib/prop-sim/types' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { PropSimulationConfig, PropStrategyRef } from '../lib/prop-sim/types' +import { usePropPlaybackStore } from '../store/usePropPlaybackStore' +import type { PropWorkerInboundMessage, PropWorkerOutboundMessage } from '../workers/prop-messages' -interface UsePropSimulationWorkerOptions { +interface UsePropSimulationWorkerArgs { seed: number playbackSpeed: number maxTapeRows: number + nSteps: 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 { +export function usePropSimulationWorker({ + seed, + playbackSpeed, + maxTapeRows, + nSteps, + strategyRef, +}: UsePropSimulationWorkerArgs) { 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' }, - ) + const workerState = usePropPlaybackStore((state) => state.workerState) + const workerError = usePropPlaybackStore((state) => state.workerError) + const setWorkerState = usePropPlaybackStore((state) => state.setWorkerState) + const setWorkerError = usePropPlaybackStore((state) => state.setWorkerError) + + const post = useCallback((message: PropWorkerInboundMessage) => { + workerRef.current?.postMessage(message) + }, []) - 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, + useEffect(() => { + const worker = new Worker(new URL('../workers/prop-simulation.worker.ts', import.meta.url), { + type: 'module', + }) + + worker.onmessage = (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case 'PROP_STATE': { + setWorkerState(message.payload.state) + break } - worker.postMessage({ type: 'init', config }) - } else if (msg.type === 'state') { - setWorkerState(msg.state) - if (!ready) { - setReady(true) + case 'PROP_ERROR': { + setWorkerError(message.payload.message) + break } + default: + break } } - worker.onerror = (event) => { - setWorkerError(event.message || 'Worker error') + workerRef.current = worker + setReady(true) + + const initConfig: Partial = { + seed, + playbackSpeed, + maxTapeRows, + nSteps, + strategyRef, } - workerRef.current = worker + worker.postMessage({ + type: 'INIT_PROP_SIM', + payload: { + config: initConfig, + }, + } satisfies PropWorkerInboundMessage) return () => { worker.terminate() workerRef.current = null + setReady(false) } - // Only run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [setWorkerError, setWorkerState]) - // 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 }) - }, []) + if (!ready) return + + post({ + type: 'SET_PROP_CONFIG', + payload: { + config: { + seed, + playbackSpeed, + maxTapeRows, + nSteps, + strategyRef, + }, + }, + }) + }, [maxTapeRows, nSteps, playbackSpeed, post, ready, seed, strategyRef]) + + const controls = useMemo( + () => ({ + play: () => post({ type: 'PLAY_PROP' }), + pause: () => post({ type: 'PAUSE_PROP' }), + step: () => post({ type: 'STEP_PROP_ONE' }), + reset: () => post({ type: 'RESET_PROP' }), + }), + [post], + ) return { ready, workerState, workerError, - controls: { - play, - pause, - step, - reset, - setStrategy, - }, + controls, } } diff --git a/lib/prop-sim/amm.ts b/lib/prop-sim/amm.ts new file mode 100644 index 0000000..66de0a4 --- /dev/null +++ b/lib/prop-sim/amm.ts @@ -0,0 +1,194 @@ +import { + PROP_INITIAL_RESERVE_X, + PROP_INITIAL_RESERVE_Y, + PROP_MIN_INPUT, + PROP_NORMALIZER_FEE_MIN, + PROP_STORAGE_SIZE, +} from './constants' +import { + ceilDiv, + clampU64, + encodeU16Le, + ensureStorageSize, + fromNano, + readU16Le, + saturatingSub, + toNano, +} from './nano' +import type { PropAmmState, PropPool, PropSwapSide, PropTrade } from './types' + +export function createAmm( + pool: PropPool, + reserveX: number, + reserveY: number, + storage?: Uint8Array, +): PropAmmState { + return { + pool, + name: pool === 'submission' ? 'Submission' : 'Normalizer', + reserveX, + reserveY, + storage: storage ? ensureStorageSize(storage) : new Uint8Array(PROP_STORAGE_SIZE), + } +} + +export function createInitialSubmissionAmm(): PropAmmState { + return createAmm('submission', PROP_INITIAL_RESERVE_X, PROP_INITIAL_RESERVE_Y) +} + +export function createInitialNormalizerAmm(liquidityMultiplier: number, feeBps: number): PropAmmState { + const reserveX = PROP_INITIAL_RESERVE_X * liquidityMultiplier + const reserveY = PROP_INITIAL_RESERVE_Y * liquidityMultiplier + const storage = new Uint8Array(PROP_STORAGE_SIZE) + storage.set(encodeU16Le(feeBps), 0) + return createAmm('normalizer', reserveX, reserveY, storage) +} + +export function ammSpot(amm: PropAmmState): number { + if (!Number.isFinite(amm.reserveX) || !Number.isFinite(amm.reserveY) || amm.reserveX <= 0) { + return Number.NaN + } + return amm.reserveY / amm.reserveX +} + +export function ammK(amm: PropAmmState): number { + return amm.reserveX * amm.reserveY +} + +export function normalizerFeeBps(amm: PropAmmState): number { + const raw = readU16Le(amm.storage, 0) + return raw === 0 ? PROP_NORMALIZER_FEE_MIN : raw +} + +export function quoteNormalizer(amm: PropAmmState, side: PropSwapSide, inputAmount: number): number { + if (!Number.isFinite(inputAmount) || inputAmount <= 0) { + return 0 + } + + if (amm.reserveX <= 0 || amm.reserveY <= 0 || !Number.isFinite(amm.reserveX) || !Number.isFinite(amm.reserveY)) { + return 0 + } + + const feeBps = normalizerFeeBps(amm) + const gammaNumerator = BigInt(Math.max(0, 10_000 - feeBps)) + + const inputNano = toNano(inputAmount) + const reserveXNano = toNano(amm.reserveX) + const reserveYNano = toNano(amm.reserveY) + + if (inputNano <= 0n || reserveXNano <= 0n || reserveYNano <= 0n) { + return 0 + } + + const k = reserveXNano * reserveYNano + + if (side === 0) { + const netIn = (inputNano * gammaNumerator) / 10_000n + const newReserveY = reserveYNano + netIn + const kDiv = ceilDiv(k, newReserveY) + const out = saturatingSub(reserveXNano, kDiv) + return fromNano(out) + } + + const netIn = (inputNano * gammaNumerator) / 10_000n + const newReserveX = reserveXNano + netIn + const kDiv = ceilDiv(k, newReserveX) + const out = saturatingSub(reserveYNano, kDiv) + return fromNano(out) +} + +export function quoteNormalizerBuyX(amm: PropAmmState, inputY: number): number { + return quoteNormalizer(amm, 0, inputY) +} + +export function quoteNormalizerSellX(amm: PropAmmState, inputX: number): number { + return quoteNormalizer(amm, 1, inputX) +} + +export function executeBuyX( + amm: PropAmmState, + quoteBuyX: (inputY: number) => number, + inputY: number, +): PropTrade | null { + if (!Number.isFinite(inputY) || inputY < PROP_MIN_INPUT) { + return null + } + + const outputX = quoteBuyX(inputY) + if (!Number.isFinite(outputX) || outputX <= 0 || outputX >= amm.reserveX) { + return null + } + + const beforeX = amm.reserveX + const beforeY = amm.reserveY + const spotBefore = beforeY / Math.max(beforeX, 1e-12) + + const nextReserveX = beforeX - outputX + const nextReserveY = beforeY + inputY + + if (!Number.isFinite(nextReserveX) || !Number.isFinite(nextReserveY) || nextReserveX <= 0 || nextReserveY <= 0) { + return null + } + + amm.reserveX = nextReserveX + amm.reserveY = nextReserveY + + return { + side: 0, + direction: 'buy_x', + inputAmount: inputY, + outputAmount: outputX, + inputAmountNano: clampU64(toNano(inputY)).toString(), + outputAmountNano: clampU64(toNano(outputX)).toString(), + beforeX, + beforeY, + reserveX: amm.reserveX, + reserveY: amm.reserveY, + spotBefore, + spotAfter: amm.reserveY / Math.max(amm.reserveX, 1e-12), + } +} + +export function executeSellX( + amm: PropAmmState, + quoteSellX: (inputX: number) => number, + inputX: number, +): PropTrade | null { + if (!Number.isFinite(inputX) || inputX < PROP_MIN_INPUT) { + return null + } + + const outputY = quoteSellX(inputX) + if (!Number.isFinite(outputY) || outputY <= 0 || outputY >= amm.reserveY) { + return null + } + + const beforeX = amm.reserveX + const beforeY = amm.reserveY + const spotBefore = beforeY / Math.max(beforeX, 1e-12) + + const nextReserveX = beforeX + inputX + const nextReserveY = beforeY - outputY + + if (!Number.isFinite(nextReserveX) || !Number.isFinite(nextReserveY) || nextReserveX <= 0 || nextReserveY <= 0) { + return null + } + + amm.reserveX = nextReserveX + amm.reserveY = nextReserveY + + return { + side: 1, + direction: 'sell_x', + inputAmount: inputX, + outputAmount: outputY, + inputAmountNano: clampU64(toNano(inputX)).toString(), + outputAmountNano: clampU64(toNano(outputY)).toString(), + beforeX, + beforeY, + reserveX: amm.reserveX, + reserveY: amm.reserveY, + spotBefore, + spotAfter: amm.reserveY / Math.max(amm.reserveX, 1e-12), + } +} diff --git a/lib/prop-sim/arbitrage.ts b/lib/prop-sim/arbitrage.ts new file mode 100644 index 0000000..f823f7c --- /dev/null +++ b/lib/prop-sim/arbitrage.ts @@ -0,0 +1,291 @@ +import { + GOLDEN_RATIO_CONJUGATE, + PROP_ARB_BRACKET_GROWTH, + PROP_ARB_BRACKET_MAX_STEPS, + PROP_ARB_GOLDEN_MAX_ITERS, + PROP_ARB_INPUT_REL_TOL, + PROP_MAX_INPUT_AMOUNT, + PROP_MIN_ARB_NOTIONAL_Y, + PROP_MIN_ARB_PROFIT_Y, + PROP_MIN_INPUT, +} from './constants' +import { normalizerFeeBps } from './amm' +import type { PropAmmState, PropSwapSide } from './types' + +export interface PropArbCandidate { + side: PropSwapSide + inputAmount: number + expectedProfit: number +} + +interface GoldenResult { + x: number + value: number +} + +function sanitizeScore(value: number): number { + if (!Number.isFinite(value)) { + return Number.NEGATIVE_INFINITY + } + + return value +} + +function bracketMaximum( + start: number, + minInput: number, + maxInput: number, + objective: (input: number) => number, +): [number, number] { + const min = Math.max(PROP_MIN_INPUT, minInput) + const max = Math.max(min, maxInput) + + let lo = min + let mid = Math.min(max, Math.max(min, start)) + let midValue = sanitizeScore(objective(mid)) + + if (midValue <= 0) { + return [lo, mid] + } + + let hi = Math.min(max, mid * PROP_ARB_BRACKET_GROWTH) + if (hi <= mid) { + return [lo, mid] + } + + let hiValue = sanitizeScore(objective(hi)) + + for (let index = 0; index < PROP_ARB_BRACKET_MAX_STEPS; index += 1) { + if (hiValue <= midValue || hi >= max) { + return [lo, hi] + } + + lo = mid + mid = hi + midValue = hiValue + + const nextHi = Math.min(max, hi * PROP_ARB_BRACKET_GROWTH) + if (nextHi <= hi) { + return [lo, hi] + } + + hi = nextHi + hiValue = sanitizeScore(objective(hi)) + } + + return [lo, hi] +} + +function goldenSectionMax(lo: number, hi: number, objective: (input: number) => number): GoldenResult { + let left = Math.max(0, Math.min(lo, hi)) + let right = Math.max(PROP_MIN_INPUT, Math.max(lo, hi)) + + if (right <= left) { + return { + x: right, + value: sanitizeScore(objective(right)), + } + } + + let bestX = left + let bestValue = sanitizeScore(objective(left)) + + const rightValue = sanitizeScore(objective(right)) + if (rightValue > bestValue) { + bestX = right + bestValue = rightValue + } + + let x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) + let x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) + let f1 = sanitizeScore(objective(x1)) + let f2 = sanitizeScore(objective(x2)) + + if (f1 > bestValue) { + bestX = x1 + bestValue = f1 + } + + if (f2 > bestValue) { + bestX = x2 + bestValue = f2 + } + + for (let index = 0; index < PROP_ARB_GOLDEN_MAX_ITERS; index += 1) { + if (f1 < f2) { + left = x1 + x1 = x2 + f1 = f2 + x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) + f2 = sanitizeScore(objective(x2)) + if (f2 > bestValue) { + bestX = x2 + bestValue = f2 + } + } else { + right = x2 + x2 = x1 + f2 = f1 + x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) + f1 = sanitizeScore(objective(x1)) + if (f1 > bestValue) { + bestX = x1 + bestValue = f1 + } + } + + const mid = 0.5 * (left + right) + const scale = Math.max(PROP_MIN_INPUT, Math.abs(mid)) + if (right - left <= PROP_ARB_INPUT_REL_TOL * scale) { + break + } + } + + return { x: bestX, value: bestValue } +} + +function pickBestCandidate( + buyCandidate: PropArbCandidate | null, + sellCandidate: PropArbCandidate | null, +): PropArbCandidate | null { + if (buyCandidate && sellCandidate) { + return sellCandidate.expectedProfit > buyCandidate.expectedProfit ? sellCandidate : buyCandidate + } + + return buyCandidate ?? sellCandidate +} + +export function findSubmissionArbOpportunity(args: { + fairPrice: number + quoteBuyX: (inputY: number) => number + quoteSellX: (inputX: number) => number + sampleStartY: () => number + minArbProfitY?: number +}): PropArbCandidate | null { + const { + fairPrice, + quoteBuyX, + quoteSellX, + sampleStartY, + minArbProfitY = PROP_MIN_ARB_PROFIT_Y, + } = args + + if (!Number.isFinite(fairPrice) || fairPrice <= 0) { + return null + } + + const minBuyInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y) + const minSellInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y / Math.max(fairPrice, 1e-9)) + const startY = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minBuyInput, sampleStartY())) + const startX = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minSellInput, startY / Math.max(fairPrice, 1e-9))) + + const buyBracket = bracketMaximum(startY, minBuyInput, PROP_MAX_INPUT_AMOUNT, (inputY) => { + const outputX = quoteBuyX(inputY) + return outputX * fairPrice - inputY + }) + + const buyOptimal = goldenSectionMax(buyBracket[0], buyBracket[1], (inputY) => { + const outputX = quoteBuyX(inputY) + return outputX * fairPrice - inputY + }) + + let buyCandidate: PropArbCandidate | null = null + if (buyOptimal.x >= minBuyInput) { + const outputX = quoteBuyX(buyOptimal.x) + const expectedProfit = outputX * fairPrice - buyOptimal.x + if (outputX > 0 && expectedProfit >= minArbProfitY) { + buyCandidate = { + side: 0, + inputAmount: buyOptimal.x, + expectedProfit, + } + } + } + + const sellBracket = bracketMaximum(startX, minSellInput, PROP_MAX_INPUT_AMOUNT, (inputX) => { + const outputY = quoteSellX(inputX) + return outputY - inputX * fairPrice + }) + + const sellOptimal = goldenSectionMax(sellBracket[0], sellBracket[1], (inputX) => { + const outputY = quoteSellX(inputX) + return outputY - inputX * fairPrice + }) + + let sellCandidate: PropArbCandidate | null = null + if (sellOptimal.x >= minSellInput) { + const outputY = quoteSellX(sellOptimal.x) + const expectedProfit = outputY - sellOptimal.x * fairPrice + if (outputY > 0 && expectedProfit >= minArbProfitY) { + sellCandidate = { + side: 1, + inputAmount: sellOptimal.x, + expectedProfit, + } + } + } + + return pickBestCandidate(buyCandidate, sellCandidate) +} + +export function findNormalizerArbOpportunity(args: { + amm: PropAmmState + fairPrice: number + quoteBuyX: (inputY: number) => number + quoteSellX: (inputX: number) => number + minArbProfitY?: number +}): PropArbCandidate | null { + const { + amm, + fairPrice, + quoteBuyX, + quoteSellX, + minArbProfitY = PROP_MIN_ARB_PROFIT_Y, + } = args + + if (!Number.isFinite(fairPrice) || fairPrice <= 0) { + return null + } + + const feeBps = normalizerFeeBps(amm) + const gamma = (10_000 - feeBps) / 10_000 + + if (!Number.isFinite(gamma) || gamma <= 0 || amm.reserveX <= 0 || amm.reserveY <= 0) { + return null + } + + const minBuyInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y) + const minSellInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y / Math.max(fairPrice, 1e-9)) + + let buyCandidate: PropArbCandidate | null = null + const buyTarget = Math.sqrt(fairPrice * amm.reserveX * gamma * amm.reserveY) + if (Number.isFinite(buyTarget) && buyTarget > amm.reserveY) { + const inputY = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minBuyInput, (buyTarget - amm.reserveY) / gamma)) + const outputX = quoteBuyX(inputY) + const expectedProfit = outputX * fairPrice - inputY + if (outputX > 0 && expectedProfit >= minArbProfitY) { + buyCandidate = { + side: 0, + inputAmount: inputY, + expectedProfit, + } + } + } + + let sellCandidate: PropArbCandidate | null = null + const sellTarget = Math.sqrt((amm.reserveY * amm.reserveX * gamma) / fairPrice) + if (Number.isFinite(sellTarget) && sellTarget > amm.reserveX) { + const inputX = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minSellInput, (sellTarget - amm.reserveX) / gamma)) + const outputY = quoteSellX(inputX) + const expectedProfit = outputY - inputX * fairPrice + if (outputY > 0 && expectedProfit >= minArbProfitY) { + sellCandidate = { + side: 1, + inputAmount: inputX, + expectedProfit, + } + } + } + + return pickBestCandidate(buyCandidate, sellCandidate) +} diff --git a/lib/prop-sim/constants.ts b/lib/prop-sim/constants.ts index 8b328e8..f6dbe31 100644 --- a/lib/prop-sim/constants.ts +++ b/lib/prop-sim/constants.ts @@ -1,50 +1,50 @@ -/** - * Prop AMM Challenge simulation parameters - * Based on: https://github.com/benedictbrady/prop-amm-challenge - */ +export const PROP_STORAGE_SIZE = 1024 -// Initial reserves +export const PROP_INITIAL_PRICE = 100 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 +export const PROP_DEFAULT_STEPS = 10_000 -// 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 +export const PROP_GBM_MU = 0 +export const PROP_GBM_DT = 1 +export const PROP_GBM_SIGMA_MIN = 0.0001 +export const PROP_GBM_SIGMA_MAX = 0.007 -// 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 +export const PROP_RETAIL_ARRIVAL_MIN = 0.4 +export const PROP_RETAIL_ARRIVAL_MAX = 1.2 +export const PROP_RETAIL_MEAN_SIZE_MIN = 12 +export const PROP_RETAIL_MEAN_SIZE_MAX = 28 +export const PROP_RETAIL_SIZE_SIGMA = 1.2 +export const PROP_RETAIL_BUY_PROB = 0.5 -// Arbitrage parameters -export const PROP_ARB_MIN_PROFIT = 0.01 // Y units (1 cent) -export const PROP_ARB_BRACKET_TOLERANCE = 0.01 // 1% relative +export const PROP_NORMALIZER_FEE_MIN = 30 +export const PROP_NORMALIZER_FEE_MAX = 80 +export const PROP_NORMALIZER_LIQ_MIN = 0.4 +export const PROP_NORMALIZER_LIQ_MAX = 2.0 -// 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 +export const PROP_MIN_ARB_PROFIT_Y = 0.01 +export const PROP_MIN_ARB_NOTIONAL_Y = 0.01 +export const PROP_MIN_INPUT = 0.001 +export const PROP_MIN_TRADE_SIZE = 0.001 -// Golden ratio for golden-section search -export const PHI = (1 + Math.sqrt(5)) / 2 -export const GOLDEN_RATIO = 1 / PHI // ≈ 0.618 +export const PROP_U64_MAX = 18_446_744_073_709_551_615n +export const PROP_NANO_SCALE = 1_000_000_000n +export const PROP_NANO_SCALE_F64 = 1_000_000_000 +export const PROP_MAX_INPUT_AMOUNT = (Number(PROP_U64_MAX) / PROP_NANO_SCALE_F64) * 0.999_999 -// Scale factor for bigint conversions (1e9) -export const PROP_SCALE = 1_000_000_000n -export const PROP_SCALE_NUM = 1_000_000_000 +export const GOLDEN_RATIO_CONJUGATE = 0.618_033_988_749_894_8 -// Storage size -export const PROP_STORAGE_SIZE = 1024 +export const PROP_ARB_BRACKET_MAX_STEPS = 24 +export const PROP_ARB_BRACKET_GROWTH = 2 +export const PROP_ARB_GOLDEN_MAX_ITERS = 12 +export const PROP_ARB_INPUT_REL_TOL = 1e-2 + +export const PROP_ROUTER_GOLDEN_MAX_ITERS = 14 +export const PROP_ROUTER_ALPHA_TOL = 1e-3 +export const PROP_ROUTER_SUBMISSION_AMOUNT_REL_TOL = 1e-2 +export const PROP_ROUTER_SCORE_REL_GAP_TOL = 1e-2 -// Playback speed profiles (shared with original) export const PROP_SPEED_PROFILE: Record = { 1: { ms: 1000, label: '1x' }, 2: { ms: 500, label: '2x' }, @@ -53,9 +53,3 @@ export const PROP_SPEED_PROFILE: Record = 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 index 7ebba69..b386854 100644 --- a/lib/prop-sim/engine.ts +++ b/lib/prop-sim/engine.ts @@ -1,96 +1,104 @@ +import { getChartViewWindow, type ChartWindow } from '../sim/chart' +import { formatNum, formatSigned } from '../sim/utils' +import type { Snapshot as LegacySnapshot } from '../sim/types' import { - createPropAmm, - executePropBuyX, - executePropSellX, - findPropArbOpportunity, - formatNum, - formatSigned, - fromScaledBigInt, - normalizerQuoteBuyX, - normalizerQuoteSellX, - propAmmK, - propAmmSpot, - routePropRetailOrder, - toScaledBigInt, - type PropQuoteFn, -} from './math' + ammK, + ammSpot, + createInitialNormalizerAmm, + createInitialSubmissionAmm, + executeBuyX, + executeSellX, + quoteNormalizerBuyX, + quoteNormalizerSellX, +} from './amm' +import { findNormalizerArbOpportunity, findSubmissionArbOpportunity } from './arbitrage' +import { + PROP_DEFAULT_STEPS, + PROP_GBM_DT, + PROP_GBM_MU, + PROP_GBM_SIGMA_MAX, + PROP_GBM_SIGMA_MIN, + PROP_MIN_TRADE_SIZE, + PROP_NORMALIZER_FEE_MAX, + PROP_NORMALIZER_FEE_MIN, + PROP_NORMALIZER_LIQ_MAX, + PROP_NORMALIZER_LIQ_MIN, + PROP_RETAIL_ARRIVAL_MAX, + PROP_RETAIL_ARRIVAL_MIN, + PROP_RETAIL_MEAN_SIZE_MAX, + PROP_RETAIL_MEAN_SIZE_MIN, +} from './constants' +import { bigintToString, ensureStorageSize, fromNano, stringToBigint, toNano } from './nano' +import { GbmPriceProcess } from './priceProcess' +import { generateRetailOrders, sampleLogNormal } from './retail' +import { routeRetailOrder } from './router' import type { - PropActiveStrategyRuntime, PropAmmState, - PropNormalizerConfig, + PropFlowType, + PropRetailOrder, + PropSampledRegime, PropSimulationConfig, PropSnapshot, - PropStorageChange, + PropStrategyRuntime, 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' +import { getStarterCodeLines } from '../prop-strategies/builtins' + +interface PropRandomSource { + next: () => number + between: (min: number, max: number) => number + gaussian: () => number +} interface PropEngineState { config: PropSimulationConfig - strategy: PropActiveStrategyRuntime + strategy: PropStrategyRuntime 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 - } + regime: PropSampledRegime + submission: PropAmmState | null + normalizer: PropAmmState | null edge: { total: number retail: number arb: number } - impliedFees: { - bidBps: number - askBps: number - } + lastStorageChangedBytes: number + lastStorageWriteStep: number | null 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 +interface EnqueueEventInput { + flow: PropFlowType + pool: 'submission' | 'normalizer' trade: PropTrade - order: PropTradeEvent['order'] - arbProfit: number priceMove: { from: number; to: number } + order: PropRetailOrder | null + arbProfit: number + routerSplit: PropTradeEvent['routerSplit'] +} + +function clampFinite(value: number, fallback: number): number { + if (!Number.isFinite(value)) { + return fallback + } + return value } export class PropSimulationEngine { private readonly state: PropEngineState - constructor(config: PropSimulationConfig, strategy: PropActiveStrategyRuntime) { + constructor(config: PropSimulationConfig, strategy: PropStrategyRuntime) { this.state = { config, strategy, @@ -99,41 +107,48 @@ export class PropSimulationEngine { 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, + regime: { + gbmSigma: 0.001, + retailArrivalRate: 0.8, + retailMeanSize: 20, + normFeeBps: 30, + normLiquidityMult: 1, }, - edge: { total: 0, retail: 0, arb: 0 }, - impliedFees: { bidBps: 0, askBps: 0 }, + submission: null, + normalizer: null, + edge: { + total: 0, + retail: 0, + arb: 0, + }, + lastStorageChangedBytes: 0, + lastStorageWriteStep: null, 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 setConfig(config: Partial): void { + this.state.config = { + ...this.state.config, + ...config, + nSteps: config.nSteps ?? this.state.config.nSteps, + } + + if (this.state.history.length > this.state.config.maxTapeRows) { + this.state.history = this.state.history.slice(0, this.state.config.maxTapeRows) } } - public setStrategy(strategy: PropActiveStrategyRuntime): void { + public setStrategy(strategy: PropStrategyRuntime): void { this.state.strategy = strategy } - public reset( - randomBetween: (min: number, max: number) => number, - ): void { + public reset(random: PropRandomSource): void { this.state.step = 0 this.state.tradeCount = 0 this.state.eventSeq = 0 @@ -141,77 +156,59 @@ export class PropSimulationEngine { 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 + this.state.lastStorageChangedBytes = 0 + this.state.lastStorageWriteStep = 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), - } + const sampledFee = Math.floor(random.between(PROP_NORMALIZER_FEE_MIN, PROP_NORMALIZER_FEE_MAX + 1)) - // 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), + this.state.regime = { + gbmSigma: random.between(PROP_GBM_SIGMA_MIN, PROP_GBM_SIGMA_MAX), + retailArrivalRate: random.between(PROP_RETAIL_ARRIVAL_MIN, PROP_RETAIL_ARRIVAL_MAX), + retailMeanSize: random.between(PROP_RETAIL_MEAN_SIZE_MIN, PROP_RETAIL_MEAN_SIZE_MAX), + normFeeBps: Math.max(PROP_NORMALIZER_FEE_MIN, Math.min(PROP_NORMALIZER_FEE_MAX, sampledFee)), + normLiquidityMult: random.between(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, + this.state.submission = createInitialSubmissionAmm() + this.state.normalizer = createInitialNormalizerAmm( + this.state.regime.normLiquidityMult, + this.state.regime.normFeeBps, ) - 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, - } + const submission = this.requireAmm(this.state.submission) + this.state.reserveTrail = [{ x: submission.reserveX, y: submission.reserveY }] - 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, + pool: 'submission', + poolName: 'Submission', + isSubmissionTrade: false, trade: null, order: null, + routerSplit: null, arbProfit: 0, fairPrice: this.state.fairPrice, priceMove: { from: this.state.fairPrice, to: this.state.fairPrice }, + edgeDelta: 0, + codeLines: [66, 67], + codeExplanation: + 'Simulation initialized. Starter strategy uses constant-product pricing with 500 bps fee and no storage writes.', + stateBadge: this.buildStateBadge(), + summary: `Regime sampled: sigma ${(this.state.regime.gbmSigma * 100).toFixed(3)}% | lambda ${this.state.regime.retailArrivalRate.toFixed(3)} | normalizer ${this.state.regime.normFeeBps} bps @ ${this.state.regime.normLiquidityMult.toFixed(2)}x`, + storageChangedBytes: 0, snapshot: this.state.currentSnapshot, } this.refreshViewWindow() } - public stepOne( - randomBetween: (min: number, max: number) => number, - gaussianRandom: () => number, - ): boolean { - this.ensurePendingEvents(randomBetween, gaussianRandom) + public stepOne(random: PropRandomSource): boolean { + this.ensurePendingEvents(random) if (!this.state.pendingEvents.length) { return false } @@ -225,7 +222,7 @@ export class PropSimulationEngine { this.state.lastEvent = event this.state.currentSnapshot = event.snapshot - this.state.reserveTrail = trackReservePoint(this.state.reserveTrail, event.snapshot) + this.trackReservePoint(event.snapshot) this.refreshViewWindow() this.state.history.unshift(event) @@ -236,323 +233,449 @@ export class PropSimulationEngine { 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 { + private ensurePendingEvents(random: PropRandomSource): void { let guard = 0 while (this.state.pendingEvents.length === 0 && guard < 8) { - this.generateNextStep(randomBetween, gaussianRandom) + this.generateNextStep(random) guard += 1 + if (this.state.step >= this.state.config.nSteps) { + break + } } } - 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) + private generateNextStep(random: PropRandomSource): void { + if (this.state.step >= this.state.config.nSteps) { + return + } + + const submission = this.requireAmm(this.state.submission) + const normalizer = this.requireAmm(this.state.normalizer) 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)) + const priceProcess = new GbmPriceProcess( + oldPrice, + PROP_GBM_MU, + this.state.regime.gbmSigma, + PROP_GBM_DT, + ) + this.state.fairPrice = clampFinite(priceProcess.step(random.gaussian()), oldPrice) 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) + this.runSubmissionArbitrage(submission, random, priceMove) + this.runNormalizerArbitrage(normalizer, priceMove) + + const orders = generateRetailOrders( + this.state.regime.retailArrivalRate, + this.state.regime.retailMeanSize, + () => random.next(), + () => random.gaussian(), + ) - // Route retail order (Poisson arrival) - if (randomBetween(0, 1) < this.state.simulationParams.arrivalRate) { - const order = this.generateRetailOrder(randomBetween, gaussianRandom) - this.routeRetailOrder(order, priceMove) + for (const order of orders) { + this.processRetailOrder(order, priceMove) } } - private makeStrategyQuoteFn(): PropQuoteFn { - const strategy = this.state.strategy - const storage = this.state.storage - const amm = this.requireAmm(this.state.strategyAmm) + private runSubmissionArbitrage( + submission: PropAmmState, + random: PropRandomSource, + priceMove: { from: number; to: number }, + ): void { + const candidate = findSubmissionArbOpportunity({ + fairPrice: this.state.fairPrice, + quoteBuyX: (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), + quoteSellX: (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), + sampleStartY: () => sampleLogNormal(this.state.regime.retailMeanSize, 1.2, () => random.gaussian()), + }) - return (side: 0 | 1, inputAmount: number): number => { - return strategy.computeSwap(amm.reserveX, amm.reserveY, side, inputAmount, storage) + if (!candidate || candidate.inputAmount <= 0) { + return } - } - private makeNormalizerQuoteFn(): PropQuoteFn { - const amm = this.requireAmm(this.state.normalizerAmm) - const feeBps = this.state.normalizerConfig.feeBps + const trade = + candidate.side === 0 + ? executeBuyX(submission, (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), candidate.inputAmount) + : executeSellX(submission, (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), candidate.inputAmount) - 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) - } + if (!trade) { + return } + + const arbProfit = trade.side === 0 + ? trade.outputAmount * this.state.fairPrice - trade.inputAmount + : trade.outputAmount - trade.inputAmount * this.state.fairPrice + + this.enqueueTradeEvent({ + flow: 'arbitrage', + pool: 'submission', + trade, + priceMove, + order: null, + arbProfit, + routerSplit: null, + }) } - private runArbitrageForAmm( - amm: PropAmmState, - quoteFn: PropQuoteFn, + private runNormalizerArbitrage( + normalizer: PropAmmState, priceMove: { from: number; to: number }, - isStrategy: boolean, ): void { - const arb = findPropArbOpportunity(amm, this.state.fairPrice, quoteFn) - if (!arb || arb.inputAmount <= 0.00000001) { + const candidate = findNormalizerArbOpportunity({ + amm: normalizer, + fairPrice: this.state.fairPrice, + quoteBuyX: (inputY) => quoteNormalizerBuyX(normalizer, inputY), + quoteSellX: (inputX) => quoteNormalizerSellX(normalizer, inputX), + }) + + if (!candidate || candidate.inputAmount <= 0) { 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) - } + const trade = + candidate.side === 0 + ? executeBuyX(normalizer, (inputY) => quoteNormalizerBuyX(normalizer, inputY), candidate.inputAmount) + : executeSellX(normalizer, (inputX) => quoteNormalizerSellX(normalizer, inputX), candidate.inputAmount) if (!trade) { return } + const arbProfit = trade.side === 0 + ? trade.outputAmount * this.state.fairPrice - trade.inputAmount + : trade.outputAmount - trade.inputAmount * this.state.fairPrice + this.enqueueTradeEvent({ flow: 'arbitrage', - amm, + pool: 'normalizer', trade, - order: null, - arbProfit: arb.expectedProfit, priceMove, - }, isStrategy, quoteFn) + order: null, + arbProfit, + routerSplit: null, + }) } - 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() + private processRetailOrder(order: PropRetailOrder, priceMove: { from: number; to: number }): void { + const submission = this.requireAmm(this.state.submission) + const normalizer = this.requireAmm(this.state.normalizer) - const splits = routePropRetailOrder( - strategyAmm, - normalizerAmm, - strategyQuote, - normalizerQuote, + const decision = routeRetailOrder({ order, - ) - - for (const [amm, amount, quoteFn] of splits) { - const isStrategy = amm.isStrategy - let trade: PropTrade | null = null + fairPrice: this.state.fairPrice, + quoteSubmissionBuyX: (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), + quoteSubmissionSellX: (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), + quoteNormalizerBuyX: (inputY) => quoteNormalizerBuyX(normalizer, inputY), + quoteNormalizerSellX: (inputX) => quoteNormalizerSellX(normalizer, inputX), + }) + + if (decision.submissionInput > PROP_MIN_TRADE_SIZE && decision.submissionOutput > 0) { + const trade = + order.side === 'buy' + ? executeBuyX(submission, (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), decision.submissionInput) + : executeSellX(submission, (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), decision.submissionInput) - 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', + pool: 'submission', + trade, + priceMove, + order, + arbProfit: 0, + routerSplit: { + alpha: decision.alpha, + submissionInput: decision.submissionInput, + normalizerInput: decision.normalizerInput, + }, + }) } + } + + if (decision.normalizerInput > PROP_MIN_TRADE_SIZE && decision.normalizerOutput > 0) { + const trade = + order.side === 'buy' + ? executeBuyX(normalizer, (inputY) => quoteNormalizerBuyX(normalizer, inputY), decision.normalizerInput) + : executeSellX(normalizer, (inputX) => quoteNormalizerSellX(normalizer, inputX), decision.normalizerInput) if (trade) { this.enqueueTradeEvent({ flow: 'retail', - amm, + pool: 'normalizer', trade, + priceMove, order, arbProfit: 0, - priceMove, - }, isStrategy, quoteFn) + routerSplit: { + alpha: decision.alpha, + submissionInput: decision.submissionInput, + normalizerInput: decision.normalizerInput, + }, + }) } } } - private enqueueTradeEvent( - input: PropTradeEventInput, - isStrategy: boolean, - quoteFn: PropQuoteFn, - ): void { - const { flow, amm, trade, order, arbProfit, priceMove } = input + private enqueueTradeEvent(input: EnqueueEventInput): void { + const { flow, pool, trade, priceMove, order, arbProfit, routerSplit } = input let edgeDelta = 0 - if (isStrategy) { + let storageChangedBytes = 0 + let codeLines: number[] = [] + let codeExplanation = 'Trade executed on normalizer. Submission strategy was not invoked.' + + if (pool === 'submission') { 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 - } + edgeDelta = this.computeRetailEdge(trade) 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) + storageChangedBytes = this.applyAfterSwap(trade) + codeLines = getStarterCodeLines(trade.side) + codeExplanation = this.describeSubmissionExecution(flow, trade, edgeDelta, storageChangedBytes) } - 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, + pool, + poolName: pool === 'submission' ? 'Submission' : 'Normalizer', + isSubmissionTrade: pool === 'submission', trade, order, + routerSplit, arbProfit, fairPrice: this.state.fairPrice, priceMove, edgeDelta, - codeLines: isStrategy ? [1] : [], + codeLines, codeExplanation, - stateBadge, - summary: this.describeTrade(flow, amm, trade, order), + stateBadge: this.buildStateBadge(), + summary: this.describeTrade(pool, flow, trade, order), + storageChangedBytes, snapshot: this.snapshotState(), - strategyExecution: isStrategy ? { - outputAmount: trade.outputAmount, - storageChanges: [], - } : undefined, } this.state.pendingEvents.push(event) } - private describeStrategyExecution( + private computeRetailEdge(trade: PropTrade): number { + if (trade.side === 1) { + return trade.inputAmount * this.state.fairPrice - trade.outputAmount + } + + return trade.inputAmount - trade.outputAmount * this.state.fairPrice + } + + private applyAfterSwap(trade: PropTrade): number { + const submission = this.requireAmm(this.state.submission) + const beforeStorage = submission.storage + const workingStorage = beforeStorage.slice() as Uint8Array + + const side = trade.side + const instruction = { + side, + inputAmountNano: stringToBigint(trade.inputAmountNano), + outputAmountNano: stringToBigint(trade.outputAmountNano), + reserveXNano: toNano(trade.reserveX), + reserveYNano: toNano(trade.reserveY), + step: this.state.step, + storage: workingStorage, + } + + let nextStorage: Uint8Array = workingStorage + try { + const maybeNext = this.state.strategy.afterSwap(instruction) + if (maybeNext instanceof Uint8Array) { + nextStorage = ensureStorageSize(maybeNext) + } else { + nextStorage = ensureStorageSize(workingStorage) + } + } catch { + nextStorage = beforeStorage + } + + let changedBytes = 0 + for (let index = 0; index < beforeStorage.length; index += 1) { + if (beforeStorage[index] !== nextStorage[index]) { + changedBytes += 1 + } + } + + submission.storage = nextStorage + this.state.lastStorageChangedBytes = changedBytes + + if (changedBytes > 0) { + this.state.lastStorageWriteStep = this.state.step + } + + return changedBytes + } + + private quoteSubmissionSwap(amm: PropAmmState, side: 0 | 1, inputAmount: number): number { + if (!Number.isFinite(inputAmount) || inputAmount <= 0) { + return 0 + } + + const outputNano = this.state.strategy.computeSwap({ + side, + inputAmountNano: toNano(inputAmount), + reserveXNano: toNano(amm.reserveX), + reserveYNano: toNano(amm.reserveY), + storage: amm.storage, + }) + + const output = fromNano(outputNano) + + if (!Number.isFinite(output) || output <= 0) { + return 0 + } + + if (side === 0 && output >= amm.reserveX) { + return 0 + } + + if (side === 1 && output >= amm.reserveY) { + return 0 + } + + return output + } + + private describeSubmissionExecution( + flow: PropFlowType, trade: PropTrade, - flow: PropTradeEvent['flow'], edgeDelta: number, + storageChangedBytes: 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}.` + const branch = trade.side === 0 ? '`compute_swap` buy-X branch' : '`compute_swap` sell-X branch' + const flowLabel = flow === 'arbitrage' ? 'arbitrage' : 'retail' + return `${branch} executed for ${flowLabel}. input=${formatNum(trade.inputAmount, 4)}, output=${formatNum(trade.outputAmount, 4)}, edge delta=${formatSigned(edgeDelta)}, storage changed=${storageChangedBytes} bytes.` } private describeTrade( - flow: PropTradeEvent['flow'], - amm: PropAmmState, + pool: 'submission' | 'normalizer', + flow: PropFlowType, trade: PropTrade, - order: { side: 'buy' | 'sell'; sizeY: number } | null, + order: PropRetailOrder | 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)}` + const direction = trade.side === 0 ? 'buy X (Y in)' : 'sell X (X in)' + const base = `${pool}: ${direction} | in=${formatNum(trade.inputAmount, 4)} out=${formatNum(trade.outputAmount, 4)}` if (flow === 'arbitrage') { - return `${base} | arb vs fair ${formatNum(this.state.fairPrice, 2)}` + return `${base} | fair=${formatNum(this.state.fairPrice, 4)}` } - const orderLabel = order ? `${order.side} ${formatNum(order.sizeY, 2)} Y` : 'retail' - return `${base} | routed from ${orderLabel}` + return `${base} | retail ${order?.side ?? 'n/a'} ${formatNum(order?.sizeY ?? 0, 3)} Y` } - 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 buildStateBadge(): string { + const lastWrite = this.state.lastStorageWriteStep === null ? 'n/a' : `step ${this.state.lastStorageWriteStep}` + return `storage Δ=${this.state.lastStorageChangedBytes} bytes | last write: ${lastWrite}` } private snapshotState(): PropSnapshot { - const strategyAmm = this.requireAmm(this.state.strategyAmm) - const normalizerAmm = this.requireAmm(this.state.normalizerAmm) + const submission = this.requireAmm(this.state.submission) + const normalizer = this.requireAmm(this.state.normalizer) 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, + submission: { + x: submission.reserveX, + y: submission.reserveY, + spot: ammSpot(submission), + k: ammK(submission), }, normalizer: { - x: normalizerAmm.reserveX, - y: normalizerAmm.reserveY, - k: propAmmK(normalizerAmm), - feeBps: this.state.normalizerConfig.feeBps, - liquidityMult: this.state.normalizerConfig.liquidityMult, + x: normalizer.reserveX, + y: normalizer.reserveY, + spot: ammSpot(normalizer), + k: ammK(normalizer), + feeBps: this.state.regime.normFeeBps, + liquidityMult: this.state.regime.normLiquidityMult, + }, + edge: { + total: this.state.edge.total, + retail: this.state.edge.retail, + arb: this.state.edge.arb, }, - edge: { ...this.state.edge }, - simulationParams: { - volatility: this.state.simulationParams.volatility, - arrivalRate: this.state.simulationParams.arrivalRate, + regime: this.state.regime, + storage: { + lastChangedBytes: this.state.lastStorageChangedBytes, + lastWriteStep: this.state.lastStorageWriteStep, }, } } - 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 toLegacySnapshot(snapshot: PropSnapshot): LegacySnapshot { + return { + step: snapshot.step, + fairPrice: snapshot.fairPrice, + strategy: { + x: snapshot.submission.x, + y: snapshot.submission.y, + bid: 500, + ask: 500, + k: snapshot.submission.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, + } + } + + private refreshViewWindow(): void { + if (!this.state.currentSnapshot) { + return + } + + const legacy = this.toLegacySnapshot(this.state.currentSnapshot) + const targetX = Math.sqrt(legacy.strategy.k / Math.max(legacy.fairPrice, 1e-9)) + const targetY = legacy.strategy.k / Math.max(targetX, 1e-9) + + this.state.viewWindow = getChartViewWindow( + legacy, + targetX, + targetY, + this.state.reserveTrail, + this.state.viewWindow, + ) + } + + private trackReservePoint(snapshot: PropSnapshot): void { + const point = { x: snapshot.submission.x, y: snapshot.submission.y } + const last = this.state.reserveTrail[this.state.reserveTrail.length - 1] + + if (last && Math.abs(last.x - point.x) < 1e-6 && Math.abs(last.y - point.y) < 1e-3) { + return + } + + this.state.reserveTrail.push(point) + if (this.state.reserveTrail.length > 180) { + this.state.reserveTrail.shift() + } } private requireAmm(amm: PropAmmState | null): PropAmmState { if (!amm) { - throw new Error('AMM state not initialized') + throw new Error('Prop AMM state is not initialized') } + return amm } @@ -561,17 +684,20 @@ export class PropSimulationEngine { isPlaying: boolean, ): PropWorkerUiState { if (!this.state.currentSnapshot || !this.state.lastEvent) { - throw new Error('Simulation is not initialized') + throw new Error('Prop simulation is not initialized') } return { - config: this.state.config, + config: { + ...this.state.config, + nSteps: this.state.config.nSteps || PROP_DEFAULT_STEPS, + }, 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, + modelUsed: this.state.strategy.modelUsed, }, isPlaying, tradeCount: this.state.tradeCount, @@ -581,7 +707,6 @@ export class PropSimulationEngine { 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 index f8ea742..0306611 100644 --- a/lib/prop-sim/index.ts +++ b/lib/prop-sim/index.ts @@ -1,4 +1,10 @@ -export * from './types' export * from './constants' -export * from './math' +export * from './types' +export * from './nano' +export * from './rng' +export * from './priceProcess' +export * from './amm' +export * from './arbitrage' +export * from './router' +export * from './retail' export * from './engine' diff --git a/lib/prop-sim/math.ts b/lib/prop-sim/math.ts deleted file mode 100644 index 9ad2b28..0000000 --- a/lib/prop-sim/math.ts +++ /dev/null @@ -1,511 +0,0 @@ -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/nano.ts b/lib/prop-sim/nano.ts new file mode 100644 index 0000000..1a792e4 --- /dev/null +++ b/lib/prop-sim/nano.ts @@ -0,0 +1,77 @@ +import { PROP_NANO_SCALE, PROP_NANO_SCALE_F64, PROP_STORAGE_SIZE, PROP_U64_MAX } from './constants' + +export function clampU64(value: bigint): bigint { + if (value <= 0n) return 0n + if (value >= PROP_U64_MAX) return PROP_U64_MAX + return value +} + +export function toNano(value: number): bigint { + if (Number.isNaN(value) || value <= 0) { + return 0n + } + + if (!Number.isFinite(value)) { + return PROP_U64_MAX + } + + const scaled = value * PROP_NANO_SCALE_F64 + if (scaled >= Number(PROP_U64_MAX)) { + return PROP_U64_MAX + } + + return BigInt(Math.floor(scaled)) +} + +export function fromNano(value: bigint): number { + return Number(clampU64(value)) / PROP_NANO_SCALE_F64 +} + +export function ceilDiv(numerator: bigint, denominator: bigint): bigint { + if (denominator <= 0n) { + return 0n + } + + return (numerator + denominator - 1n) / denominator +} + +export function saturatingSub(a: bigint, b: bigint): bigint { + return a > b ? a - b : 0n +} + +export function encodeU16Le(value: number): Uint8Array { + const normalized = Math.max(0, Math.min(0xffff, Math.trunc(value))) + return new Uint8Array([normalized & 0xff, (normalized >>> 8) & 0xff]) +} + +export function readU16Le(storage: Uint8Array, offset = 0): number { + if (offset < 0 || offset + 1 >= storage.length) { + return 0 + } + + return storage[offset] | (storage[offset + 1] << 8) +} + +export function ensureStorageSize(storage: Uint8Array): Uint8Array { + if (storage.length === PROP_STORAGE_SIZE) { + return storage + } + + const next = new Uint8Array(PROP_STORAGE_SIZE) + next.set(storage.subarray(0, PROP_STORAGE_SIZE)) + return next +} + +export function bigintToString(value: bigint): string { + return clampU64(value).toString() +} + +export function stringToBigint(value: string): bigint { + try { + return clampU64(BigInt(value)) + } catch { + return 0n + } +} + +export { PROP_NANO_SCALE } diff --git a/lib/prop-sim/priceProcess.ts b/lib/prop-sim/priceProcess.ts new file mode 100644 index 0000000..2747a5e --- /dev/null +++ b/lib/prop-sim/priceProcess.ts @@ -0,0 +1,22 @@ +export class GbmPriceProcess { + private current: number + + private readonly driftTerm: number + + private readonly volTerm: number + + constructor(initialPrice: number, mu: number, sigma: number, dt: number) { + this.current = initialPrice + this.driftTerm = (mu - 0.5 * sigma * sigma) * dt + this.volTerm = sigma * Math.sqrt(dt) + } + + public currentPrice(): number { + return this.current + } + + public step(gaussianShock: number): number { + this.current *= Math.exp(this.driftTerm + this.volTerm * gaussianShock) + return this.current + } +} diff --git a/lib/prop-sim/retail.ts b/lib/prop-sim/retail.ts new file mode 100644 index 0000000..0701b89 --- /dev/null +++ b/lib/prop-sim/retail.ts @@ -0,0 +1,49 @@ +import { + PROP_MIN_INPUT, + PROP_RETAIL_BUY_PROB, + PROP_RETAIL_SIZE_SIGMA, +} from './constants' +import type { PropRetailOrder } from './types' + +export function samplePoisson(lambda: number, randomUnit: () => number): number { + const clamped = Math.max(0.01, lambda) + const threshold = Math.exp(-clamped) + + let count = 0 + let product = 1 + + while (product > threshold) { + count += 1 + product *= Math.max(1e-12, randomUnit()) + } + + return count - 1 +} + +export function sampleLogNormal(mean: number, sigma: number, gaussianRandom: () => number): number { + const sigmaSafe = Math.max(0.01, sigma) + const muLn = Math.log(Math.max(0.01, mean)) - 0.5 * sigmaSafe * sigmaSafe + const sample = Math.exp(muLn + sigmaSafe * gaussianRandom()) + return Math.max(PROP_MIN_INPUT, sample) +} + +export function generateRetailOrders( + arrivalRate: number, + meanSizeY: number, + randomUnit: () => number, + gaussianRandom: () => number, +): PropRetailOrder[] { + const count = samplePoisson(arrivalRate, randomUnit) + if (count <= 0) { + return [] + } + + const orders: PropRetailOrder[] = [] + for (let index = 0; index < count; index += 1) { + const side = randomUnit() < PROP_RETAIL_BUY_PROB ? 'buy' : 'sell' + const sizeY = sampleLogNormal(meanSizeY, PROP_RETAIL_SIZE_SIGMA, gaussianRandom) + orders.push({ side, sizeY }) + } + + return orders +} diff --git a/lib/prop-sim/rng.ts b/lib/prop-sim/rng.ts new file mode 100644 index 0000000..f43115d --- /dev/null +++ b/lib/prop-sim/rng.ts @@ -0,0 +1,25 @@ +import { SeededRng } from '../sim/utils' + +export class PropRng { + private readonly rng: SeededRng + + constructor(seed: number) { + this.rng = new SeededRng(seed) + } + + public reset(seed: number): void { + this.rng.reset(seed) + } + + public next(): number { + return this.rng.next() + } + + public between(min: number, max: number): number { + return this.rng.between(min, max) + } + + public gaussian(): number { + return this.rng.gaussian() + } +} diff --git a/lib/prop-sim/router.ts b/lib/prop-sim/router.ts new file mode 100644 index 0000000..cfaa3e5 --- /dev/null +++ b/lib/prop-sim/router.ts @@ -0,0 +1,172 @@ +import { + GOLDEN_RATIO_CONJUGATE, + PROP_MIN_TRADE_SIZE, + PROP_ROUTER_ALPHA_TOL, + PROP_ROUTER_GOLDEN_MAX_ITERS, + PROP_ROUTER_SCORE_REL_GAP_TOL, + PROP_ROUTER_SUBMISSION_AMOUNT_REL_TOL, +} from './constants' +import type { PropOrderSide, PropRetailOrder } from './types' + +interface QuotePoint { + alpha: number + inSubmission: number + inNormalizer: number + outSubmission: number + outNormalizer: number +} + +export interface RouterDecision { + orderSide: PropOrderSide + alpha: number + submissionInput: number + normalizerInput: number + submissionOutput: number + normalizerOutput: number +} + +function quoteScore(point: QuotePoint): number { + const score = point.outSubmission + point.outNormalizer + return Number.isFinite(score) ? score : Number.NEGATIVE_INFINITY +} + +function bestQuote(a: QuotePoint, b: QuotePoint): QuotePoint { + return quoteScore(b) > quoteScore(a) ? b : a +} + +function withinRelGap(a: number, b: number, relTol: number): boolean { + if (!Number.isFinite(a) || !Number.isFinite(b)) { + return false + } + + const denominator = Math.max(1e-12, Math.abs(a), Math.abs(b)) + return Math.abs(a - b) <= relTol * denominator +} + +function maximizeSplit(totalInput: number, evaluate: (alpha: number) => QuotePoint): QuotePoint { + let left = 0 + let right = 1 + + const edgeLeft = evaluate(left) + const edgeRight = evaluate(right) + let best = bestQuote(edgeLeft, edgeRight) + + let x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) + let x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) + let q1 = evaluate(x1) + let q2 = evaluate(x2) + best = bestQuote(best, q1) + best = bestQuote(best, q2) + + for (let index = 0; index < PROP_ROUTER_GOLDEN_MAX_ITERS; index += 1) { + if (right - left <= PROP_ROUTER_ALPHA_TOL) { + break + } + + const midAlpha = 0.5 * (left + right) + const submissionMidAmount = totalInput * midAlpha + const amountWidth = totalInput * (right - left) + const amountScale = Math.max(PROP_MIN_TRADE_SIZE, Math.abs(submissionMidAmount)) + if (amountWidth <= PROP_ROUTER_SUBMISSION_AMOUNT_REL_TOL * amountScale) { + break + } + + if (withinRelGap(quoteScore(q1), quoteScore(q2), PROP_ROUTER_SCORE_REL_GAP_TOL)) { + break + } + + if (quoteScore(q1) < quoteScore(q2)) { + left = x1 + x1 = x2 + q1 = q2 + x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) + q2 = evaluate(x2) + best = bestQuote(best, q2) + } else { + right = x2 + x2 = x1 + q2 = q1 + x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) + q1 = evaluate(x1) + best = bestQuote(best, q1) + } + } + + const center = evaluate((left + right) * 0.5) + best = bestQuote(best, center) + + return best +} + +export function routeRetailOrder(args: { + order: PropRetailOrder + fairPrice: number + quoteSubmissionBuyX: (inputY: number) => number + quoteSubmissionSellX: (inputX: number) => number + quoteNormalizerBuyX: (inputY: number) => number + quoteNormalizerSellX: (inputX: number) => number +}): RouterDecision { + const { + order, + fairPrice, + quoteSubmissionBuyX, + quoteSubmissionSellX, + quoteNormalizerBuyX, + quoteNormalizerSellX, + } = args + + if (order.side === 'buy') { + const totalY = Math.max(0, order.sizeY) + + const best = maximizeSplit(totalY, (alpha) => { + const inSubmission = totalY * Math.min(1, Math.max(0, alpha)) + const inNormalizer = totalY * (1 - Math.min(1, Math.max(0, alpha))) + + const outSubmission = inSubmission > PROP_MIN_TRADE_SIZE ? quoteSubmissionBuyX(inSubmission) : 0 + const outNormalizer = inNormalizer > PROP_MIN_TRADE_SIZE ? quoteNormalizerBuyX(inNormalizer) : 0 + + return { + alpha, + inSubmission, + inNormalizer, + outSubmission, + outNormalizer, + } + }) + + return { + orderSide: order.side, + alpha: best.alpha, + submissionInput: best.inSubmission, + normalizerInput: best.inNormalizer, + submissionOutput: best.outSubmission, + normalizerOutput: best.outNormalizer, + } + } + + const totalX = order.sizeY / Math.max(fairPrice, 1e-9) + const best = maximizeSplit(totalX, (alpha) => { + const inSubmission = totalX * Math.min(1, Math.max(0, alpha)) + const inNormalizer = totalX * (1 - Math.min(1, Math.max(0, alpha))) + + const outSubmission = inSubmission > PROP_MIN_TRADE_SIZE ? quoteSubmissionSellX(inSubmission) : 0 + const outNormalizer = inNormalizer > PROP_MIN_TRADE_SIZE ? quoteNormalizerSellX(inNormalizer) : 0 + + return { + alpha, + inSubmission, + inNormalizer, + outSubmission, + outNormalizer, + } + }) + + return { + orderSide: order.side, + alpha: best.alpha, + submissionInput: best.inSubmission, + normalizerInput: best.inNormalizer, + submissionOutput: best.outSubmission, + normalizerOutput: best.outNormalizer, + } +} diff --git a/lib/prop-sim/types.ts b/lib/prop-sim/types.ts index b9168b3..f638fc6 100644 --- a/lib/prop-sim/types.ts +++ b/lib/prop-sim/types.ts @@ -1,4 +1,7 @@ -export type PropFlowType = 'arbitrage' | 'retail' | 'system' +export type PropFlowType = 'system' | 'arbitrage' | 'retail' +export type PropPool = 'submission' | 'normalizer' +export type PropOrderSide = 'buy' | 'sell' +export type PropSwapSide = 0 | 1 export type PropStrategyKind = 'builtin' @@ -12,85 +15,117 @@ export interface PropSimulationConfig { strategyRef: PropStrategyRef playbackSpeed: number maxTapeRows: number + nSteps: number +} + +export interface PropSampledRegime { + gbmSigma: number + retailArrivalRate: number + retailMeanSize: number + normFeeBps: number + normLiquidityMult: number +} + +export interface PropStorageSummary { + lastChangedBytes: number + lastWriteStep: number | null } export interface PropAmmState { + pool: PropPool name: string reserveX: number reserveY: number - isStrategy: boolean + storage: Uint8Array +} + +export interface PropSwapInstruction { + side: PropSwapSide + inputAmountNano: bigint + reserveXNano: bigint + reserveYNano: bigint + storage: Uint8Array +} + +export interface PropAfterSwapInstruction { + side: PropSwapSide + inputAmountNano: bigint + outputAmountNano: bigint + reserveXNano: bigint + reserveYNano: bigint + step: number + storage: Uint8Array } -export interface PropNormalizerConfig { - feeBps: number // Sampled per simulation: 30-80 - liquidityMult: number // Sampled per simulation: 0.4-2.0 +export interface PropStrategyRuntime { + ref: PropStrategyRef + name: string + code: string + modelUsed: string + computeSwap: (instruction: PropSwapInstruction) => bigint + afterSwap: (instruction: PropAfterSwapInstruction) => Uint8Array | void } export interface PropTrade { - side: 'buy' | 'sell' // buy = AMM buys X (receives X, pays Y), sell = AMM sells X + side: PropSwapSide + direction: 'buy_x' | 'sell_x' inputAmount: number outputAmount: number - timestamp: number - reserveX: number // Post-trade - reserveY: number + inputAmountNano: string + outputAmountNano: string beforeX: number beforeY: number + reserveX: number + reserveY: 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 PropRetailOrder { + side: PropOrderSide + sizeY: number } -export interface PropSnapshotNormalizer { +export interface PropSnapshotAmm { x: number y: number + spot: number k: number - feeBps: number - liquidityMult: number } export interface PropSnapshot { step: number fairPrice: number - strategy: PropSnapshotAmm - normalizer: PropSnapshotNormalizer + submission: PropSnapshotAmm + normalizer: PropSnapshotAmm & { + feeBps: number + liquidityMult: number + } 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[] + regime: PropSampledRegime + storage: PropStorageSummary } export interface PropTradeEvent { id: number step: number flow: PropFlowType - ammName: string - isStrategyTrade: boolean + pool: PropPool + poolName: string + isSubmissionTrade: boolean trade: PropTrade | null - order: { side: 'buy' | 'sell'; sizeY: number } | null + order: PropRetailOrder | null + routerSplit: + | { + alpha: number + submissionInput: number + normalizerInput: number + } + | null arbProfit: number fairPrice: number priceMove: { from: number; to: number } @@ -99,56 +134,8 @@ export interface PropTradeEvent { codeExplanation: string stateBadge: string summary: string + storageChangedBytes: number 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 { @@ -158,7 +145,7 @@ export interface PropWorkerUiState { id: string name: string code: string - feeBps: number + modelUsed: string } isPlaying: boolean tradeCount: number @@ -168,14 +155,4 @@ export interface PropWorkerUiState { 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 index 572bd92..0f4a795 100644 --- a/lib/prop-strategies/builtins.ts +++ b/lib/prop-strategies/builtins.ts @@ -1,231 +1,84 @@ -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], +import { ceilDiv, clampU64, ensureStorageSize, saturatingSub } from '../prop-sim/nano' +import type { PropAfterSwapInstruction, PropStrategyRef, PropStrategyRuntime, PropSwapInstruction } from '../prop-sim/types' +import { STARTER_CODE_LINES, STARTER_STRATEGY_SOURCE } from './starterSource' + +interface PropBuiltinStrategy { + id: string + name: string + modelUsed: string + code: string + computeSwap: (instruction: PropSwapInstruction) => bigint + afterSwap: (instruction: PropAfterSwapInstruction) => Uint8Array | void } -#[cfg(not(feature = "no-entrypoint"))] -entrypoint!(process_instruction); +const STARTER_FEE_NUMERATOR = 950n +const STARTER_FEE_DENOMINATOR = 1000n -pub fn process_instruction( - _program_id: &Pubkey, _accounts: &[AccountInfo], instruction_data: &[u8], -) -> ProgramResult { - if instruction_data.is_empty() { - return Ok(()); - } +function starterComputeSwap(instruction: PropSwapInstruction): bigint { + const reserveX = clampU64(instruction.reserveXNano) + const reserveY = clampU64(instruction.reserveYNano) + const inputAmount = clampU64(instruction.inputAmountNano) - 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, - }; + if (reserveX === 0n || reserveY === 0n) { + return 0n + } - 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; + const k = reserveX * reserveY - if reserve_x == 0 || reserve_y == 0 { - return 0; - } + if (instruction.side === 0) { + const netY = (inputAmount * STARTER_FEE_NUMERATOR) / STARTER_FEE_DENOMINATOR + const newReserveY = reserveY + netY + const kDiv = ceilDiv(k, newReserveY) + return saturatingSub(reserveX, kDiv) + } - let k = reserve_x * reserve_y; + if (instruction.side === 1) { + const netX = (inputAmount * STARTER_FEE_NUMERATOR) / STARTER_FEE_DENOMINATOR + const newReserveX = reserveX + netX + const kDiv = ceilDiv(k, newReserveX) + return saturatingSub(reserveY, kDiv) + } - 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, - } + return 0n } -/// 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 - } - } +function starterAfterSwap(instruction: PropAfterSwapInstruction): Uint8Array { + return ensureStorageSize(instruction.storage) } -export const PROP_BUILTIN_STRATEGIES: PropBuiltinStrategy[] = [ +const BUILTIN_STRATEGIES: PropBuiltinStrategy[] = [ { - id: 'starter-500bps', + id: 'starter', 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 + modelUsed: 'GPT-5.3-Codex', + code: STARTER_STRATEGY_SOURCE, + computeSwap: starterComputeSwap, + afterSwap: starterAfterSwap, }, ] -export function getPropBuiltinStrategyById(id: string): PropBuiltinStrategy | undefined { - return PROP_BUILTIN_STRATEGIES.find((s) => s.id === id) +export const PROP_BUILTIN_STRATEGIES = BUILTIN_STRATEGIES.map((strategy) => ({ + kind: 'builtin' as const, + id: strategy.id, + name: strategy.name, +})) + +export function getPropBuiltinStrategy(ref: PropStrategyRef): PropStrategyRuntime { + const strategy = BUILTIN_STRATEGIES.find((item) => item.id === ref.id) + if (!strategy) { + throw new Error(`Builtin strategy '${ref.id}' not found.`) + } + + return { + ref, + name: strategy.name, + code: strategy.code, + modelUsed: strategy.modelUsed, + computeSwap: strategy.computeSwap, + afterSwap: strategy.afterSwap, + } +} + +export function getStarterCodeLines(side: 0 | 1): number[] { + return side === 0 ? STARTER_CODE_LINES.buyBranch : STARTER_CODE_LINES.sellBranch } diff --git a/lib/prop-strategies/starterSource.ts b/lib/prop-strategies/starterSource.ts new file mode 100644 index 0000000..9130bb2 --- /dev/null +++ b/lib/prop-strategies/starterSource.ts @@ -0,0 +1,94 @@ +export const STARTER_STRATEGY_SOURCE = `use pinocchio::{account_info::AccountInfo, entrypoint, pubkey::Pubkey, ProgramResult}; +use prop_amm_submission_sdk::{set_return_data_bytes, set_return_data_u64}; + +const NAME: &str = "My Strategy"; +const MODEL_USED: &str = "GPT-5.3-Codex"; // Use "None" for fully human-written submissions. +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] { + // tag 0 or 1 = compute_swap (side) + 0 | 1 => { + let output = compute_swap(instruction_data); + set_return_data_u64(output); + } + // tag 2 = after_swap (no-op for starter) + 2 => { + // No storage updates needed for basic CFMM + } + // tag 3 = get_name (for leaderboard display) + 3 => set_return_data_bytes(NAME.as_bytes()), + // tag 4 = get_model_used (for metadata display) + 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 => { + 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; + reserve_x.saturating_sub(k_div) as u64 + } + 1 => { + 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; + reserve_y.saturating_sub(k_div) as u64 + } + _ => 0, + } +} +` + +export const STARTER_CODE_LINES = { + reserveGuard: [66, 67], + buyBranch: [73, 74, 75, 76], + sellBranch: [79, 80, 81, 82], +} diff --git a/store/usePropPlaybackStore.ts b/store/usePropPlaybackStore.ts new file mode 100644 index 0000000..4fe2d33 --- /dev/null +++ b/store/usePropPlaybackStore.ts @@ -0,0 +1,18 @@ +'use client' + +import { create } from 'zustand' +import type { PropWorkerUiState } from '../lib/prop-sim/types' + +interface PropPlaybackStoreState { + workerState: PropWorkerUiState | null + workerError: string | null + setWorkerState: (state: PropWorkerUiState) => void + setWorkerError: (message: string | null) => void +} + +export const usePropPlaybackStore = create((set) => ({ + workerState: null, + workerError: null, + setWorkerState: (workerState) => set({ workerState }), + setWorkerError: (workerError) => set({ workerError }), +})) diff --git a/store/usePropUiStore.ts b/store/usePropUiStore.ts new file mode 100644 index 0000000..3becc0a --- /dev/null +++ b/store/usePropUiStore.ts @@ -0,0 +1,54 @@ +'use client' + +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { PROP_DEFAULT_STEPS } from '../lib/prop-sim/constants' +import type { PropStrategyRef } from '../lib/prop-sim/types' + +interface PropUiStoreState { + playbackSpeed: number + maxTapeRows: number + nSteps: number + strategyRef: PropStrategyRef + showCodeExplanation: boolean + chartAutoZoom: boolean + setPlaybackSpeed: (value: number) => void + setMaxTapeRows: (value: number) => void + setNSteps: (value: number) => void + setStrategyRef: (value: PropStrategyRef) => void + setShowCodeExplanation: (value: boolean) => void + setChartAutoZoom: (value: boolean) => void +} + +export const usePropUiStore = create()( + persist( + (set) => ({ + playbackSpeed: 3, + maxTapeRows: 20, + nSteps: PROP_DEFAULT_STEPS, + strategyRef: { + kind: 'builtin', + id: 'starter', + }, + showCodeExplanation: true, + chartAutoZoom: true, + setPlaybackSpeed: (playbackSpeed) => set({ playbackSpeed }), + setMaxTapeRows: (maxTapeRows) => set({ maxTapeRows }), + setNSteps: (nSteps) => set({ nSteps: Math.max(1, Math.trunc(nSteps) || PROP_DEFAULT_STEPS) }), + setStrategyRef: (strategyRef) => set({ strategyRef }), + setShowCodeExplanation: (showCodeExplanation) => set({ showCodeExplanation }), + setChartAutoZoom: (chartAutoZoom) => set({ chartAutoZoom }), + }), + { + name: 'ammvisualizer-prop-ui-v1', + partialize: (state) => ({ + playbackSpeed: state.playbackSpeed, + maxTapeRows: state.maxTapeRows, + nSteps: state.nSteps, + strategyRef: state.strategyRef, + showCodeExplanation: state.showCodeExplanation, + chartAutoZoom: state.chartAutoZoom, + }), + }, + ), +) diff --git a/tests/prop-engine-determinism.test.ts b/tests/prop-engine-determinism.test.ts new file mode 100644 index 0000000..fb037c7 --- /dev/null +++ b/tests/prop-engine-determinism.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { PropSimulationEngine } from '../lib/prop-sim/engine' +import { PROP_DEFAULT_STEPS } from '../lib/prop-sim/constants' +import { PropRng } from '../lib/prop-sim/rng' +import { getPropBuiltinStrategy, PROP_BUILTIN_STRATEGIES } from '../lib/prop-strategies/builtins' +import type { PropSimulationConfig } from '../lib/prop-sim/types' + +const baseConfig: PropSimulationConfig = { + seed: 1337, + strategyRef: { kind: 'builtin', id: 'starter' }, + playbackSpeed: 3, + maxTapeRows: 20, + nSteps: PROP_DEFAULT_STEPS, +} + +function runSeries(seed: number): Array<{ step: number; fairPrice: number; edge: number; storageDelta: number }> { + const config = { ...baseConfig, seed } + const runtime = getPropBuiltinStrategy(config.strategyRef) + const engine = new PropSimulationEngine(config, runtime) + const rng = new PropRng(seed) + + engine.reset(rng) + + const series: Array<{ step: number; fairPrice: number; edge: number; storageDelta: number }> = [] + let guard = 0 + + while (series.length < 30 && guard < 400) { + guard += 1 + const advanced = engine.stepOne(rng) + if (!advanced) { + break + } + + const state = engine.toUiState(PROP_BUILTIN_STRATEGIES, false) + series.push({ + step: state.snapshot.step, + fairPrice: Number(state.snapshot.fairPrice.toFixed(8)), + edge: Number(state.snapshot.edge.total.toFixed(8)), + storageDelta: state.snapshot.storage.lastChangedBytes, + }) + } + + return series +} + +describe('prop simulation engine determinism', () => { + it('replays same sequence for same seed', () => { + const first = runSeries(1337) + const second = runSeries(1337) + expect(first).toEqual(second) + }) + + it('produces a different sequence for different seeds', () => { + const first = runSeries(1337) + const second = runSeries(1338) + expect(first).not.toEqual(second) + }) + + it('maintains finite positive reserves in short runs', () => { + const config = { ...baseConfig, seed: 42 } + const runtime = getPropBuiltinStrategy(config.strategyRef) + const engine = new PropSimulationEngine(config, runtime) + const rng = new PropRng(config.seed) + + engine.reset(rng) + + for (let index = 0; index < 80; index += 1) { + engine.stepOne(rng) + const state = engine.toUiState(PROP_BUILTIN_STRATEGIES, false) + expect(Number.isFinite(state.snapshot.submission.x)).toBe(true) + expect(Number.isFinite(state.snapshot.submission.y)).toBe(true) + expect(Number.isFinite(state.snapshot.normalizer.x)).toBe(true) + expect(Number.isFinite(state.snapshot.normalizer.y)).toBe(true) + expect(state.snapshot.submission.x).toBeGreaterThan(0) + expect(state.snapshot.submission.y).toBeGreaterThan(0) + expect(state.snapshot.normalizer.x).toBeGreaterThan(0) + expect(state.snapshot.normalizer.y).toBeGreaterThan(0) + } + }) +}) diff --git a/tests/prop-nano.test.ts b/tests/prop-nano.test.ts new file mode 100644 index 0000000..bb762e9 --- /dev/null +++ b/tests/prop-nano.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { PROP_U64_MAX } from '../lib/prop-sim/constants' +import { ceilDiv, encodeU16Le, fromNano, readU16Le, saturatingSub, toNano } from '../lib/prop-sim/nano' + +describe('prop nano helpers', () => { + it('converts f64 values to nano with floor semantics', () => { + expect(toNano(1)).toBe(1_000_000_000n) + expect(toNano(1.2345678919)).toBe(1_234_567_891n) + expect(toNano(0)).toBe(0n) + expect(toNano(-5)).toBe(0n) + expect(toNano(Number.NaN)).toBe(0n) + expect(toNano(Number.POSITIVE_INFINITY)).toBe(PROP_U64_MAX) + }) + + it('converts nano back to f64', () => { + expect(fromNano(1_000_000_000n)).toBeCloseTo(1, 9) + expect(fromNano(123_456_789n)).toBeCloseTo(0.123456789, 12) + }) + + it('supports ceil division and saturating subtraction', () => { + expect(ceilDiv(10n, 3n)).toBe(4n) + expect(ceilDiv(9n, 3n)).toBe(3n) + expect(saturatingSub(10n, 5n)).toBe(5n) + expect(saturatingSub(5n, 10n)).toBe(0n) + }) + + it('encodes and decodes u16 little-endian values for storage', () => { + const encoded = encodeU16Le(80) + expect(encoded.length).toBe(2) + + const storage = new Uint8Array(1024) + storage.set(encoded, 0) + expect(readU16Le(storage, 0)).toBe(80) + }) +}) diff --git a/tests/prop-starter-runtime.test.ts b/tests/prop-starter-runtime.test.ts new file mode 100644 index 0000000..2635453 --- /dev/null +++ b/tests/prop-starter-runtime.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { ceilDiv, saturatingSub } from '../lib/prop-sim/nano' +import { getPropBuiltinStrategy } from '../lib/prop-strategies/builtins' +import type { PropSwapInstruction } from '../lib/prop-sim/types' + +function expectedStarterSwap(side: 0 | 1, input: bigint, reserveX: bigint, reserveY: bigint): bigint { + if (reserveX === 0n || reserveY === 0n) { + return 0n + } + + const k = reserveX * reserveY + + if (side === 0) { + const netY = (input * 950n) / 1000n + const newReserveY = reserveY + netY + return saturatingSub(reserveX, ceilDiv(k, newReserveY)) + } + + const netX = (input * 950n) / 1000n + const newReserveX = reserveX + netX + return saturatingSub(reserveY, ceilDiv(k, newReserveX)) +} + +describe('starter builtin strategy runtime', () => { + const runtime = getPropBuiltinStrategy({ kind: 'builtin', id: 'starter' }) + + it('matches starter buy-side compute_swap behavior', () => { + const instruction: PropSwapInstruction = { + side: 0, + inputAmountNano: 10_000_000_000n, + reserveXNano: 100_000_000_000n, + reserveYNano: 10_000_000_000_000n, + storage: new Uint8Array(1024), + } + + const actual = runtime.computeSwap(instruction) + const expected = expectedStarterSwap(0, instruction.inputAmountNano, instruction.reserveXNano, instruction.reserveYNano) + expect(actual).toBe(expected) + }) + + it('matches starter sell-side compute_swap behavior', () => { + const instruction: PropSwapInstruction = { + side: 1, + inputAmountNano: 2_500_000_000n, + reserveXNano: 100_000_000_000n, + reserveYNano: 10_000_000_000_000n, + storage: new Uint8Array(1024), + } + + const actual = runtime.computeSwap(instruction) + const expected = expectedStarterSwap(1, instruction.inputAmountNano, instruction.reserveXNano, instruction.reserveYNano) + expect(actual).toBe(expected) + }) + + it('keeps storage unchanged in afterSwap (starter no-op)', () => { + const storage = new Uint8Array(1024) + storage[0] = 45 + storage[12] = 200 + + const next = runtime.afterSwap({ + side: 0, + inputAmountNano: 1n, + outputAmountNano: 1n, + reserveXNano: 1n, + reserveYNano: 1n, + step: 1, + storage, + }) + + const output = next ?? storage + expect(output[0]).toBe(45) + expect(output[12]).toBe(200) + }) +}) diff --git a/workers/prop-messages.ts b/workers/prop-messages.ts new file mode 100644 index 0000000..fcca67d --- /dev/null +++ b/workers/prop-messages.ts @@ -0,0 +1,41 @@ +import type { PropSimulationConfig, PropWorkerUiState } from '../lib/prop-sim/types' + +export type PropWorkerInboundMessage = + | { + type: 'INIT_PROP_SIM' + payload?: { + config?: Partial + } + } + | { + type: 'SET_PROP_CONFIG' + payload: { + config: Partial + } + } + | { + type: 'STEP_PROP_ONE' + } + | { + type: 'PLAY_PROP' + } + | { + type: 'PAUSE_PROP' + } + | { + type: 'RESET_PROP' + } + +export type PropWorkerOutboundMessage = + | { + type: 'PROP_STATE' + payload: { + state: PropWorkerUiState + } + } + | { + type: 'PROP_ERROR' + payload: { + message: string + } + } diff --git a/workers/prop-simulation.worker.ts b/workers/prop-simulation.worker.ts index bfe2891..504e4d8 100644 --- a/workers/prop-simulation.worker.ts +++ b/workers/prop-simulation.worker.ts @@ -1,240 +1,225 @@ 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 -// ============================================================================ - +import { PROP_DEFAULT_STEPS, PROP_SPEED_PROFILE } from '../lib/prop-sim/constants' +import { PropRng } from '../lib/prop-sim/rng' +import type { PropSimulationConfig } from '../lib/prop-sim/types' +import { getPropBuiltinStrategy, PROP_BUILTIN_STRATEGIES } from '../lib/prop-strategies/builtins' +import type { PropWorkerInboundMessage, PropWorkerOutboundMessage } from './prop-messages' + +const worker = self as unknown as { + postMessage: (message: PropWorkerOutboundMessage) => void + onmessage: ((event: MessageEvent) => void) | null + setInterval: typeof setInterval + clearInterval: typeof clearInterval +} + +const DEFAULT_CONFIG: PropSimulationConfig = { + seed: 1337, + strategyRef: { + kind: 'builtin', + id: 'starter', + }, + playbackSpeed: 3, + maxTapeRows: 20, + nSteps: PROP_DEFAULT_STEPS, +} + +let config: PropSimulationConfig = { ...DEFAULT_CONFIG } 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) -} +let rng = new PropRng(config.seed) -function randomBetween(min: number, max: number): number { - return min + rng() * (max - min) -} +let isPlaying = false +let playTimer: ReturnType | null = null +let stepping = false +let messageQueue: Promise = Promise.resolve() + +worker.onmessage = (event) => { + const inbound = event.data + messageQueue = messageQueue + .then(async () => { + await handleMessage(inbound) + }) + .catch((error) => { + emitError(error) + }) +} + +async function handleMessage(message: PropWorkerInboundMessage): Promise { + switch (message.type) { + case 'INIT_PROP_SIM': { + config = { + ...config, + ...message.payload?.config, + } + config.nSteps = config.nSteps || PROP_DEFAULT_STEPS + rng.reset(config.seed) + await ensureEngineInitialized() + await resetEngine() + emitState() + break + } + + case 'SET_PROP_CONFIG': { + const previous = config + const next: PropSimulationConfig = { + ...config, + ...message.payload.config, + nSteps: message.payload.config.nSteps ?? config.nSteps, + } + config = next -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) -} + const seedChanged = next.seed !== previous.seed + const strategyChanged = + next.strategyRef.kind !== previous.strategyRef.kind || + next.strategyRef.id !== previous.strategyRef.id + const nStepsChanged = next.nSteps !== previous.nSteps -// ============================================================================ -// Strategy Runtime Factory -// ============================================================================ + await ensureEngineInitialized() + engine!.setConfig(next) -function createActiveStrategyRuntime(ref: PropStrategyRef): PropActiveStrategyRuntime { - if (ref.kind !== 'builtin') { - throw new Error('Only builtin strategies are supported') - } + if (seedChanged) { + rng.reset(next.seed) + } - const builtin = getPropBuiltinStrategyById(ref.id) - if (!builtin) { - throw new Error(`Unknown builtin strategy: ${ref.id}`) - } + if (strategyChanged || seedChanged || nStepsChanged) { + await resetEngine() + } - 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 + if (isPlaying && next.playbackSpeed !== previous.playbackSpeed) { + restartPlaybackTimer() } - 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() + emitState() break - case 'pause': - handlePause() + } + + case 'STEP_PROP_ONE': { + stopPlayback() + await ensureEngineInitialized() + engine!.stepOne(rng) + emitState() break - case 'step': - handleStep() + } + + case 'PLAY_PROP': { + await ensureEngineInitialized() + startPlayback() + emitState() break - case 'reset': - handleReset() + } + + case 'PAUSE_PROP': { + stopPlayback() + emitState() break - case 'getState': - postState() + } + + case 'RESET_PROP': { + stopPlayback() + await ensureEngineInitialized() + await resetEngine() + emitState() break + } + + default: { + const unsupported: never = message + throw new Error(`Unsupported Prop worker message: ${JSON.stringify(unsupported)}`) + } } } -function handleInit(config: PropSimulationConfig): void { - seedRng(config.seed) - const strategy = createActiveStrategyRuntime(config.strategyRef) +async function ensureEngineInitialized(): Promise { + if (engine) { + return + } + + const strategy = getPropBuiltinStrategy(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) +async function resetEngine(): Promise { + if (!engine) { + return } - - postState() -} -function handleSetStrategy(strategyRef: PropStrategyRef): void { - if (!engine) return - const strategy = createActiveStrategyRuntime(strategyRef) + const strategy = getPropBuiltinStrategy(config.strategyRef) engine.setStrategy(strategy) - engine.reset(randomBetween) - postState() + engine.setConfig(config) + rng.reset(config.seed) + engine.reset(rng) } -function handlePlay(): void { - if (!engine || isPlaying) return +function startPlayback(): 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() + restartPlaybackTimer() } -function handlePause(): void { +function stopPlayback(): void { isPlaying = false - if (playbackInterval) { - clearInterval(playbackInterval) - playbackInterval = null + + if (playTimer) { + worker.clearInterval(playTimer) + playTimer = null } - postState() } -function handleStep(): void { - if (!engine) return - if (isPlaying) { - handlePause() +function restartPlaybackTimer(): void { + if (!engine) { + return } - engine.stepOne(randomBetween, gaussianRandom) - postState() -} -function handleReset(): void { - if (!engine) return - handlePause() - seedRng(engine['state'].config.seed) - engine.reset(randomBetween) - postState() -} + if (playTimer) { + worker.clearInterval(playTimer) + playTimer = null + } -function tick(): void { - if (!engine || !isPlaying) return - engine.stepOne(randomBetween, gaussianRandom) - postState() -} + const profile = PROP_SPEED_PROFILE[config.playbackSpeed] ?? PROP_SPEED_PROFILE[3] -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 }) -} + playTimer = worker.setInterval(() => { + if (!engine || !isPlaying || stepping) { + return + } -// ============================================================================ -// Worker Entry -// ============================================================================ + try { + stepping = true + const advanced = engine.stepOne(rng) + if (!advanced) { + stopPlayback() + } + emitState() + } catch (error) { + stopPlayback() + emitError(error) + } finally { + stepping = false + } + }, profile.ms) +} + +function emitState(): void { + if (!engine) { + return + } -self.onmessage = (event: MessageEvent) => { - handleMessage(event.data) + worker.postMessage({ + type: 'PROP_STATE', + payload: { + state: engine.toUiState(PROP_BUILTIN_STRATEGIES, isPlaying), + }, + }) } -// Signal ready -self.postMessage({ type: 'ready' }) +function emitError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + + worker.postMessage({ + type: 'PROP_ERROR', + payload: { + message, + }, + }) +} From 65dcc812ee7685409126a063c608ab58845e0b80 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sun, 15 Feb 2026 18:34:25 -0500 Subject: [PATCH 3/7] Revert "feat: implement prop amm visualizer runtime and themed chart" This reverts commit 3fdb7ea7e323c3ddce05d721a4c8c871e441ff98. --- PROP-AMM-VISUALIZER-SPEC.md | 655 +++++++++++----------- app/globals.css | 118 ---- app/page.tsx | 1 - app/prop-amm/page.tsx | 146 +++-- components/HeaderActions.tsx | 11 +- components/PropCodePanel.tsx | 96 ++++ components/PropMarketPanel.tsx | 418 ++++++++++++++ components/prop/PropAmmChart.tsx | 304 ---------- components/prop/PropCodePanel.tsx | 130 ----- components/prop/PropMarketPanel.tsx | 250 --------- hooks/usePropSimulationWorker.ts | 166 +++--- lib/prop-sim/amm.ts | 194 ------- lib/prop-sim/arbitrage.ts | 291 ---------- lib/prop-sim/constants.ts | 76 +-- lib/prop-sim/engine.ts | 775 +++++++++++--------------- lib/prop-sim/index.ts | 10 +- lib/prop-sim/math.ts | 511 +++++++++++++++++ lib/prop-sim/nano.ts | 77 --- lib/prop-sim/priceProcess.ts | 22 - lib/prop-sim/retail.ts | 49 -- lib/prop-sim/rng.ts | 25 - lib/prop-sim/router.ts | 172 ------ lib/prop-sim/types.ts | 175 +++--- lib/prop-strategies/builtins.ts | 279 +++++++--- lib/prop-strategies/starterSource.ts | 94 ---- store/usePropPlaybackStore.ts | 18 - store/usePropUiStore.ts | 54 -- tests/prop-engine-determinism.test.ts | 80 --- tests/prop-nano.test.ts | 35 -- tests/prop-starter-runtime.test.ts | 74 --- workers/prop-messages.ts | 41 -- workers/prop-simulation.worker.ts | 383 +++++++------ 32 files changed, 2376 insertions(+), 3354 deletions(-) create mode 100644 components/PropCodePanel.tsx create mode 100644 components/PropMarketPanel.tsx delete mode 100644 components/prop/PropAmmChart.tsx delete mode 100644 components/prop/PropCodePanel.tsx delete mode 100644 components/prop/PropMarketPanel.tsx delete mode 100644 lib/prop-sim/amm.ts delete mode 100644 lib/prop-sim/arbitrage.ts create mode 100644 lib/prop-sim/math.ts delete mode 100644 lib/prop-sim/nano.ts delete mode 100644 lib/prop-sim/priceProcess.ts delete mode 100644 lib/prop-sim/retail.ts delete mode 100644 lib/prop-sim/rng.ts delete mode 100644 lib/prop-sim/router.ts delete mode 100644 lib/prop-strategies/starterSource.ts delete mode 100644 store/usePropPlaybackStore.ts delete mode 100644 store/usePropUiStore.ts delete mode 100644 tests/prop-engine-determinism.test.ts delete mode 100644 tests/prop-nano.test.ts delete mode 100644 tests/prop-starter-runtime.test.ts delete mode 100644 workers/prop-messages.ts diff --git a/PROP-AMM-VISUALIZER-SPEC.md b/PROP-AMM-VISUALIZER-SPEC.md index d955889..2f73163 100644 --- a/PROP-AMM-VISUALIZER-SPEC.md +++ b/PROP-AMM-VISUALIZER-SPEC.md @@ -1,422 +1,403 @@ # Prop AMM Visualizer Spec -**Author:** Codex +**Author:** Cesare **Date:** 2026-02-15 -**Status:** Definitive implementation plan (starter strategy section) +**Status:** Draft --- -## 1. Objective +## Overview -Add a new section to the AMM Visualizer for the **Prop AMM Challenge** that lets users step through the **built-in starter strategy** under the real Prop AMM simulation mechanics. +This document specifies a new section of the AMM Visualizer to support the **Prop AMM Challenge** — a custom price function competition using Rust/Solana-style programs. Unlike the original AMM Challenge (dynamic fees on constant-product), Prop AMM lets participants define the *entire* output calculation for swaps. -Primary outcome: - -- A `/prop-amm` route with side-by-side strategy code + market simulation. -- Behavior aligned with `prop-amm-challenge` runtime semantics (not approximate EVM-fee semantics). -- MVP limited to built-in starter strategy + normalizer (no custom Rust compilation in browser). - ---- - -## 2. Source of Truth - -This plan is grounded in: - -- `https://github.com/benedictbrady/prop-amm-challenge` (README + runtime crates) -- `https://github.com/muttoni/ammvisualizer/blob/main/PROP-AMM-VISUALIZER-SPEC.md` (draft to sense-check and refine) - -Critical mechanics from the challenge codebase: - -- 10,000 simulation steps per run. -- Per simulation, sample: - - `gbm_sigma ~ U[0.0001, 0.007]` - - `retail_arrival_rate ~ U[0.4, 1.2]` - - `retail_mean_size ~ U[12, 28]` - - `norm_fee_bps ~ U{30..80}` - - `norm_liquidity_mult ~ U[0.4, 2.0]` -- Step order: - 1. Fair price GBM update - 2. Arbitrage on submission AMM - 3. Arbitrage on normalizer AMM - 4. Poisson retail arrivals routed across both AMMs -- Starter strategy: - - Constant product - - 5% fee (`FEE_NUMERATOR=950`, `FEE_DENOMINATOR=1000`) - - `after_swap` no-op -- Instruction model: - - `compute_swap` payload includes 1024-byte read-only storage - - `after_swap` payload includes post-trade reserves + step + mutable storage -- Arithmetic path: - - Quotes/execution use `u64` nano units (1e9 scaling), then convert back to `f64` for simulator reserves. - ---- - -## 3. Scope - -### In scope (MVP) - -- New `/prop-amm` section. -- Built-in starter strategy visualization. -- Normalizer with sampled fee/liquidity regime. -- Trade-by-trade playback controls (play/pause/step/reset). -- Trade tape with edge deltas and flow type. -- Prop-specific metrics panel. -- Read-only Rust code panel for starter strategy. -- Storage panel (1024-byte view), even if unchanged for starter. - -### Out of scope (deferred) - -- User-authored Rust strategy upload/compile/execute. -- Browser-side Rust toolchain/WASM compiler integration. -- Full BPF runtime emulation in browser. -- Leaderboard submission flow. +**Goal:** Enable visual debugging and intuition-building for Prop AMM strategies, mirroring the same step-through experience the current visualizer provides for Solidity fee strategies. --- -## 4. Sense Check vs Existing Draft Spec - -The referenced draft is directionally correct. The following changes are required for parity and clarity: +## Key Differences from Original Challenge -1. Event order must match Rust engine exactly: `price -> submission arb -> normalizer arb -> retail`. -2. Normalizer fee handling must match native normalizer path: fee read from storage bytes `[0..2]` and initialized from sampled `norm_fee_bps`. -3. Runtime math must preserve nano-unit conversion and integer rounding behavior (`u64`, ceil-div) before reserve updates. -4. `after_swap` must run only after executed trades, never during quote search. -5. Arbitrage logic is asymmetric: - - submission side uses bracket + golden search over quote surface - - normalizer side uses closed-form candidate sizing then quote check -6. MVP built-ins should be starter-first. Additional synthetic curves are optional future work, not baseline requirements. -7. Storage support is still required in UI and engine even if starter does not mutate storage. +| Aspect | Original AMM Challenge | Prop AMM Challenge | +|--------|------------------------|-------------------| +| **Language** | Solidity | Rust | +| **Core Interface** | `afterSwap() → (bidFee, askFee)` | `compute_swap() → output_amount` | +| **Pricing Model** | Constant-product + dynamic fees | Custom price function (any curve) | +| **Storage** | 32 uint256 slots | 1024-byte buffer | +| **Normalizer** | Fixed 30 bps | Variable: 30-80 bps fee, 0.4-2.0x liquidity | +| **Volatility** | σ ~ U[0.088%, 0.101%] | σ ~ U[0.01%, 0.70%] | +| **Arbitrage** | Closed-form optimal | Golden-section search | +| **Requirements** | None | Monotonic, concave, <100k CU | --- -## 5. Product Design - -### Routes and navigation - -- Keep current page unchanged at `/`. -- Add new page at `/prop-amm`. -- Add explicit navigation in header between `AMM Challenge` and `Prop AMM`. - -### Page layout - -- Keep the same two-panel mental model: - - left: code panel - - right: market panel with chart, metrics, trade tape -- Reuse existing shell/theming styles where possible. - -### Code panel (Prop) - -- Read-only Rust source for starter strategy. -- Rust syntax highlighting. -- No compile/edit actions in MVP. -- "What this code is doing" panel with deterministic explanation templates for: - - buy branch - - sell branch - - invalid side / zero reserve fallback -- Metadata strip: - - strategy name - - model-used string from source - - storage usage: `No-op` for starter - -### Market panel (Prop) - -Displayed metrics: - -- Step index and trade count -- Fair price -- Submission spot price -- Normalizer spot price -- Submission cumulative edge: - - total - - retail component - - arbitrage component -- Sampled regime (fixed for simulation): - - sigma - - retail arrival rate - - retail mean size - - normalizer fee bps - - normalizer liquidity multiplier -- Storage summary: - - changed byte count - - last write step - -Trade tape row fields: - -- Flow: `system | arbitrage | retail` -- Pool: `submission | normalizer` -- Direction: AMM buys X vs sells X -- Input/output amounts (human + nano) -- Fair price at execution -- Edge delta (submission trades only) -- Router split context for retail events (submission share) - -Chart behavior: - -- Show both AMM reserve states and reserve trails. -- Show fair price target point for each pool. -- Keep existing curve visuals for starter/normalizer (hyperbolic), but structure chart API to allow sampled custom curves later. - ---- - -## 6. Technical Architecture - -### New files +## Architecture Changes -```text -app/prop-amm/page.tsx -hooks/usePropSimulationWorker.ts -workers/prop-simulation.worker.ts -workers/prop-messages.ts +### 1. New Route Structure -components/prop/PropCodePanel.tsx -components/prop/PropMarketPanel.tsx -components/prop/PropAmmChart.tsx - -lib/prop-sim/constants.ts -lib/prop-sim/types.ts -lib/prop-sim/nano.ts -lib/prop-sim/rng.ts -lib/prop-sim/priceProcess.ts -lib/prop-sim/amm.ts -lib/prop-sim/arbitrage.ts -lib/prop-sim/router.ts -lib/prop-sim/retail.ts -lib/prop-sim/engine.ts - -lib/prop-strategies/builtins.ts -lib/prop-strategies/starterSource.ts - -store/usePropUiStore.ts -store/usePropPlaybackStore.ts ``` - -### Existing files to modify - -```text -components/HeaderActions.tsx (add nav link) -app/globals.css (prop panel classes) +/ → Original AMM Challenge Visualizer (existing) +/prop-amm → Prop AMM Challenge Visualizer (new) ``` -### Deliberate isolation - -- Do not modify `lib/sim/*`, `workers/simulation.worker.ts`, or existing EVM strategy runtime. -- Prop simulator and worker remain independent to reduce regression risk. - ---- +Both share layout chrome (header, footer, theme) but have separate simulation engines and strategy systems. -## 7. Data Model +### 2. New Module Structure -```ts -type PropFlowType = 'system' | 'arbitrage' | 'retail' +``` +lib/ +├── sim/ # Original engine (unchanged) +│ ├── engine.ts +│ ├── math.ts +│ ├── types.ts +│ └── ... +└── prop-sim/ # NEW: Prop AMM engine + ├── engine.ts # PropSimulationEngine class + ├── math.ts # Custom curve math, golden-section search + ├── types.ts # PropAmmState, PropSnapshot, etc. + ├── constants.ts # Parameter ranges, defaults + ├── normalizer.ts # Variable normalizer logic + └── arbitrage.ts # Golden-section arb solver + +workers/ +├── simulation.worker.ts # Original worker (unchanged) +└── prop-simulation.worker.ts # NEW: Prop AMM worker + +components/ +├── MarketPanel.tsx # Shared (parameterized) +├── CodePanel.tsx # Shared (language-aware) +├── PropCodePanel.tsx # NEW: Rust-specific code display +└── PropMarketPanel.tsx # NEW: Prop-specific metrics + +lib/ +└── prop-strategies/ + ├── builtins.ts # Built-in Rust strategy definitions + └── starter.rs # Embedded starter strategy source +``` -interface PropSimulationConfig { - seed: number - playbackSpeed: number - maxTapeRows: number - nSteps: number // default 10_000 -} +### 3. Simulation Engine (PropSimulationEngine) -interface PropSampledRegime { - gbmSigma: number - retailArrivalRate: number - retailMeanSize: number - normFeeBps: number - normLiquidityMult: number -} +#### State Shape +```typescript interface PropAmmState { - name: 'submission' | 'normalizer' - reserveX: number + name: string + reserveX: number // 1e9 scale internally reserveY: number - storage: Uint8Array // 1024 bytes + isStrategy: boolean + // No explicit fees — pricing determined by compute_swap } -interface PropSnapshot { - step: number - fairPrice: number - submission: { x: number; y: number; spot: number } - normalizer: { x: number; y: number; spot: number } - edge: { total: number; retail: number; arb: number } - regime: PropSampledRegime +interface PropNormalizerConfig { + feeBps: number // Sampled per simulation: U{30..80} + liquidityMult: number // Sampled per simulation: U[0.4, 2.0] } -interface PropTradeEvent { - id: number +interface PropSnapshot { step: number - flow: PropFlowType - amm: 'submission' | 'normalizer' - side: 'buy_x' | 'sell_x' - inputAmount: number - outputAmount: number fairPrice: number - edgeDelta: number - codeLines: number[] - codeExplanation: string - storageChangedBytes: number - snapshot: PropSnapshot + strategy: { + x: number + y: number + k: number // Effective k for reference + impliedBid: number // Back-calculated from last trade + impliedAsk: number + } + normalizer: { + x: number + y: number + k: number + feeBps: number + liquidityMult: number + } + edge: { + total: number + retail: number + arb: number + } } ``` ---- +#### Parameter Ranges (from spec) + +```typescript +const PROP_PARAMS = { + // Price process + volatility: { min: 0.0001, max: 0.007 }, // 0.01% to 0.70% per step + + // Retail flow + arrivalRate: { min: 0.4, max: 1.2 }, + orderSizeMean: { min: 12, max: 28 }, // Y terms + + // Normalizer + normalizerFee: { min: 30, max: 80 }, // bps, integer + normalizerLiquidity: { min: 0.4, max: 2.0 }, + + // Initial reserves + initialX: 100, + initialY: 10_000, + initialPrice: 100, + + // Arbitrage thresholds + arbMinProfit: 0.01, // Y units + arbBracketTolerance: 0.01, // 1% relative +} +``` -## 8. Runtime Semantics (must match challenge behavior) +### 4. Custom Price Function Interface -### Swap interface semantics +Instead of returning fees, Prop AMM strategies return `output_amount` directly: -- `side=0`: buy X with Y input. -- `side=1`: sell X for Y output. -- Strategy receives reserves + storage in nano-unit instruction payload. -- Return `u64` output amount in nano units. +```typescript +interface PropComputeSwapInput { + side: 0 | 1 // 0 = buy X (Y in), 1 = sell X (X in) + inputAmount: bigint // 1e9 scale + reserveX: bigint + reserveY: bigint + storage: Uint8Array // 1024 bytes, read-only during quote +} -### Starter strategy implementation +interface PropComputeSwapOutput { + outputAmount: bigint // 1e9 scale +} -- Implement TypeScript mirror of `programs/starter/src/lib.rs`. -- Preserve integer path: - - `k = reserve_x * reserve_y` - - `net = input * 950 / 1000` - - `new reserve` via ceil division - - saturating subtraction behavior +interface PropAfterSwapInput { + tag: 2 + side: 0 | 1 + inputAmount: bigint + outputAmount: bigint + reserveX: bigint // Post-trade + reserveY: bigint + step: bigint + storage: Uint8Array // 1024 bytes, read/write +} +``` -### Normalizer implementation +### 5. Built-in Strategies + +The visualizer will include TypeScript implementations that mirror the behavior of Rust starter strategies: + +```typescript +// lib/prop-strategies/builtins.ts + +export const PROP_BUILTIN_STRATEGIES: PropBuiltinStrategy[] = [ + { + id: 'starter-500bps', + name: 'Starter (500 bps)', + code: STARTER_RUST_SOURCE, + computeSwap: (input) => { + // Constant-product with 5% fee (500 bps) + const FEE_NUM = 950n + const FEE_DENOM = 1000n + const k = input.reserveX * input.reserveY + + if (input.side === 0) { + // Buy X: input Y, output X + const netY = (input.inputAmount * FEE_NUM) / FEE_DENOM + const newY = input.reserveY + netY + const newX = (k + newY - 1n) / newY // ceil div + return { outputAmount: input.reserveX - newX } + } else { + // Sell X: input X, output Y + const netX = (input.inputAmount * FEE_NUM) / FEE_DENOM + const newX = input.reserveX + netX + const newY = (k + newX - 1n) / newX + return { outputAmount: input.reserveY - newY } + } + }, + afterSwap: (input, storage) => { + // No-op for starter + return storage + }, + }, + { + id: 'constant-product-30bps', + name: 'Constant Product (30 bps)', + // ... similar implementation with 30 bps + }, + { + id: 'linear-invariant', + name: 'Linear Invariant (Stable)', + // ... x + y = k style pricing + }, +] +``` -- Implement TypeScript mirror of `crates/shared/src/normalizer.rs`. -- Fee bps read from storage bytes `[0..2]`, fallback to 30 if zero. -- Initialize normalizer storage with sampled `norm_fee_bps` LE bytes. +### 6. Golden-Section Arbitrage Solver -### after_swap behavior +Unlike the original closed-form arb calculation, Prop AMM uses golden-section search: -- Called after every executed trade on each AMM. -- Not called during arbitrage/router quote evaluations. -- Starter `after_swap` is no-op, but engine must support future storage updates. +```typescript +// lib/prop-sim/arbitrage.ts -### Arbitrage behavior +interface ArbResult { + side: 'buy' | 'sell' + inputAmount: number + expectedProfit: number +} -- Submission AMM: - - evaluate both sides via quote functions - - bracket maximum with growth 2.0 up to 24 steps - - golden search up to 12 iterations - - stop when input bracket width <= 1% relative -- Normalizer AMM: - - closed-form candidate sizing per side using fee-adjusted CP formulas - - evaluate both sides, execute better profitable candidate -- Global thresholds: - - minimum arb profit: `0.01 Y` - - minimum arb notional floor: `0.01 Y` +export function findPropArbOpportunity( + amm: PropAmmState, + fairPrice: number, + computeSwap: (side: 0 | 1, input: bigint) => bigint, + minProfit: number = 0.01, + tolerance: number = 0.01, +): ArbResult | null { + // Golden-section search for optimal trade size + // Early-stop when bracket width < tolerance + // Skip if expected profit < minProfit + + const PHI = (1 + Math.sqrt(5)) / 2 + // ... implementation +} +``` -### Retail routing behavior +### 7. Order Routing with Golden-Section + +```typescript +// lib/prop-sim/math.ts + +export function routeRetailOrderProp( + strategy: PropAmmState, + normalizer: PropAmmState, + strategyQuote: (side: 0 | 1, input: bigint) => bigint, + normalizerQuote: (side: 0 | 1, input: bigint) => bigint, + order: { side: 'buy' | 'sell'; sizeY: number }, + tolerance: number = 0.01, +): Array<[PropAmmState, number]> { + // Golden-section search over split ratio α ∈ [0, 1] + // Maximize total output + // Early-stop when submission trade < 1% bracket or 1% objective gap +} +``` -- Sample `n ~ Poisson(lambda)` orders per step. -- Each order size from log-normal with sampled mean and fixed sigma. -- Buy/sell side is Bernoulli `p=0.5`. -- Router split uses golden-section search over alpha in `[0,1]`: - - max 14 iterations - - alpha tolerance 1e-3 - - submission amount rel tolerance 1e-2 - - objective gap early-stop tolerance 1e-2 +--- -### Edge accounting +## UI Changes -For submission trades only: +### 1. New Page: `/prop-amm` -- AMM buys X (sell-X flow): `edge = amount_x * fair_price - amount_y` -- AMM sells X (buy-X flow): `edge = amount_y - amount_x * fair_price` +``` +app/ +├── page.tsx # Original (unchanged) +└── prop-amm/ + └── page.tsx # Prop AMM visualizer +``` -Total edge is cumulative sum; also track `retail` and `arb` components separately. +### 2. Code Panel Differences ---- +| Feature | Original | Prop AMM | +|---------|----------|----------| +| Language | Solidity | Rust | +| Syntax highlighting | solidity | rust | +| Line explanation | Fee return values | Output amount calculation | +| Storage display | slots[0..31] | 1024-byte hex view | +| Compiler | In-browser solc | N/A (builtins only for MVP) | -## 9. UI/Worker Contract +**MVP Scope:** For the initial release, only built-in strategies are supported. Custom Rust compilation would require WASM tooling and is deferred to a future iteration. -`workers/prop-messages.ts` should mirror current architecture with Prop-specific payloads: +### 3. Market Panel Differences -- inbound: - - `INIT_PROP_SIM` - - `SET_PROP_CONFIG` - - `STEP_PROP_ONE` - - `PLAY_PROP` - - `PAUSE_PROP` - - `RESET_PROP` -- outbound: - - `PROP_STATE` - - `PROP_ERROR` +| Metric | Original | Prop AMM | +|--------|----------|----------| +| "Strategy Fees" | bid/ask bps | Implied bid/ask (back-calculated) | +| "Slot[0] Fee" | Direct read | Storage byte view | +| Normalizer info | Fixed 30/30 bps | Variable fee + liquidity mult | +| Curve shape | Always hyperbolic | Varies by strategy | -No compile/library message types in MVP. +**New Metrics for Prop AMM:** ---- +``` +┌─────────────────────────────────────────────────────────────┐ +│ Fair Price: 101.234 Y/X │ Strategy Spot: 100.891 Y/X │ +│ Implied Fees: ~47/52 bps │ Normalizer: 45 bps @ 1.3x liq │ +│ Curve Type: Concave ✓ │ Monotonic ✓ │ +│ Cumulative Edge: +12.34 (retail +45.67, arb -33.33) │ +└─────────────────────────────────────────────────────────────┘ +``` -## 10. Testing and Validation +### 4. Chart Adaptations -### Unit tests +The reserve curve chart needs to handle non-hyperbolic curves: -- Nano conversion helpers (`toNano`, `fromNano`, saturating bounds). -- Starter `computeSwap` parity cases against Rust logic. -- Normalizer dynamic fee decoding from storage. -- Arbitrage threshold behavior (`min_arb_profit`, notional floor). -- Router split behavior and early-stop criteria. +- **Current:** Draws `xy = k` hyperbola +- **Prop AMM:** Sample the actual pricing function to draw effective curve -### Integration tests +```typescript +function samplePriceCurve( + computeSwap: (side: 0 | 1, input: bigint) => bigint, + reserveX: number, + reserveY: number, + steps: number = 50, +): Array<{ x: number; y: number }> { + // Sample buy/sell at various sizes to trace the effective curve +} +``` -- Deterministic seed replay snapshots for: - - first N events - - cumulative edge - - regime sampling -- Validate invariants on each event: - - reserves remain finite and positive - - spot price finite - - no negative outputs - - after_swap called only after execution +### 5. Trade Tape Differences -### Manual acceptance checklist +Add normalizer config display per simulation: -- `/prop-amm` loads with starter code and active simulation controls. -- Initial system event displays sampled regime and initial reserves. -- Stepping produces arbitrage and retail events with sensible deltas. -- Trade tape and metrics remain consistent after reset. -- Existing `/` route remains unchanged. +``` +┌──────────────────────────────────────────────────────────────┐ +│ [System] Simulation started │ +│ Normalizer config: 45 bps fee, 1.32x liquidity │ +│ Volatility regime: 0.34% per step │ +├──────────────────────────────────────────────────────────────┤ +│ [Arb] t=1 | Strategy: sold 0.234 X for 23.12 Y │ +│ fair=101.2, implied spread ~48 bps | edge delta: -0.12 │ +└──────────────────────────────────────────────────────────────┘ +``` --- -## 11. Implementation Plan +## Implementation Phases -### Phase 1: Simulation core +### Phase 1: Core Engine (Week 1) -- Build `lib/prop-sim/*` engine and math modules. -- Implement starter and normalizer runtime mirrors. -- Implement Prop worker and hook. -- Add engine-level tests for parity-critical behavior. +- [ ] Create `lib/prop-sim/` module structure +- [ ] Implement `PropSimulationEngine` class +- [ ] Implement golden-section arbitrage solver +- [ ] Implement golden-section order router +- [ ] Add 3 built-in strategies (starter, 30bps, linear) +- [ ] Create `prop-simulation.worker.ts` -### Phase 2: Prop UI +### Phase 2: UI Integration (Week 2) -- Add `/prop-amm` page. -- Build `PropCodePanel`, `PropMarketPanel`, and chart component. -- Wire playback controls and tape rendering. -- Add header navigation and styling. +- [ ] Create `/prop-amm/page.tsx` route +- [ ] Adapt `CodePanel` for Rust syntax +- [ ] Create `PropMarketPanel` with new metrics +- [ ] Update chart to sample custom curves +- [ ] Add normalizer config display -### Phase 3: QA and stabilization +### Phase 3: Polish & Testing (Week 3) -- Add deterministic integration fixtures. -- Verify no regression in existing EVM visualizer path. -- Tune rendering performance for long simulations. -- Final doc pass in README and this spec. +- [ ] Add strategy explanation system for Prop +- [ ] Cross-check edge calculation against reference +- [ ] Add curve shape validation display +- [ ] Performance optimization +- [ ] Documentation + +### Future (Deferred) + +- Custom Rust strategy compilation (WASM toolchain) +- Side-by-side comparison mode +- Export simulation traces --- -## 12. Risks and Mitigations +## Open Questions + +1. **WASM Compilation:** Should we support custom Rust strategies via in-browser compilation? This requires bundling Rust/WASM tooling and significantly increases complexity. Recommend deferring to v2. -- Risk: drift from Rust semantics due numeric differences. - Mitigation: enforce nano/int-first quote path + fixture-based parity tests. +2. **Shared vs Separate Workers:** Should Prop AMM share the simulation worker with original, or use a completely separate worker? Recommend separate for clarity. -- Risk: UI complexity from adding second simulator mode. - Mitigation: strict module isolation and route-level separation. +3. **Curve Visualization:** For non-constant-product curves, how many sample points are needed for smooth visualization? Recommend 50-100 points with adaptive sampling near current reserves. -- Risk: storage view adds complexity without starter value. - Mitigation: keep minimal, read-only summary in MVP and expand later. +4. **Storage View:** How to display 1024 bytes usefully? Recommend collapsible hex view with "changed bytes" highlighting. --- -## 13. Deferred Extensions +## References -- Built-in adaptive storage strategy for demonstrating `after_swap`. -- Upload custom `lib.rs` and run in remote sandbox. -- Optional BPF parity mode badge in UI. -- Batch score distribution panel (1,000-sim summary). +- [Prop AMM Challenge Spec](https://github.com/benedictbrady/prop-amm-challenge) +- [Original AMM Challenge](https://github.com/benedictbrady/amm-challenge) +- [Current Visualizer Repo](https://github.com/muttoni/ammvisualizer) diff --git a/app/globals.css b/app/globals.css index 95c8581..3891937 100644 --- a/app/globals.css +++ b/app/globals.css @@ -74,14 +74,6 @@ body { opacity: 0.42; } -.prop-backdrop { - background-image: - linear-gradient(to right, rgba(54, 76, 108, 0.26) 1px, transparent 1px), - linear-gradient(to bottom, rgba(54, 76, 108, 0.2) 1px, transparent 1px); - background-size: 52px 52px; - opacity: 0.33; -} - h1, h2, h3, @@ -127,49 +119,6 @@ h3 { overflow: hidden; } -.prop-app-shell { - background: - radial-gradient(circle at 20% -24%, rgba(34, 56, 86, 0.95) 0%, rgba(18, 29, 45, 0.95) 46%, rgba(10, 19, 31, 0.98) 100%); -} - -.prop-app-shell .topbar p, -.prop-app-shell .clock, -.prop-app-shell .metric-card span, -.prop-app-shell .depth-legend, -.prop-app-shell .trade-column-head span { - color: #8194b3; -} - -.prop-app-shell .topbar p a, -.prop-app-shell .challenge-link.active, -.prop-app-shell .speed-inner strong { - color: #9bb1d8; -} - -.prop-app-shell .layout, -.prop-app-shell .code-panel, -.prop-app-shell .market-panel, -.prop-app-shell .panel-head, -.prop-app-shell .market-controls, -.prop-app-shell .chart-wrap, -.prop-app-shell .metrics-panel, -.prop-app-shell .trade-column { - border-color: rgba(72, 96, 130, 0.5); -} - -.prop-app-shell .metric-card, -.prop-app-shell .trade-row, -.prop-app-shell .code-line { - background: rgba(13, 23, 38, 0.66); - border-color: rgba(60, 84, 116, 0.56); -} - -.prop-app-shell .metric-card strong, -.prop-app-shell .trade-text, -.prop-app-shell .trade-edge { - color: #a3b7d8; -} - .topbar { display: flex; align-items: flex-end; @@ -199,36 +148,6 @@ h3 { border-bottom-color: var(--accent-soft); } -.challenge-nav { - display: flex; - align-items: center; - gap: 8px; - margin-top: 2px; -} - -.challenge-link { - border: 1px solid var(--line); - border-radius: 7px; - background: linear-gradient(180deg, var(--panel-strong) 0%, var(--panel-soft) 100%); - color: var(--ink-soft); - font-family: 'Space Mono', 'Courier New', monospace; - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; - text-decoration: none; - padding: 5px 8px; -} - -.challenge-link:hover { - color: var(--accent); - border-color: var(--accent-soft); -} - -.challenge-link.active { - color: var(--accent); - border-color: var(--accent-soft); -} - .top-actions { display: flex; align-items: center; @@ -438,17 +357,6 @@ h3 { font-size: 0.96rem; } -.prop-meta-row { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - color: var(--ink-soft); - font-size: 0.72rem; - font-family: 'Space Mono', 'Courier New', monospace; - letter-spacing: 0.03em; - text-transform: uppercase; -} - .strategy-picker select, .editor-field input, .editor-field textarea, @@ -1040,28 +948,6 @@ h3 { background: linear-gradient(180deg, var(--chart-bg-top) 0%, var(--chart-bg-bottom) 100%); } -.prop-chart-wrap { - border-bottom: 1px solid rgba(69, 92, 126, 0.46); - padding: 7px 8px; - background: transparent; -} - -.prop-chart-host { - height: 100%; -} - -#propCurveChart { - display: block; - width: 100%; - height: 100%; - min-height: 0; - border-radius: 14px; - border: 1px solid rgba(69, 92, 126, 0.55); - box-shadow: - inset 0 0 0 1px rgba(34, 51, 74, 0.4), - 0 10px 20px rgba(3, 8, 15, 0.35); -} - .market-bottom { flex: 0 0 auto; display: grid; @@ -1551,10 +1437,6 @@ h3 { min-height: 220px; } - #propCurveChart { - min-height: 220px; - } - .trade-tape { max-height: 34svh; overflow: auto; diff --git a/app/page.tsx b/app/page.tsx index 9e4413c..2b44bc8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -176,7 +176,6 @@ export default function Page() { setTheme(theme === 'dark' ? 'light' : 'dark')} - currentView="amm" /> {workerError ?
    Worker error: {workerError}
    : null} diff --git a/app/prop-amm/page.tsx b/app/prop-amm/page.tsx index 8ee8d67..f25218e 100644 --- a/app/prop-amm/page.tsx +++ b/app/prop-amm/page.tsx @@ -1,67 +1,52 @@ 'use client' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { HeaderActions } from '../../components/HeaderActions' -import { PropCodePanel } from '../../components/prop/PropCodePanel' -import { PropMarketPanel } from '../../components/prop/PropMarketPanel' -import { PROP_DEFAULT_STEPS } from '../../lib/prop-sim/constants' -import type { PropSimulationConfig, PropStrategyRef, PropWorkerUiState } from '../../lib/prop-sim/types' -import { PROP_BUILTIN_STRATEGIES, getPropBuiltinStrategy } from '../../lib/prop-strategies/builtins' +import { FooterLinks } from '../../components/FooterLinks' +import { PropCodePanel } from '../../components/PropCodePanel' +import { PropMarketPanel } from '../../components/PropMarketPanel' import { usePropSimulationWorker } from '../../hooks/usePropSimulationWorker' -import { usePropUiStore } from '../../store/usePropUiStore' 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, config: Omit): PropWorkerUiState { - const requested = PROP_BUILTIN_STRATEGIES.find((strategy) => strategy.id === strategyRef.id) - const selected = requested ?? PROP_BUILTIN_STRATEGIES[0] - const runtime = getPropBuiltinStrategy({ kind: 'builtin', id: selected.id }) +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, - submission: { + strategy: { x: 100, y: 10_000, - spot: 100, k: 1_000_000, + impliedBidBps: builtin.feeBps, + impliedAskBps: builtin.feeBps, }, normalizer: { x: 100, y: 10_000, - spot: 100, k: 1_000_000, feeBps: 30, - liquidityMult: 1, - }, - edge: { - total: 0, - retail: 0, - arb: 0, - }, - regime: { - gbmSigma: 0.001, - retailArrivalRate: 0.8, - retailMeanSize: 20, - normFeeBps: 30, - normLiquidityMult: 1, - }, - storage: { - lastChangedBytes: 0, - lastWriteStep: null, + liquidityMult: 1.0, }, + edge: { total: 0, retail: 0, arb: 0 }, + simulationParams: { volatility: 0.003, arrivalRate: 0.8 }, } return { config: { - ...config, - strategyRef: runtime.ref, + seed: 1337, + strategyRef: { kind: 'builtin', id: builtin.id }, + playbackSpeed, + maxTapeRows, }, currentStrategy: { - kind: runtime.ref.kind, - id: runtime.ref.id, - name: runtime.name, - code: runtime.code, - modelUsed: runtime.modelUsed, + kind: 'builtin', + id: builtin.id, + name: builtin.name, + code: builtin.code, + feeBps: builtin.feeBps, }, isPlaying: false, tradeCount: 0, @@ -70,52 +55,60 @@ function buildFallbackUiState(strategyRef: PropStrategyRef, config: Omit ({ + 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 setTheme = useUiStore((state) => state.setTheme) - - const playbackSpeed = usePropUiStore((state) => state.playbackSpeed) - const maxTapeRows = usePropUiStore((state) => state.maxTapeRows) - const nSteps = usePropUiStore((state) => state.nSteps) - const strategyRef = usePropUiStore((state) => state.strategyRef) - const showCodeExplanation = usePropUiStore((state) => state.showCodeExplanation) - const chartAutoZoom = usePropUiStore((state) => state.chartAutoZoom) + 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 setPlaybackSpeed = usePropUiStore((state) => state.setPlaybackSpeed) - const setStrategyRef = usePropUiStore((state) => state.setStrategyRef) - const setShowCodeExplanation = usePropUiStore((state) => state.setShowCodeExplanation) - const setChartAutoZoom = usePropUiStore((state) => state.setChartAutoZoom) + 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({ + const { + ready, + workerState, + workerError, + controls, + } = usePropSimulationWorker({ seed: 1337, playbackSpeed, maxTapeRows, - nSteps, - strategyRef, + strategyRef: propStrategyRef, }) useEffect(() => { @@ -123,29 +116,21 @@ export default function PropAmmPage() { }, [theme]) const fallbackState = useMemo( - () => - buildFallbackUiState(strategyRef, { - seed: 1337, - playbackSpeed, - maxTapeRows, - nSteps: nSteps || PROP_DEFAULT_STEPS, - }), - [maxTapeRows, nSteps, playbackSpeed, strategyRef], + () => 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" - currentView="prop" /> {workerError ?
    Worker error: {workerError}
    : null} @@ -153,13 +138,15 @@ export default function PropAmmPage() {
    { + setPropStrategyRef(next) + controls.setStrategy(next) + }} onToggleExplanationOverlay={() => setShowCodeExplanation(!showCodeExplanation)} /> @@ -177,7 +164,6 @@ export default function PropAmmPage() { controls.pause() return } - controls.play() }} onStep={() => { @@ -190,6 +176,8 @@ export default function PropAmmPage() { }} />
    + +
    ) diff --git a/components/HeaderActions.tsx b/components/HeaderActions.tsx index e4dd120..93e254c 100644 --- a/components/HeaderActions.tsx +++ b/components/HeaderActions.tsx @@ -7,7 +7,6 @@ interface HeaderActionsProps { onToggleTheme: () => void subtitle?: string subtitleLink?: string - currentView?: 'amm' | 'prop' } function XIcon() { @@ -26,7 +25,7 @@ function GitHubIcon() { ) } -export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink, currentView = 'amm' }: HeaderActionsProps) { +export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink }: HeaderActionsProps) { const toggleLabel = theme === 'dark' ? 'Light Theme' : 'Dark Theme' const title = subtitle ? `AMM Strategy Visualizer — ${subtitle}` : 'AMM Strategy Visualizer' const linkHref = subtitleLink ?? 'https://ammchallenge.com' @@ -42,14 +41,6 @@ export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink, cu {linkText}

    -
    diff --git a/components/PropCodePanel.tsx b/components/PropCodePanel.tsx new file mode 100644 index 0000000..70dd348 --- /dev/null +++ b/components/PropCodePanel.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useMemo } from 'react' +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 lines = useMemo(() => code.split('\n'), [code]) + const highlightSet = useMemo(() => new Set(highlightedLines), [highlightedLines]) + + return ( +
    +
    +

    Strategy Code (Rust)

    +
    + + +
    +
    + +
    +
    +          
    +            {lines.map((line, i) => {
    +              const lineNum = i + 1
    +              const isHighlighted = highlightSet.has(lineNum)
    +              return (
    +                
    + {lineNum} + {line || ' '} +
    + ) + })} +
    +
    +
    + + {showExplanationOverlay ? ( +
    +

    What the code is doing

    +

    {codeExplanation}

    +
    + Note: Prop AMM strategies define a custom compute_swap function that returns{' '} + output_amount directly, rather than just adjusting fees on a constant-product curve. +
    +
    + ) : null} +
    + ) +} diff --git a/components/PropMarketPanel.tsx b/components/PropMarketPanel.tsx new file mode 100644 index 0000000..25e512a --- /dev/null +++ b/components/PropMarketPanel.tsx @@ -0,0 +1,418 @@ +'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} + /> +
    +
    + +
    +
    +
    +
    + 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/components/prop/PropAmmChart.tsx b/components/prop/PropAmmChart.tsx deleted file mode 100644 index 3afce74..0000000 --- a/components/prop/PropAmmChart.tsx +++ /dev/null @@ -1,304 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { buildCurvePath, buildTrailPath } from '../../lib/sim/chart' -import type { ThemeMode } from '../../lib/sim/types' -import type { PropTradeEvent, PropSnapshot } from '../../lib/prop-sim/types' - -interface PropAmmChartProps { - snapshot: PropSnapshot - reserveTrail: Array<{ x: number; y: number }> - lastEvent: PropTradeEvent | null - theme: ThemeMode - viewWindow: { xMin: number; xMax: number; yMin: number; yMax: number } | null - autoZoom: boolean - chartSize: { width: number; height: number } -} - -const PROP_CHART_PALETTE = { - canvas: '#0b1422', - canvasGlow: '#101f34', - grid: '#22344f', - axis: '#4b5f7d', - strategyCurve: '#8ea6d5', - normalizerCurve: '#34465d', - trail: '#6f87b5', - strategyDot: '#9fb4de', - strategyRing: '#4f6285', - normalizerDot: '#5e708d', - targetDot: '#7f95be', - helper: '#334b6a', - annotation: '#617395', - axisLabel: '#8395b1', -} - -function buildFallbackWindow(snapshot: PropSnapshot): { xMin: number; xMax: number; yMin: number; yMax: number } { - const xMin = Math.min(snapshot.submission.x, snapshot.normalizer.x) * 0.6 - const xMax = Math.max(snapshot.submission.x, snapshot.normalizer.x) * 1.25 - const yMin = Math.min(snapshot.submission.y, snapshot.normalizer.y) * 0.55 - const yMax = Math.max(snapshot.submission.y, snapshot.normalizer.y) * 1.2 - - return { - xMin: Math.max(1e-6, xMin), - xMax: Math.max(xMin + 1, xMax), - yMin: Math.max(1e-6, yMin), - yMax: Math.max(yMin + 1, yMax), - } -} - -export function PropAmmChart({ - snapshot, - reserveTrail, - lastEvent, - viewWindow, - autoZoom, - chartSize, -}: PropAmmChartProps) { - const geometry = useMemo(() => { - const width = Math.max(320, Math.round(chartSize.width)) - const height = Math.max(220, Math.round(chartSize.height)) - return { - width, - height, - margin: { - left: Math.max(56, Math.min(84, width * 0.1)), - right: Math.max(16, Math.min(34, width * 0.04)), - top: Math.max(16, Math.min(30, height * 0.09)), - bottom: Math.max(44, Math.min(64, height * 0.2)), - }, - } - }, [chartSize.height, chartSize.width]) - - const chart = useMemo(() => { - const activeWindow = viewWindow ?? buildFallbackWindow(snapshot) - const xMin = activeWindow.xMin - const xMax = activeWindow.xMax - const yMin = activeWindow.yMin - const yMax = activeWindow.yMax - - const innerW = geometry.width - geometry.margin.left - geometry.margin.right - const innerH = geometry.height - geometry.margin.top - geometry.margin.bottom - - const xToPx = (x: number) => geometry.margin.left + ((x - xMin) / (xMax - xMin)) * innerW - const yToPx = (y: number) => geometry.margin.top + (1 - (y - yMin) / (yMax - yMin)) * innerH - - const strategyPath = buildCurvePath(snapshot.submission.k, xMin, xMax, xToPx, yToPx) - const normalizerPath = buildCurvePath(snapshot.normalizer.k, xMin, xMax, xToPx, yToPx) - const trailPath = buildTrailPath(reserveTrail.slice(-120), xToPx, yToPx) - - const submissionPoint = { - x: xToPx(snapshot.submission.x), - y: yToPx(snapshot.submission.y), - } - - const normalizerPoint = { - x: xToPx(snapshot.normalizer.x), - y: yToPx(snapshot.normalizer.y), - } - - const targetX = Math.sqrt(snapshot.submission.k / Math.max(snapshot.fairPrice, 1e-9)) - const targetY = snapshot.submission.k / Math.max(targetX, 1e-9) - - const targetPoint = { - x: xToPx(targetX), - y: yToPx(targetY), - } - - const xAxisY = geometry.height - geometry.margin.bottom - const yAxisX = geometry.margin.left - - const tradeArrow = - lastEvent?.trade && lastEvent.isSubmissionTrade - ? { - fromX: xToPx(lastEvent.trade.beforeX), - fromY: yToPx(lastEvent.trade.beforeY), - toX: xToPx(lastEvent.trade.reserveX), - toY: yToPx(lastEvent.trade.reserveY), - } - : null - - return { - innerW, - innerH, - xToPx, - yToPx, - xAxisY, - yAxisX, - strategyPath, - normalizerPath, - trailPath, - submissionPoint, - normalizerPoint, - targetPoint, - tradeArrow, - } - }, [geometry.height, geometry.margin.bottom, geometry.margin.left, geometry.margin.right, geometry.margin.top, geometry.width, lastEvent, reserveTrail, snapshot, viewWindow]) - - const gridColumns = 8 - const gridRows = 6 - - return ( - - - - - - - - - - - {Array.from({ length: gridColumns + 1 }).map((_, index) => { - const x = geometry.margin.left + (chart.innerW * index) / gridColumns - return ( - - ) - })} - - {Array.from({ length: gridRows + 1 }).map((_, index) => { - const y = geometry.margin.top + (chart.innerH * index) / gridRows - return ( - - ) - })} - - - - - - - - - - - - {chart.tradeArrow ? ( - - ) : null} - - - - - - - - - - - - input - - - - - output - - - - compute_swap() - - - - - - - - Input - - - Output - - - ) -} diff --git a/components/prop/PropCodePanel.tsx b/components/prop/PropCodePanel.tsx deleted file mode 100644 index cc6e45e..0000000 --- a/components/prop/PropCodePanel.tsx +++ /dev/null @@ -1,130 +0,0 @@ -'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 - modelUsed: string - highlightedLines: number[] - codeExplanation: string - showExplanationOverlay: boolean - onSelectStrategy: (strategy: PropStrategyRef) => void - onToggleExplanationOverlay: () => void -} - -function encodeStrategyRef(strategy: PropStrategyRef): string { - return `${strategy.kind}:${strategy.id}` -} - -function decodeStrategyRef(value: string): PropStrategyRef { - const [kind, ...idParts] = value.split(':') - return { - kind: kind === 'builtin' ? 'builtin' : 'builtin', - id: idParts.join(':'), - } -} - -export function PropCodePanel({ - availableStrategies, - selectedStrategy, - code, - modelUsed, - 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 highlightedCodeLines = 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 ( -
    -
    -
    -
    -

    Starter Strategy (Rust)

    -
    - -
    - -
    - -
    - Model used: {modelUsed} - Storage behavior: No-op afterSwap -
    -
    -
    - -
    - {lines.map((line, index) => { - const lineNumber = index + 1 - const active = lineSet.has(lineNumber) - - return ( -
    - {String(lineNumber).padStart(2, '0')} - -
    - ) - })} -
    - -
    - - -
    -
    - ) -} diff --git a/components/prop/PropMarketPanel.tsx b/components/prop/PropMarketPanel.tsx deleted file mode 100644 index bf10c3a..0000000 --- a/components/prop/PropMarketPanel.tsx +++ /dev/null @@ -1,250 +0,0 @@ -'use client' - -import { useLayoutEffect, useRef, useState } from 'react' -import { PROP_SPEED_PROFILE } from '../../lib/prop-sim/constants' -import type { PropTradeEvent, PropWorkerUiState } from '../../lib/prop-sim/types' -import type { ThemeMode } from '../../lib/sim/types' -import { formatNum, formatSigned } from '../../lib/sim/utils' -import { PropAmmChart } from './PropAmmChart' - -interface PropMarketPanelProps { - state: PropWorkerUiState - theme: ThemeMode - playbackSpeed: number - autoZoom: boolean - isInitializing?: boolean - onPlaybackSpeedChange: (value: number) => void - onToggleAutoZoom: () => void - onPlayPause: () => void - onStep: () => void - onReset: () => void -} - -export function PropMarketPanel({ - state, - theme, - playbackSpeed, - autoZoom, - isInitializing = false, - onPlaybackSpeedChange, - onToggleAutoZoom, - onPlayPause, - onStep, - onReset, -}: PropMarketPanelProps) { - const snapshot = state.snapshot - 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) => (prev.width === width && prev.height === height ? prev : { 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) => (prev.width === width && prev.height === height ? prev : { width, height })) - }) - - measure() - observer.observe(host) - return () => observer.disconnect() - }, []) - - return ( -
    -
    -

    Simulated Market (Prop AMM)

    - - Step {snapshot.step} | Trade {state.tradeCount} - {isInitializing ? ' | Loading' : ''} - -
    - -
    -
    -
    -
    - - - -
    - -
    - - - -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    - - - - - - - - - -
    -
    -
    -
    - - -
    -
    - ) -} - -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
    - {label} - {value} -
    - ) -} - -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' - const alphaLabel = event.routerSplit ? `α=${formatNum(event.routerSplit.alpha, 3)}` : null - - return ( -
  • -
    - {flowLabel} - - t{event.step} | {event.poolName} - -
    -

    {event.summary}

    - {alphaLabel ?

    router split {alphaLabel}

    : null} - {event.isSubmissionTrade ? ( -
    submission edge delta: {formatSigned(event.edgeDelta)}
    - ) : ( -
    normalizer trade
    - )} -
  • - ) -} - -function ControlIcon({ kind }: { kind: 'play' | 'pause' | 'step' | 'reset' }) { - if (kind === 'pause') { - return ( - - ) - } - - if (kind === 'step') { - return ( - - ) - } - - if (kind === 'reset') { - return ( - - ) - } - - return ( - - ) -} diff --git a/hooks/usePropSimulationWorker.ts b/hooks/usePropSimulationWorker.ts index 8208566..0dc25bf 100644 --- a/hooks/usePropSimulationWorker.ts +++ b/hooks/usePropSimulationWorker.ts @@ -1,114 +1,120 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { PropSimulationConfig, PropStrategyRef } from '../lib/prop-sim/types' -import { usePropPlaybackStore } from '../store/usePropPlaybackStore' -import type { PropWorkerInboundMessage, PropWorkerOutboundMessage } from '../workers/prop-messages' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { PropSimulationConfig, PropStrategyRef, PropWorkerUiState } from '../lib/prop-sim/types' -interface UsePropSimulationWorkerArgs { +interface UsePropSimulationWorkerOptions { seed: number playbackSpeed: number maxTapeRows: number - nSteps: number strategyRef: PropStrategyRef } -export function usePropSimulationWorker({ - seed, - playbackSpeed, - maxTapeRows, - nSteps, - strategyRef, -}: UsePropSimulationWorkerArgs) { +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) - const workerState = usePropPlaybackStore((state) => state.workerState) - const workerError = usePropPlaybackStore((state) => state.workerError) - const setWorkerState = usePropPlaybackStore((state) => state.setWorkerState) - const setWorkerError = usePropPlaybackStore((state) => state.setWorkerError) - - const post = useCallback((message: PropWorkerInboundMessage) => { - workerRef.current?.postMessage(message) - }, []) - + // Initialize worker useEffect(() => { - const worker = new Worker(new URL('../workers/prop-simulation.worker.ts', import.meta.url), { - type: 'module', - }) - - worker.onmessage = (event: MessageEvent) => { - const message = event.data - switch (message.type) { - case 'PROP_STATE': { - setWorkerState(message.payload.state) - break + 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, } - case 'PROP_ERROR': { - setWorkerError(message.payload.message) - break + worker.postMessage({ type: 'init', config }) + } else if (msg.type === 'state') { + setWorkerState(msg.state) + if (!ready) { + setReady(true) } - default: - break } } - workerRef.current = worker - setReady(true) - - const initConfig: Partial = { - seed, - playbackSpeed, - maxTapeRows, - nSteps, - strategyRef, + worker.onerror = (event) => { + setWorkerError(event.message || 'Worker error') } - worker.postMessage({ - type: 'INIT_PROP_SIM', - payload: { - config: initConfig, - }, - } satisfies PropWorkerInboundMessage) + workerRef.current = worker return () => { worker.terminate() workerRef.current = null - setReady(false) } - }, [setWorkerError, setWorkerState]) + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Update config when options change useEffect(() => { - if (!ready) return - - post({ - type: 'SET_PROP_CONFIG', - payload: { - config: { - seed, - playbackSpeed, - maxTapeRows, - nSteps, - strategyRef, - }, - }, - }) - }, [maxTapeRows, nSteps, playbackSpeed, post, ready, seed, strategyRef]) - - const controls = useMemo( - () => ({ - play: () => post({ type: 'PLAY_PROP' }), - pause: () => post({ type: 'PAUSE_PROP' }), - step: () => post({ type: 'STEP_PROP_ONE' }), - reset: () => post({ type: 'RESET_PROP' }), - }), - [post], - ) + 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, + controls: { + play, + pause, + step, + reset, + setStrategy, + }, } } diff --git a/lib/prop-sim/amm.ts b/lib/prop-sim/amm.ts deleted file mode 100644 index 66de0a4..0000000 --- a/lib/prop-sim/amm.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - PROP_INITIAL_RESERVE_X, - PROP_INITIAL_RESERVE_Y, - PROP_MIN_INPUT, - PROP_NORMALIZER_FEE_MIN, - PROP_STORAGE_SIZE, -} from './constants' -import { - ceilDiv, - clampU64, - encodeU16Le, - ensureStorageSize, - fromNano, - readU16Le, - saturatingSub, - toNano, -} from './nano' -import type { PropAmmState, PropPool, PropSwapSide, PropTrade } from './types' - -export function createAmm( - pool: PropPool, - reserveX: number, - reserveY: number, - storage?: Uint8Array, -): PropAmmState { - return { - pool, - name: pool === 'submission' ? 'Submission' : 'Normalizer', - reserveX, - reserveY, - storage: storage ? ensureStorageSize(storage) : new Uint8Array(PROP_STORAGE_SIZE), - } -} - -export function createInitialSubmissionAmm(): PropAmmState { - return createAmm('submission', PROP_INITIAL_RESERVE_X, PROP_INITIAL_RESERVE_Y) -} - -export function createInitialNormalizerAmm(liquidityMultiplier: number, feeBps: number): PropAmmState { - const reserveX = PROP_INITIAL_RESERVE_X * liquidityMultiplier - const reserveY = PROP_INITIAL_RESERVE_Y * liquidityMultiplier - const storage = new Uint8Array(PROP_STORAGE_SIZE) - storage.set(encodeU16Le(feeBps), 0) - return createAmm('normalizer', reserveX, reserveY, storage) -} - -export function ammSpot(amm: PropAmmState): number { - if (!Number.isFinite(amm.reserveX) || !Number.isFinite(amm.reserveY) || amm.reserveX <= 0) { - return Number.NaN - } - return amm.reserveY / amm.reserveX -} - -export function ammK(amm: PropAmmState): number { - return amm.reserveX * amm.reserveY -} - -export function normalizerFeeBps(amm: PropAmmState): number { - const raw = readU16Le(amm.storage, 0) - return raw === 0 ? PROP_NORMALIZER_FEE_MIN : raw -} - -export function quoteNormalizer(amm: PropAmmState, side: PropSwapSide, inputAmount: number): number { - if (!Number.isFinite(inputAmount) || inputAmount <= 0) { - return 0 - } - - if (amm.reserveX <= 0 || amm.reserveY <= 0 || !Number.isFinite(amm.reserveX) || !Number.isFinite(amm.reserveY)) { - return 0 - } - - const feeBps = normalizerFeeBps(amm) - const gammaNumerator = BigInt(Math.max(0, 10_000 - feeBps)) - - const inputNano = toNano(inputAmount) - const reserveXNano = toNano(amm.reserveX) - const reserveYNano = toNano(amm.reserveY) - - if (inputNano <= 0n || reserveXNano <= 0n || reserveYNano <= 0n) { - return 0 - } - - const k = reserveXNano * reserveYNano - - if (side === 0) { - const netIn = (inputNano * gammaNumerator) / 10_000n - const newReserveY = reserveYNano + netIn - const kDiv = ceilDiv(k, newReserveY) - const out = saturatingSub(reserveXNano, kDiv) - return fromNano(out) - } - - const netIn = (inputNano * gammaNumerator) / 10_000n - const newReserveX = reserveXNano + netIn - const kDiv = ceilDiv(k, newReserveX) - const out = saturatingSub(reserveYNano, kDiv) - return fromNano(out) -} - -export function quoteNormalizerBuyX(amm: PropAmmState, inputY: number): number { - return quoteNormalizer(amm, 0, inputY) -} - -export function quoteNormalizerSellX(amm: PropAmmState, inputX: number): number { - return quoteNormalizer(amm, 1, inputX) -} - -export function executeBuyX( - amm: PropAmmState, - quoteBuyX: (inputY: number) => number, - inputY: number, -): PropTrade | null { - if (!Number.isFinite(inputY) || inputY < PROP_MIN_INPUT) { - return null - } - - const outputX = quoteBuyX(inputY) - if (!Number.isFinite(outputX) || outputX <= 0 || outputX >= amm.reserveX) { - return null - } - - const beforeX = amm.reserveX - const beforeY = amm.reserveY - const spotBefore = beforeY / Math.max(beforeX, 1e-12) - - const nextReserveX = beforeX - outputX - const nextReserveY = beforeY + inputY - - if (!Number.isFinite(nextReserveX) || !Number.isFinite(nextReserveY) || nextReserveX <= 0 || nextReserveY <= 0) { - return null - } - - amm.reserveX = nextReserveX - amm.reserveY = nextReserveY - - return { - side: 0, - direction: 'buy_x', - inputAmount: inputY, - outputAmount: outputX, - inputAmountNano: clampU64(toNano(inputY)).toString(), - outputAmountNano: clampU64(toNano(outputX)).toString(), - beforeX, - beforeY, - reserveX: amm.reserveX, - reserveY: amm.reserveY, - spotBefore, - spotAfter: amm.reserveY / Math.max(amm.reserveX, 1e-12), - } -} - -export function executeSellX( - amm: PropAmmState, - quoteSellX: (inputX: number) => number, - inputX: number, -): PropTrade | null { - if (!Number.isFinite(inputX) || inputX < PROP_MIN_INPUT) { - return null - } - - const outputY = quoteSellX(inputX) - if (!Number.isFinite(outputY) || outputY <= 0 || outputY >= amm.reserveY) { - return null - } - - const beforeX = amm.reserveX - const beforeY = amm.reserveY - const spotBefore = beforeY / Math.max(beforeX, 1e-12) - - const nextReserveX = beforeX + inputX - const nextReserveY = beforeY - outputY - - if (!Number.isFinite(nextReserveX) || !Number.isFinite(nextReserveY) || nextReserveX <= 0 || nextReserveY <= 0) { - return null - } - - amm.reserveX = nextReserveX - amm.reserveY = nextReserveY - - return { - side: 1, - direction: 'sell_x', - inputAmount: inputX, - outputAmount: outputY, - inputAmountNano: clampU64(toNano(inputX)).toString(), - outputAmountNano: clampU64(toNano(outputY)).toString(), - beforeX, - beforeY, - reserveX: amm.reserveX, - reserveY: amm.reserveY, - spotBefore, - spotAfter: amm.reserveY / Math.max(amm.reserveX, 1e-12), - } -} diff --git a/lib/prop-sim/arbitrage.ts b/lib/prop-sim/arbitrage.ts deleted file mode 100644 index f823f7c..0000000 --- a/lib/prop-sim/arbitrage.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - GOLDEN_RATIO_CONJUGATE, - PROP_ARB_BRACKET_GROWTH, - PROP_ARB_BRACKET_MAX_STEPS, - PROP_ARB_GOLDEN_MAX_ITERS, - PROP_ARB_INPUT_REL_TOL, - PROP_MAX_INPUT_AMOUNT, - PROP_MIN_ARB_NOTIONAL_Y, - PROP_MIN_ARB_PROFIT_Y, - PROP_MIN_INPUT, -} from './constants' -import { normalizerFeeBps } from './amm' -import type { PropAmmState, PropSwapSide } from './types' - -export interface PropArbCandidate { - side: PropSwapSide - inputAmount: number - expectedProfit: number -} - -interface GoldenResult { - x: number - value: number -} - -function sanitizeScore(value: number): number { - if (!Number.isFinite(value)) { - return Number.NEGATIVE_INFINITY - } - - return value -} - -function bracketMaximum( - start: number, - minInput: number, - maxInput: number, - objective: (input: number) => number, -): [number, number] { - const min = Math.max(PROP_MIN_INPUT, minInput) - const max = Math.max(min, maxInput) - - let lo = min - let mid = Math.min(max, Math.max(min, start)) - let midValue = sanitizeScore(objective(mid)) - - if (midValue <= 0) { - return [lo, mid] - } - - let hi = Math.min(max, mid * PROP_ARB_BRACKET_GROWTH) - if (hi <= mid) { - return [lo, mid] - } - - let hiValue = sanitizeScore(objective(hi)) - - for (let index = 0; index < PROP_ARB_BRACKET_MAX_STEPS; index += 1) { - if (hiValue <= midValue || hi >= max) { - return [lo, hi] - } - - lo = mid - mid = hi - midValue = hiValue - - const nextHi = Math.min(max, hi * PROP_ARB_BRACKET_GROWTH) - if (nextHi <= hi) { - return [lo, hi] - } - - hi = nextHi - hiValue = sanitizeScore(objective(hi)) - } - - return [lo, hi] -} - -function goldenSectionMax(lo: number, hi: number, objective: (input: number) => number): GoldenResult { - let left = Math.max(0, Math.min(lo, hi)) - let right = Math.max(PROP_MIN_INPUT, Math.max(lo, hi)) - - if (right <= left) { - return { - x: right, - value: sanitizeScore(objective(right)), - } - } - - let bestX = left - let bestValue = sanitizeScore(objective(left)) - - const rightValue = sanitizeScore(objective(right)) - if (rightValue > bestValue) { - bestX = right - bestValue = rightValue - } - - let x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) - let x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) - let f1 = sanitizeScore(objective(x1)) - let f2 = sanitizeScore(objective(x2)) - - if (f1 > bestValue) { - bestX = x1 - bestValue = f1 - } - - if (f2 > bestValue) { - bestX = x2 - bestValue = f2 - } - - for (let index = 0; index < PROP_ARB_GOLDEN_MAX_ITERS; index += 1) { - if (f1 < f2) { - left = x1 - x1 = x2 - f1 = f2 - x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) - f2 = sanitizeScore(objective(x2)) - if (f2 > bestValue) { - bestX = x2 - bestValue = f2 - } - } else { - right = x2 - x2 = x1 - f2 = f1 - x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) - f1 = sanitizeScore(objective(x1)) - if (f1 > bestValue) { - bestX = x1 - bestValue = f1 - } - } - - const mid = 0.5 * (left + right) - const scale = Math.max(PROP_MIN_INPUT, Math.abs(mid)) - if (right - left <= PROP_ARB_INPUT_REL_TOL * scale) { - break - } - } - - return { x: bestX, value: bestValue } -} - -function pickBestCandidate( - buyCandidate: PropArbCandidate | null, - sellCandidate: PropArbCandidate | null, -): PropArbCandidate | null { - if (buyCandidate && sellCandidate) { - return sellCandidate.expectedProfit > buyCandidate.expectedProfit ? sellCandidate : buyCandidate - } - - return buyCandidate ?? sellCandidate -} - -export function findSubmissionArbOpportunity(args: { - fairPrice: number - quoteBuyX: (inputY: number) => number - quoteSellX: (inputX: number) => number - sampleStartY: () => number - minArbProfitY?: number -}): PropArbCandidate | null { - const { - fairPrice, - quoteBuyX, - quoteSellX, - sampleStartY, - minArbProfitY = PROP_MIN_ARB_PROFIT_Y, - } = args - - if (!Number.isFinite(fairPrice) || fairPrice <= 0) { - return null - } - - const minBuyInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y) - const minSellInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y / Math.max(fairPrice, 1e-9)) - const startY = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minBuyInput, sampleStartY())) - const startX = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minSellInput, startY / Math.max(fairPrice, 1e-9))) - - const buyBracket = bracketMaximum(startY, minBuyInput, PROP_MAX_INPUT_AMOUNT, (inputY) => { - const outputX = quoteBuyX(inputY) - return outputX * fairPrice - inputY - }) - - const buyOptimal = goldenSectionMax(buyBracket[0], buyBracket[1], (inputY) => { - const outputX = quoteBuyX(inputY) - return outputX * fairPrice - inputY - }) - - let buyCandidate: PropArbCandidate | null = null - if (buyOptimal.x >= minBuyInput) { - const outputX = quoteBuyX(buyOptimal.x) - const expectedProfit = outputX * fairPrice - buyOptimal.x - if (outputX > 0 && expectedProfit >= minArbProfitY) { - buyCandidate = { - side: 0, - inputAmount: buyOptimal.x, - expectedProfit, - } - } - } - - const sellBracket = bracketMaximum(startX, minSellInput, PROP_MAX_INPUT_AMOUNT, (inputX) => { - const outputY = quoteSellX(inputX) - return outputY - inputX * fairPrice - }) - - const sellOptimal = goldenSectionMax(sellBracket[0], sellBracket[1], (inputX) => { - const outputY = quoteSellX(inputX) - return outputY - inputX * fairPrice - }) - - let sellCandidate: PropArbCandidate | null = null - if (sellOptimal.x >= minSellInput) { - const outputY = quoteSellX(sellOptimal.x) - const expectedProfit = outputY - sellOptimal.x * fairPrice - if (outputY > 0 && expectedProfit >= minArbProfitY) { - sellCandidate = { - side: 1, - inputAmount: sellOptimal.x, - expectedProfit, - } - } - } - - return pickBestCandidate(buyCandidate, sellCandidate) -} - -export function findNormalizerArbOpportunity(args: { - amm: PropAmmState - fairPrice: number - quoteBuyX: (inputY: number) => number - quoteSellX: (inputX: number) => number - minArbProfitY?: number -}): PropArbCandidate | null { - const { - amm, - fairPrice, - quoteBuyX, - quoteSellX, - minArbProfitY = PROP_MIN_ARB_PROFIT_Y, - } = args - - if (!Number.isFinite(fairPrice) || fairPrice <= 0) { - return null - } - - const feeBps = normalizerFeeBps(amm) - const gamma = (10_000 - feeBps) / 10_000 - - if (!Number.isFinite(gamma) || gamma <= 0 || amm.reserveX <= 0 || amm.reserveY <= 0) { - return null - } - - const minBuyInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y) - const minSellInput = Math.max(PROP_MIN_INPUT, PROP_MIN_ARB_NOTIONAL_Y / Math.max(fairPrice, 1e-9)) - - let buyCandidate: PropArbCandidate | null = null - const buyTarget = Math.sqrt(fairPrice * amm.reserveX * gamma * amm.reserveY) - if (Number.isFinite(buyTarget) && buyTarget > amm.reserveY) { - const inputY = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minBuyInput, (buyTarget - amm.reserveY) / gamma)) - const outputX = quoteBuyX(inputY) - const expectedProfit = outputX * fairPrice - inputY - if (outputX > 0 && expectedProfit >= minArbProfitY) { - buyCandidate = { - side: 0, - inputAmount: inputY, - expectedProfit, - } - } - } - - let sellCandidate: PropArbCandidate | null = null - const sellTarget = Math.sqrt((amm.reserveY * amm.reserveX * gamma) / fairPrice) - if (Number.isFinite(sellTarget) && sellTarget > amm.reserveX) { - const inputX = Math.min(PROP_MAX_INPUT_AMOUNT, Math.max(minSellInput, (sellTarget - amm.reserveX) / gamma)) - const outputY = quoteSellX(inputX) - const expectedProfit = outputY - inputX * fairPrice - if (outputY > 0 && expectedProfit >= minArbProfitY) { - sellCandidate = { - side: 1, - inputAmount: inputX, - expectedProfit, - } - } - } - - return pickBestCandidate(buyCandidate, sellCandidate) -} diff --git a/lib/prop-sim/constants.ts b/lib/prop-sim/constants.ts index f6dbe31..8b328e8 100644 --- a/lib/prop-sim/constants.ts +++ b/lib/prop-sim/constants.ts @@ -1,50 +1,50 @@ -export const PROP_STORAGE_SIZE = 1024 +/** + * Prop AMM Challenge simulation parameters + * Based on: https://github.com/benedictbrady/prop-amm-challenge + */ -export const PROP_INITIAL_PRICE = 100 +// Initial reserves export const PROP_INITIAL_RESERVE_X = 100 export const PROP_INITIAL_RESERVE_Y = 10_000 +export const PROP_INITIAL_PRICE = 100 -export const PROP_DEFAULT_STEPS = 10_000 - -export const PROP_GBM_MU = 0 -export const PROP_GBM_DT = 1 -export const PROP_GBM_SIGMA_MIN = 0.0001 -export const PROP_GBM_SIGMA_MAX = 0.007 +// 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 -export const PROP_RETAIL_ARRIVAL_MIN = 0.4 -export const PROP_RETAIL_ARRIVAL_MAX = 1.2 -export const PROP_RETAIL_MEAN_SIZE_MIN = 12 -export const PROP_RETAIL_MEAN_SIZE_MAX = 28 -export const PROP_RETAIL_SIZE_SIGMA = 1.2 -export const PROP_RETAIL_BUY_PROB = 0.5 +// 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 -export const PROP_NORMALIZER_FEE_MIN = 30 -export const PROP_NORMALIZER_FEE_MAX = 80 -export const PROP_NORMALIZER_LIQ_MIN = 0.4 -export const PROP_NORMALIZER_LIQ_MAX = 2.0 +// 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 -export const PROP_MIN_ARB_PROFIT_Y = 0.01 -export const PROP_MIN_ARB_NOTIONAL_Y = 0.01 -export const PROP_MIN_INPUT = 0.001 -export const PROP_MIN_TRADE_SIZE = 0.001 +// Arbitrage parameters +export const PROP_ARB_MIN_PROFIT = 0.01 // Y units (1 cent) +export const PROP_ARB_BRACKET_TOLERANCE = 0.01 // 1% relative -export const PROP_U64_MAX = 18_446_744_073_709_551_615n -export const PROP_NANO_SCALE = 1_000_000_000n -export const PROP_NANO_SCALE_F64 = 1_000_000_000 -export const PROP_MAX_INPUT_AMOUNT = (Number(PROP_U64_MAX) / PROP_NANO_SCALE_F64) * 0.999_999 +// 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 -export const GOLDEN_RATIO_CONJUGATE = 0.618_033_988_749_894_8 +// Golden ratio for golden-section search +export const PHI = (1 + Math.sqrt(5)) / 2 +export const GOLDEN_RATIO = 1 / PHI // ≈ 0.618 -export const PROP_ARB_BRACKET_MAX_STEPS = 24 -export const PROP_ARB_BRACKET_GROWTH = 2 -export const PROP_ARB_GOLDEN_MAX_ITERS = 12 -export const PROP_ARB_INPUT_REL_TOL = 1e-2 +// Scale factor for bigint conversions (1e9) +export const PROP_SCALE = 1_000_000_000n +export const PROP_SCALE_NUM = 1_000_000_000 -export const PROP_ROUTER_GOLDEN_MAX_ITERS = 14 -export const PROP_ROUTER_ALPHA_TOL = 1e-3 -export const PROP_ROUTER_SUBMISSION_AMOUNT_REL_TOL = 1e-2 -export const PROP_ROUTER_SCORE_REL_GAP_TOL = 1e-2 +// 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' }, @@ -53,3 +53,9 @@ export const PROP_SPEED_PROFILE: Record = 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 index b386854..7ebba69 100644 --- a/lib/prop-sim/engine.ts +++ b/lib/prop-sim/engine.ts @@ -1,104 +1,96 @@ -import { getChartViewWindow, type ChartWindow } from '../sim/chart' -import { formatNum, formatSigned } from '../sim/utils' -import type { Snapshot as LegacySnapshot } from '../sim/types' import { - ammK, - ammSpot, - createInitialNormalizerAmm, - createInitialSubmissionAmm, - executeBuyX, - executeSellX, - quoteNormalizerBuyX, - quoteNormalizerSellX, -} from './amm' -import { findNormalizerArbOpportunity, findSubmissionArbOpportunity } from './arbitrage' -import { - PROP_DEFAULT_STEPS, - PROP_GBM_DT, - PROP_GBM_MU, - PROP_GBM_SIGMA_MAX, - PROP_GBM_SIGMA_MIN, - PROP_MIN_TRADE_SIZE, - PROP_NORMALIZER_FEE_MAX, - PROP_NORMALIZER_FEE_MIN, - PROP_NORMALIZER_LIQ_MAX, - PROP_NORMALIZER_LIQ_MIN, - PROP_RETAIL_ARRIVAL_MAX, - PROP_RETAIL_ARRIVAL_MIN, - PROP_RETAIL_MEAN_SIZE_MAX, - PROP_RETAIL_MEAN_SIZE_MIN, -} from './constants' -import { bigintToString, ensureStorageSize, fromNano, stringToBigint, toNano } from './nano' -import { GbmPriceProcess } from './priceProcess' -import { generateRetailOrders, sampleLogNormal } from './retail' -import { routeRetailOrder } from './router' + createPropAmm, + executePropBuyX, + executePropSellX, + findPropArbOpportunity, + formatNum, + formatSigned, + fromScaledBigInt, + normalizerQuoteBuyX, + normalizerQuoteSellX, + propAmmK, + propAmmSpot, + routePropRetailOrder, + toScaledBigInt, + type PropQuoteFn, +} from './math' import type { + PropActiveStrategyRuntime, PropAmmState, - PropFlowType, - PropRetailOrder, - PropSampledRegime, + PropNormalizerConfig, PropSimulationConfig, PropSnapshot, - PropStrategyRuntime, + PropStorageChange, PropTrade, PropTradeEvent, PropWorkerUiState, } from './types' -import { getStarterCodeLines } from '../prop-strategies/builtins' - -interface PropRandomSource { - next: () => number - between: (min: number, max: number) => number - gaussian: () => number -} +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: PropStrategyRuntime + strategy: PropActiveStrategyRuntime step: number tradeCount: number eventSeq: number fairPrice: number prevFairPrice: number - regime: PropSampledRegime - submission: PropAmmState | null - normalizer: PropAmmState | null + storage: Uint8Array + strategyAmm: PropAmmState | null + normalizerAmm: PropAmmState | null + normalizerConfig: PropNormalizerConfig + simulationParams: { + volatility: number + arrivalRate: number + orderSizeMean: number + } edge: { total: number retail: number arb: number } - lastStorageChangedBytes: number - lastStorageWriteStep: number | null + 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 EnqueueEventInput { - flow: PropFlowType - pool: 'submission' | 'normalizer' +interface PropTradeEventInput { + flow: PropTradeEvent['flow'] + amm: PropAmmState trade: PropTrade - priceMove: { from: number; to: number } - order: PropRetailOrder | null + order: PropTradeEvent['order'] arbProfit: number - routerSplit: PropTradeEvent['routerSplit'] -} - -function clampFinite(value: number, fallback: number): number { - if (!Number.isFinite(value)) { - return fallback - } - return value + priceMove: { from: number; to: number } } export class PropSimulationEngine { private readonly state: PropEngineState - constructor(config: PropSimulationConfig, strategy: PropStrategyRuntime) { + constructor(config: PropSimulationConfig, strategy: PropActiveStrategyRuntime) { this.state = { config, strategy, @@ -107,48 +99,41 @@ export class PropSimulationEngine { eventSeq: 0, fairPrice: 100, prevFairPrice: 100, - regime: { - gbmSigma: 0.001, - retailArrivalRate: 0.8, - retailMeanSize: 20, - normFeeBps: 30, - normLiquidityMult: 1, + 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, }, - submission: null, - normalizer: null, - edge: { - total: 0, - retail: 0, - arb: 0, - }, - lastStorageChangedBytes: 0, - lastStorageWriteStep: null, + 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: Partial): void { - this.state.config = { - ...this.state.config, - ...config, - nSteps: config.nSteps ?? this.state.config.nSteps, - } - - if (this.state.history.length > this.state.config.maxTapeRows) { - this.state.history = this.state.history.slice(0, this.state.config.maxTapeRows) + 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: PropStrategyRuntime): void { + public setStrategy(strategy: PropActiveStrategyRuntime): void { this.state.strategy = strategy } - public reset(random: PropRandomSource): void { + public reset( + randomBetween: (min: number, max: number) => number, + ): void { this.state.step = 0 this.state.tradeCount = 0 this.state.eventSeq = 0 @@ -156,59 +141,77 @@ export class PropSimulationEngine { 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 - this.state.lastStorageChangedBytes = 0 - this.state.lastStorageWriteStep = null - const sampledFee = Math.floor(random.between(PROP_NORMALIZER_FEE_MIN, PROP_NORMALIZER_FEE_MAX + 1)) + // 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), + } - this.state.regime = { - gbmSigma: random.between(PROP_GBM_SIGMA_MIN, PROP_GBM_SIGMA_MAX), - retailArrivalRate: random.between(PROP_RETAIL_ARRIVAL_MIN, PROP_RETAIL_ARRIVAL_MAX), - retailMeanSize: random.between(PROP_RETAIL_MEAN_SIZE_MIN, PROP_RETAIL_MEAN_SIZE_MAX), - normFeeBps: Math.max(PROP_NORMALIZER_FEE_MIN, Math.min(PROP_NORMALIZER_FEE_MAX, sampledFee)), - normLiquidityMult: random.between(PROP_NORMALIZER_LIQ_MIN, PROP_NORMALIZER_LIQ_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), } - this.state.submission = createInitialSubmissionAmm() - this.state.normalizer = createInitialNormalizerAmm( - this.state.regime.normLiquidityMult, - this.state.regime.normFeeBps, + // Create AMMs + this.state.strategyAmm = createPropAmm( + this.state.strategy.name, + PROP_INITIAL_RESERVE_X, + PROP_INITIAL_RESERVE_Y, + true, ) - const submission = this.requireAmm(this.state.submission) - this.state.reserveTrail = [{ x: submission.reserveX, y: submission.reserveY }] + 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', - pool: 'submission', - poolName: 'Submission', - isSubmissionTrade: false, + 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, - routerSplit: null, arbProfit: 0, fairPrice: this.state.fairPrice, priceMove: { from: this.state.fairPrice, to: this.state.fairPrice }, - edgeDelta: 0, - codeLines: [66, 67], - codeExplanation: - 'Simulation initialized. Starter strategy uses constant-product pricing with 500 bps fee and no storage writes.', - stateBadge: this.buildStateBadge(), - summary: `Regime sampled: sigma ${(this.state.regime.gbmSigma * 100).toFixed(3)}% | lambda ${this.state.regime.retailArrivalRate.toFixed(3)} | normalizer ${this.state.regime.normFeeBps} bps @ ${this.state.regime.normLiquidityMult.toFixed(2)}x`, - storageChangedBytes: 0, snapshot: this.state.currentSnapshot, } this.refreshViewWindow() } - public stepOne(random: PropRandomSource): boolean { - this.ensurePendingEvents(random) + public stepOne( + randomBetween: (min: number, max: number) => number, + gaussianRandom: () => number, + ): boolean { + this.ensurePendingEvents(randomBetween, gaussianRandom) if (!this.state.pendingEvents.length) { return false } @@ -222,7 +225,7 @@ export class PropSimulationEngine { this.state.lastEvent = event this.state.currentSnapshot = event.snapshot - this.trackReservePoint(event.snapshot) + this.state.reserveTrail = trackReservePoint(this.state.reserveTrail, event.snapshot) this.refreshViewWindow() this.state.history.unshift(event) @@ -233,449 +236,323 @@ export class PropSimulationEngine { return true } - private ensurePendingEvents(random: PropRandomSource): void { + 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(random) + this.generateNextStep(randomBetween, gaussianRandom) guard += 1 - if (this.state.step >= this.state.config.nSteps) { - break - } } } - private generateNextStep(random: PropRandomSource): void { - if (this.state.step >= this.state.config.nSteps) { - return - } - - const submission = this.requireAmm(this.state.submission) - const normalizer = this.requireAmm(this.state.normalizer) + 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 priceProcess = new GbmPriceProcess( - oldPrice, - PROP_GBM_MU, - this.state.regime.gbmSigma, - PROP_GBM_DT, - ) - this.state.fairPrice = clampFinite(priceProcess.step(random.gaussian()), oldPrice) + 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 } - this.runSubmissionArbitrage(submission, random, priceMove) - this.runNormalizerArbitrage(normalizer, priceMove) - - const orders = generateRetailOrders( - this.state.regime.retailArrivalRate, - this.state.regime.retailMeanSize, - () => random.next(), - () => random.gaussian(), - ) + // Run arbitrage on both AMMs + this.runArbitrageForAmm(strategyAmm, this.makeStrategyQuoteFn(), priceMove, true) + this.runArbitrageForAmm(normalizerAmm, this.makeNormalizerQuoteFn(), priceMove, false) - for (const order of orders) { - this.processRetailOrder(order, priceMove) + // Route retail order (Poisson arrival) + if (randomBetween(0, 1) < this.state.simulationParams.arrivalRate) { + const order = this.generateRetailOrder(randomBetween, gaussianRandom) + this.routeRetailOrder(order, priceMove) } } - private runSubmissionArbitrage( - submission: PropAmmState, - random: PropRandomSource, - priceMove: { from: number; to: number }, - ): void { - const candidate = findSubmissionArbOpportunity({ - fairPrice: this.state.fairPrice, - quoteBuyX: (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), - quoteSellX: (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), - sampleStartY: () => sampleLogNormal(this.state.regime.retailMeanSize, 1.2, () => random.gaussian()), - }) + private makeStrategyQuoteFn(): PropQuoteFn { + const strategy = this.state.strategy + const storage = this.state.storage + const amm = this.requireAmm(this.state.strategyAmm) - if (!candidate || candidate.inputAmount <= 0) { - return + return (side: 0 | 1, inputAmount: number): number => { + return strategy.computeSwap(amm.reserveX, amm.reserveY, side, inputAmount, storage) } + } - const trade = - candidate.side === 0 - ? executeBuyX(submission, (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), candidate.inputAmount) - : executeSellX(submission, (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), candidate.inputAmount) + private makeNormalizerQuoteFn(): PropQuoteFn { + const amm = this.requireAmm(this.state.normalizerAmm) + const feeBps = this.state.normalizerConfig.feeBps - if (!trade) { - return + 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) + } } - - const arbProfit = trade.side === 0 - ? trade.outputAmount * this.state.fairPrice - trade.inputAmount - : trade.outputAmount - trade.inputAmount * this.state.fairPrice - - this.enqueueTradeEvent({ - flow: 'arbitrage', - pool: 'submission', - trade, - priceMove, - order: null, - arbProfit, - routerSplit: null, - }) } - private runNormalizerArbitrage( - normalizer: PropAmmState, + private runArbitrageForAmm( + amm: PropAmmState, + quoteFn: PropQuoteFn, priceMove: { from: number; to: number }, + isStrategy: boolean, ): void { - const candidate = findNormalizerArbOpportunity({ - amm: normalizer, - fairPrice: this.state.fairPrice, - quoteBuyX: (inputY) => quoteNormalizerBuyX(normalizer, inputY), - quoteSellX: (inputX) => quoteNormalizerSellX(normalizer, inputX), - }) - - if (!candidate || candidate.inputAmount <= 0) { + const arb = findPropArbOpportunity(amm, this.state.fairPrice, quoteFn) + if (!arb || arb.inputAmount <= 0.00000001) { return } - const trade = - candidate.side === 0 - ? executeBuyX(normalizer, (inputY) => quoteNormalizerBuyX(normalizer, inputY), candidate.inputAmount) - : executeSellX(normalizer, (inputX) => quoteNormalizerSellX(normalizer, inputX), candidate.inputAmount) + 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 } - const arbProfit = trade.side === 0 - ? trade.outputAmount * this.state.fairPrice - trade.inputAmount - : trade.outputAmount - trade.inputAmount * this.state.fairPrice - this.enqueueTradeEvent({ flow: 'arbitrage', - pool: 'normalizer', + amm, trade, - priceMove, order: null, - arbProfit, - routerSplit: null, - }) + arbProfit: arb.expectedProfit, + priceMove, + }, isStrategy, quoteFn) } - private processRetailOrder(order: PropRetailOrder, priceMove: { from: number; to: number }): void { - const submission = this.requireAmm(this.state.submission) - const normalizer = this.requireAmm(this.state.normalizer) + 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 decision = routeRetailOrder({ + const splits = routePropRetailOrder( + strategyAmm, + normalizerAmm, + strategyQuote, + normalizerQuote, order, - fairPrice: this.state.fairPrice, - quoteSubmissionBuyX: (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), - quoteSubmissionSellX: (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), - quoteNormalizerBuyX: (inputY) => quoteNormalizerBuyX(normalizer, inputY), - quoteNormalizerSellX: (inputX) => quoteNormalizerSellX(normalizer, inputX), - }) - - if (decision.submissionInput > PROP_MIN_TRADE_SIZE && decision.submissionOutput > 0) { - const trade = - order.side === 'buy' - ? executeBuyX(submission, (inputY) => this.quoteSubmissionSwap(submission, 0, inputY), decision.submissionInput) - : executeSellX(submission, (inputX) => this.quoteSubmissionSwap(submission, 1, inputX), decision.submissionInput) + ) - if (trade) { - this.enqueueTradeEvent({ - flow: 'retail', - pool: 'submission', - trade, - priceMove, - order, - arbProfit: 0, - routerSplit: { - alpha: decision.alpha, - submissionInput: decision.submissionInput, - normalizerInput: decision.normalizerInput, - }, - }) - } - } + for (const [amm, amount, quoteFn] of splits) { + const isStrategy = amm.isStrategy + let trade: PropTrade | null = null - if (decision.normalizerInput > PROP_MIN_TRADE_SIZE && decision.normalizerOutput > 0) { - const trade = - order.side === 'buy' - ? executeBuyX(normalizer, (inputY) => quoteNormalizerBuyX(normalizer, inputY), decision.normalizerInput) - : executeSellX(normalizer, (inputX) => quoteNormalizerSellX(normalizer, inputX), decision.normalizerInput) + 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', - pool: 'normalizer', + amm, trade, - priceMove, order, arbProfit: 0, - routerSplit: { - alpha: decision.alpha, - submissionInput: decision.submissionInput, - normalizerInput: decision.normalizerInput, - }, - }) + priceMove, + }, isStrategy, quoteFn) } } } - private enqueueTradeEvent(input: EnqueueEventInput): void { - const { flow, pool, trade, priceMove, order, arbProfit, routerSplit } = input + private enqueueTradeEvent( + input: PropTradeEventInput, + isStrategy: boolean, + quoteFn: PropQuoteFn, + ): void { + const { flow, amm, trade, order, arbProfit, priceMove } = input let edgeDelta = 0 - let storageChangedBytes = 0 - let codeLines: number[] = [] - let codeExplanation = 'Trade executed on normalizer. Submission strategy was not invoked.' - - if (pool === 'submission') { + if (isStrategy) { if (flow === 'arbitrage') { edgeDelta = -arbProfit this.state.edge.arb += edgeDelta } else { - edgeDelta = this.computeRetailEdge(trade) + // 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 - storageChangedBytes = this.applyAfterSwap(trade) - codeLines = getStarterCodeLines(trade.side) - codeExplanation = this.describeSubmissionExecution(flow, trade, edgeDelta, storageChangedBytes) + // 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, - pool, - poolName: pool === 'submission' ? 'Submission' : 'Normalizer', - isSubmissionTrade: pool === 'submission', + ammName: amm.name, + isStrategyTrade: isStrategy, trade, order, - routerSplit, arbProfit, fairPrice: this.state.fairPrice, priceMove, edgeDelta, - codeLines, + codeLines: isStrategy ? [1] : [], codeExplanation, - stateBadge: this.buildStateBadge(), - summary: this.describeTrade(pool, flow, trade, order), - storageChangedBytes, + stateBadge, + summary: this.describeTrade(flow, amm, trade, order), snapshot: this.snapshotState(), + strategyExecution: isStrategy ? { + outputAmount: trade.outputAmount, + storageChanges: [], + } : undefined, } this.state.pendingEvents.push(event) } - private computeRetailEdge(trade: PropTrade): number { - if (trade.side === 1) { - return trade.inputAmount * this.state.fairPrice - trade.outputAmount - } - - return trade.inputAmount - trade.outputAmount * this.state.fairPrice - } - - private applyAfterSwap(trade: PropTrade): number { - const submission = this.requireAmm(this.state.submission) - const beforeStorage = submission.storage - const workingStorage = beforeStorage.slice() as Uint8Array - - const side = trade.side - const instruction = { - side, - inputAmountNano: stringToBigint(trade.inputAmountNano), - outputAmountNano: stringToBigint(trade.outputAmountNano), - reserveXNano: toNano(trade.reserveX), - reserveYNano: toNano(trade.reserveY), - step: this.state.step, - storage: workingStorage, - } - - let nextStorage: Uint8Array = workingStorage - try { - const maybeNext = this.state.strategy.afterSwap(instruction) - if (maybeNext instanceof Uint8Array) { - nextStorage = ensureStorageSize(maybeNext) - } else { - nextStorage = ensureStorageSize(workingStorage) - } - } catch { - nextStorage = beforeStorage - } - - let changedBytes = 0 - for (let index = 0; index < beforeStorage.length; index += 1) { - if (beforeStorage[index] !== nextStorage[index]) { - changedBytes += 1 - } - } - - submission.storage = nextStorage - this.state.lastStorageChangedBytes = changedBytes - - if (changedBytes > 0) { - this.state.lastStorageWriteStep = this.state.step - } - - return changedBytes - } - - private quoteSubmissionSwap(amm: PropAmmState, side: 0 | 1, inputAmount: number): number { - if (!Number.isFinite(inputAmount) || inputAmount <= 0) { - return 0 - } - - const outputNano = this.state.strategy.computeSwap({ - side, - inputAmountNano: toNano(inputAmount), - reserveXNano: toNano(amm.reserveX), - reserveYNano: toNano(amm.reserveY), - storage: amm.storage, - }) - - const output = fromNano(outputNano) - - if (!Number.isFinite(output) || output <= 0) { - return 0 - } - - if (side === 0 && output >= amm.reserveX) { - return 0 - } - - if (side === 1 && output >= amm.reserveY) { - return 0 - } - - return output - } - - private describeSubmissionExecution( - flow: PropFlowType, + private describeStrategyExecution( trade: PropTrade, + flow: PropTradeEvent['flow'], edgeDelta: number, - storageChangedBytes: number, ): string { - const branch = trade.side === 0 ? '`compute_swap` buy-X branch' : '`compute_swap` sell-X branch' - const flowLabel = flow === 'arbitrage' ? 'arbitrage' : 'retail' - return `${branch} executed for ${flowLabel}. input=${formatNum(trade.inputAmount, 4)}, output=${formatNum(trade.outputAmount, 4)}, edge delta=${formatSigned(edgeDelta)}, storage changed=${storageChangedBytes} bytes.` + 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( - pool: 'submission' | 'normalizer', - flow: PropFlowType, + flow: PropTradeEvent['flow'], + amm: PropAmmState, trade: PropTrade, - order: PropRetailOrder | null, + order: { side: 'buy' | 'sell'; sizeY: number } | null, ): string { - const direction = trade.side === 0 ? 'buy X (Y in)' : 'sell X (X in)' - const base = `${pool}: ${direction} | in=${formatNum(trade.inputAmount, 4)} out=${formatNum(trade.outputAmount, 4)}` + 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} | fair=${formatNum(this.state.fairPrice, 4)}` + return `${base} | arb vs fair ${formatNum(this.state.fairPrice, 2)}` } - return `${base} | retail ${order?.side ?? 'n/a'} ${formatNum(order?.sizeY ?? 0, 3)} Y` + const orderLabel = order ? `${order.side} ${formatNum(order.sizeY, 2)} Y` : 'retail' + return `${base} | routed from ${orderLabel}` } - private buildStateBadge(): string { - const lastWrite = this.state.lastStorageWriteStep === null ? 'n/a' : `step ${this.state.lastStorageWriteStep}` - return `storage Δ=${this.state.lastStorageChangedBytes} bytes | last write: ${lastWrite}` + 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 submission = this.requireAmm(this.state.submission) - const normalizer = this.requireAmm(this.state.normalizer) + const strategyAmm = this.requireAmm(this.state.strategyAmm) + const normalizerAmm = this.requireAmm(this.state.normalizerAmm) return { step: this.state.step, fairPrice: this.state.fairPrice, - submission: { - x: submission.reserveX, - y: submission.reserveY, - spot: ammSpot(submission), - k: ammK(submission), - }, - normalizer: { - x: normalizer.reserveX, - y: normalizer.reserveY, - spot: ammSpot(normalizer), - k: ammK(normalizer), - feeBps: this.state.regime.normFeeBps, - liquidityMult: this.state.regime.normLiquidityMult, - }, - edge: { - total: this.state.edge.total, - retail: this.state.edge.retail, - arb: this.state.edge.arb, - }, - regime: this.state.regime, - storage: { - lastChangedBytes: this.state.lastStorageChangedBytes, - lastWriteStep: this.state.lastStorageWriteStep, - }, - } - } - - private toLegacySnapshot(snapshot: PropSnapshot): LegacySnapshot { - return { - step: snapshot.step, - fairPrice: snapshot.fairPrice, strategy: { - x: snapshot.submission.x, - y: snapshot.submission.y, - bid: 500, - ask: 500, - k: snapshot.submission.k, + x: strategyAmm.reserveX, + y: strategyAmm.reserveY, + k: propAmmK(strategyAmm), + impliedBidBps: this.state.impliedFees.bidBps, + impliedAskBps: this.state.impliedFees.askBps, }, normalizer: { - x: snapshot.normalizer.x, - y: snapshot.normalizer.y, - bid: snapshot.normalizer.feeBps, - ask: snapshot.normalizer.feeBps, - k: snapshot.normalizer.k, + 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, }, - edge: snapshot.edge, - } - } - - private refreshViewWindow(): void { - if (!this.state.currentSnapshot) { - return } - - const legacy = this.toLegacySnapshot(this.state.currentSnapshot) - const targetX = Math.sqrt(legacy.strategy.k / Math.max(legacy.fairPrice, 1e-9)) - const targetY = legacy.strategy.k / Math.max(targetX, 1e-9) - - this.state.viewWindow = getChartViewWindow( - legacy, - targetX, - targetY, - this.state.reserveTrail, - this.state.viewWindow, - ) } - private trackReservePoint(snapshot: PropSnapshot): void { - const point = { x: snapshot.submission.x, y: snapshot.submission.y } - const last = this.state.reserveTrail[this.state.reserveTrail.length - 1] - - if (last && Math.abs(last.x - point.x) < 1e-6 && Math.abs(last.y - point.y) < 1e-3) { - return - } - - this.state.reserveTrail.push(point) - if (this.state.reserveTrail.length > 180) { - this.state.reserveTrail.shift() - } + 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('Prop AMM state is not initialized') + throw new Error('AMM state not initialized') } - return amm } @@ -684,20 +561,17 @@ export class PropSimulationEngine { isPlaying: boolean, ): PropWorkerUiState { if (!this.state.currentSnapshot || !this.state.lastEvent) { - throw new Error('Prop simulation is not initialized') + throw new Error('Simulation is not initialized') } return { - config: { - ...this.state.config, - nSteps: this.state.config.nSteps || PROP_DEFAULT_STEPS, - }, + 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, - modelUsed: this.state.strategy.modelUsed, + feeBps: this.state.strategy.feeBps, }, isPlaying, tradeCount: this.state.tradeCount, @@ -707,6 +581,7 @@ export class PropSimulationEngine { 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 index 0306611..f8ea742 100644 --- a/lib/prop-sim/index.ts +++ b/lib/prop-sim/index.ts @@ -1,10 +1,4 @@ -export * from './constants' export * from './types' -export * from './nano' -export * from './rng' -export * from './priceProcess' -export * from './amm' -export * from './arbitrage' -export * from './router' -export * from './retail' +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/nano.ts b/lib/prop-sim/nano.ts deleted file mode 100644 index 1a792e4..0000000 --- a/lib/prop-sim/nano.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { PROP_NANO_SCALE, PROP_NANO_SCALE_F64, PROP_STORAGE_SIZE, PROP_U64_MAX } from './constants' - -export function clampU64(value: bigint): bigint { - if (value <= 0n) return 0n - if (value >= PROP_U64_MAX) return PROP_U64_MAX - return value -} - -export function toNano(value: number): bigint { - if (Number.isNaN(value) || value <= 0) { - return 0n - } - - if (!Number.isFinite(value)) { - return PROP_U64_MAX - } - - const scaled = value * PROP_NANO_SCALE_F64 - if (scaled >= Number(PROP_U64_MAX)) { - return PROP_U64_MAX - } - - return BigInt(Math.floor(scaled)) -} - -export function fromNano(value: bigint): number { - return Number(clampU64(value)) / PROP_NANO_SCALE_F64 -} - -export function ceilDiv(numerator: bigint, denominator: bigint): bigint { - if (denominator <= 0n) { - return 0n - } - - return (numerator + denominator - 1n) / denominator -} - -export function saturatingSub(a: bigint, b: bigint): bigint { - return a > b ? a - b : 0n -} - -export function encodeU16Le(value: number): Uint8Array { - const normalized = Math.max(0, Math.min(0xffff, Math.trunc(value))) - return new Uint8Array([normalized & 0xff, (normalized >>> 8) & 0xff]) -} - -export function readU16Le(storage: Uint8Array, offset = 0): number { - if (offset < 0 || offset + 1 >= storage.length) { - return 0 - } - - return storage[offset] | (storage[offset + 1] << 8) -} - -export function ensureStorageSize(storage: Uint8Array): Uint8Array { - if (storage.length === PROP_STORAGE_SIZE) { - return storage - } - - const next = new Uint8Array(PROP_STORAGE_SIZE) - next.set(storage.subarray(0, PROP_STORAGE_SIZE)) - return next -} - -export function bigintToString(value: bigint): string { - return clampU64(value).toString() -} - -export function stringToBigint(value: string): bigint { - try { - return clampU64(BigInt(value)) - } catch { - return 0n - } -} - -export { PROP_NANO_SCALE } diff --git a/lib/prop-sim/priceProcess.ts b/lib/prop-sim/priceProcess.ts deleted file mode 100644 index 2747a5e..0000000 --- a/lib/prop-sim/priceProcess.ts +++ /dev/null @@ -1,22 +0,0 @@ -export class GbmPriceProcess { - private current: number - - private readonly driftTerm: number - - private readonly volTerm: number - - constructor(initialPrice: number, mu: number, sigma: number, dt: number) { - this.current = initialPrice - this.driftTerm = (mu - 0.5 * sigma * sigma) * dt - this.volTerm = sigma * Math.sqrt(dt) - } - - public currentPrice(): number { - return this.current - } - - public step(gaussianShock: number): number { - this.current *= Math.exp(this.driftTerm + this.volTerm * gaussianShock) - return this.current - } -} diff --git a/lib/prop-sim/retail.ts b/lib/prop-sim/retail.ts deleted file mode 100644 index 0701b89..0000000 --- a/lib/prop-sim/retail.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - PROP_MIN_INPUT, - PROP_RETAIL_BUY_PROB, - PROP_RETAIL_SIZE_SIGMA, -} from './constants' -import type { PropRetailOrder } from './types' - -export function samplePoisson(lambda: number, randomUnit: () => number): number { - const clamped = Math.max(0.01, lambda) - const threshold = Math.exp(-clamped) - - let count = 0 - let product = 1 - - while (product > threshold) { - count += 1 - product *= Math.max(1e-12, randomUnit()) - } - - return count - 1 -} - -export function sampleLogNormal(mean: number, sigma: number, gaussianRandom: () => number): number { - const sigmaSafe = Math.max(0.01, sigma) - const muLn = Math.log(Math.max(0.01, mean)) - 0.5 * sigmaSafe * sigmaSafe - const sample = Math.exp(muLn + sigmaSafe * gaussianRandom()) - return Math.max(PROP_MIN_INPUT, sample) -} - -export function generateRetailOrders( - arrivalRate: number, - meanSizeY: number, - randomUnit: () => number, - gaussianRandom: () => number, -): PropRetailOrder[] { - const count = samplePoisson(arrivalRate, randomUnit) - if (count <= 0) { - return [] - } - - const orders: PropRetailOrder[] = [] - for (let index = 0; index < count; index += 1) { - const side = randomUnit() < PROP_RETAIL_BUY_PROB ? 'buy' : 'sell' - const sizeY = sampleLogNormal(meanSizeY, PROP_RETAIL_SIZE_SIGMA, gaussianRandom) - orders.push({ side, sizeY }) - } - - return orders -} diff --git a/lib/prop-sim/rng.ts b/lib/prop-sim/rng.ts deleted file mode 100644 index f43115d..0000000 --- a/lib/prop-sim/rng.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { SeededRng } from '../sim/utils' - -export class PropRng { - private readonly rng: SeededRng - - constructor(seed: number) { - this.rng = new SeededRng(seed) - } - - public reset(seed: number): void { - this.rng.reset(seed) - } - - public next(): number { - return this.rng.next() - } - - public between(min: number, max: number): number { - return this.rng.between(min, max) - } - - public gaussian(): number { - return this.rng.gaussian() - } -} diff --git a/lib/prop-sim/router.ts b/lib/prop-sim/router.ts deleted file mode 100644 index cfaa3e5..0000000 --- a/lib/prop-sim/router.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - GOLDEN_RATIO_CONJUGATE, - PROP_MIN_TRADE_SIZE, - PROP_ROUTER_ALPHA_TOL, - PROP_ROUTER_GOLDEN_MAX_ITERS, - PROP_ROUTER_SCORE_REL_GAP_TOL, - PROP_ROUTER_SUBMISSION_AMOUNT_REL_TOL, -} from './constants' -import type { PropOrderSide, PropRetailOrder } from './types' - -interface QuotePoint { - alpha: number - inSubmission: number - inNormalizer: number - outSubmission: number - outNormalizer: number -} - -export interface RouterDecision { - orderSide: PropOrderSide - alpha: number - submissionInput: number - normalizerInput: number - submissionOutput: number - normalizerOutput: number -} - -function quoteScore(point: QuotePoint): number { - const score = point.outSubmission + point.outNormalizer - return Number.isFinite(score) ? score : Number.NEGATIVE_INFINITY -} - -function bestQuote(a: QuotePoint, b: QuotePoint): QuotePoint { - return quoteScore(b) > quoteScore(a) ? b : a -} - -function withinRelGap(a: number, b: number, relTol: number): boolean { - if (!Number.isFinite(a) || !Number.isFinite(b)) { - return false - } - - const denominator = Math.max(1e-12, Math.abs(a), Math.abs(b)) - return Math.abs(a - b) <= relTol * denominator -} - -function maximizeSplit(totalInput: number, evaluate: (alpha: number) => QuotePoint): QuotePoint { - let left = 0 - let right = 1 - - const edgeLeft = evaluate(left) - const edgeRight = evaluate(right) - let best = bestQuote(edgeLeft, edgeRight) - - let x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) - let x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) - let q1 = evaluate(x1) - let q2 = evaluate(x2) - best = bestQuote(best, q1) - best = bestQuote(best, q2) - - for (let index = 0; index < PROP_ROUTER_GOLDEN_MAX_ITERS; index += 1) { - if (right - left <= PROP_ROUTER_ALPHA_TOL) { - break - } - - const midAlpha = 0.5 * (left + right) - const submissionMidAmount = totalInput * midAlpha - const amountWidth = totalInput * (right - left) - const amountScale = Math.max(PROP_MIN_TRADE_SIZE, Math.abs(submissionMidAmount)) - if (amountWidth <= PROP_ROUTER_SUBMISSION_AMOUNT_REL_TOL * amountScale) { - break - } - - if (withinRelGap(quoteScore(q1), quoteScore(q2), PROP_ROUTER_SCORE_REL_GAP_TOL)) { - break - } - - if (quoteScore(q1) < quoteScore(q2)) { - left = x1 - x1 = x2 - q1 = q2 - x2 = left + GOLDEN_RATIO_CONJUGATE * (right - left) - q2 = evaluate(x2) - best = bestQuote(best, q2) - } else { - right = x2 - x2 = x1 - q2 = q1 - x1 = right - GOLDEN_RATIO_CONJUGATE * (right - left) - q1 = evaluate(x1) - best = bestQuote(best, q1) - } - } - - const center = evaluate((left + right) * 0.5) - best = bestQuote(best, center) - - return best -} - -export function routeRetailOrder(args: { - order: PropRetailOrder - fairPrice: number - quoteSubmissionBuyX: (inputY: number) => number - quoteSubmissionSellX: (inputX: number) => number - quoteNormalizerBuyX: (inputY: number) => number - quoteNormalizerSellX: (inputX: number) => number -}): RouterDecision { - const { - order, - fairPrice, - quoteSubmissionBuyX, - quoteSubmissionSellX, - quoteNormalizerBuyX, - quoteNormalizerSellX, - } = args - - if (order.side === 'buy') { - const totalY = Math.max(0, order.sizeY) - - const best = maximizeSplit(totalY, (alpha) => { - const inSubmission = totalY * Math.min(1, Math.max(0, alpha)) - const inNormalizer = totalY * (1 - Math.min(1, Math.max(0, alpha))) - - const outSubmission = inSubmission > PROP_MIN_TRADE_SIZE ? quoteSubmissionBuyX(inSubmission) : 0 - const outNormalizer = inNormalizer > PROP_MIN_TRADE_SIZE ? quoteNormalizerBuyX(inNormalizer) : 0 - - return { - alpha, - inSubmission, - inNormalizer, - outSubmission, - outNormalizer, - } - }) - - return { - orderSide: order.side, - alpha: best.alpha, - submissionInput: best.inSubmission, - normalizerInput: best.inNormalizer, - submissionOutput: best.outSubmission, - normalizerOutput: best.outNormalizer, - } - } - - const totalX = order.sizeY / Math.max(fairPrice, 1e-9) - const best = maximizeSplit(totalX, (alpha) => { - const inSubmission = totalX * Math.min(1, Math.max(0, alpha)) - const inNormalizer = totalX * (1 - Math.min(1, Math.max(0, alpha))) - - const outSubmission = inSubmission > PROP_MIN_TRADE_SIZE ? quoteSubmissionSellX(inSubmission) : 0 - const outNormalizer = inNormalizer > PROP_MIN_TRADE_SIZE ? quoteNormalizerSellX(inNormalizer) : 0 - - return { - alpha, - inSubmission, - inNormalizer, - outSubmission, - outNormalizer, - } - }) - - return { - orderSide: order.side, - alpha: best.alpha, - submissionInput: best.inSubmission, - normalizerInput: best.inNormalizer, - submissionOutput: best.outSubmission, - normalizerOutput: best.outNormalizer, - } -} diff --git a/lib/prop-sim/types.ts b/lib/prop-sim/types.ts index f638fc6..b9168b3 100644 --- a/lib/prop-sim/types.ts +++ b/lib/prop-sim/types.ts @@ -1,7 +1,4 @@ -export type PropFlowType = 'system' | 'arbitrage' | 'retail' -export type PropPool = 'submission' | 'normalizer' -export type PropOrderSide = 'buy' | 'sell' -export type PropSwapSide = 0 | 1 +export type PropFlowType = 'arbitrage' | 'retail' | 'system' export type PropStrategyKind = 'builtin' @@ -15,117 +12,85 @@ export interface PropSimulationConfig { strategyRef: PropStrategyRef playbackSpeed: number maxTapeRows: number - nSteps: number -} - -export interface PropSampledRegime { - gbmSigma: number - retailArrivalRate: number - retailMeanSize: number - normFeeBps: number - normLiquidityMult: number -} - -export interface PropStorageSummary { - lastChangedBytes: number - lastWriteStep: number | null } export interface PropAmmState { - pool: PropPool name: string reserveX: number reserveY: number - storage: Uint8Array -} - -export interface PropSwapInstruction { - side: PropSwapSide - inputAmountNano: bigint - reserveXNano: bigint - reserveYNano: bigint - storage: Uint8Array -} - -export interface PropAfterSwapInstruction { - side: PropSwapSide - inputAmountNano: bigint - outputAmountNano: bigint - reserveXNano: bigint - reserveYNano: bigint - step: number - storage: Uint8Array + isStrategy: boolean } -export interface PropStrategyRuntime { - ref: PropStrategyRef - name: string - code: string - modelUsed: string - computeSwap: (instruction: PropSwapInstruction) => bigint - afterSwap: (instruction: PropAfterSwapInstruction) => Uint8Array | void +export interface PropNormalizerConfig { + feeBps: number // Sampled per simulation: 30-80 + liquidityMult: number // Sampled per simulation: 0.4-2.0 } export interface PropTrade { - side: PropSwapSide - direction: 'buy_x' | 'sell_x' + side: 'buy' | 'sell' // buy = AMM buys X (receives X, pays Y), sell = AMM sells X inputAmount: number outputAmount: number - inputAmountNano: string - outputAmountNano: string + timestamp: number + reserveX: number // Post-trade + reserveY: number beforeX: number beforeY: number - reserveX: number - reserveY: number spotBefore: number spotAfter: number + impliedFeeBps: number // Back-calculated from trade } -export interface PropRetailOrder { - side: PropOrderSide - sizeY: number +export interface PropSnapshotAmm { + x: number + y: number + k: number // x * y for reference + impliedBidBps: number // Last trade implied fee + impliedAskBps: number } -export interface PropSnapshotAmm { +export interface PropSnapshotNormalizer { x: number y: number - spot: number k: number + feeBps: number + liquidityMult: number } export interface PropSnapshot { step: number fairPrice: number - submission: PropSnapshotAmm - normalizer: PropSnapshotAmm & { - feeBps: number - liquidityMult: number - } + strategy: PropSnapshotAmm + normalizer: PropSnapshotNormalizer edge: { total: number retail: number arb: number } - regime: PropSampledRegime - storage: PropStorageSummary + 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 - pool: PropPool - poolName: string - isSubmissionTrade: boolean + ammName: string + isStrategyTrade: boolean trade: PropTrade | null - order: PropRetailOrder | null - routerSplit: - | { - alpha: number - submissionInput: number - normalizerInput: number - } - | null + order: { side: 'buy' | 'sell'; sizeY: number } | null arbProfit: number fairPrice: number priceMove: { from: number; to: number } @@ -134,8 +99,56 @@ export interface PropTradeEvent { codeExplanation: string stateBadge: string summary: string - storageChangedBytes: number 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 { @@ -145,7 +158,7 @@ export interface PropWorkerUiState { id: string name: string code: string - modelUsed: string + feeBps: number } isPlaying: boolean tradeCount: number @@ -155,4 +168,14 @@ export interface PropWorkerUiState { 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 index 0f4a795..572bd92 100644 --- a/lib/prop-strategies/builtins.ts +++ b/lib/prop-strategies/builtins.ts @@ -1,84 +1,231 @@ -import { ceilDiv, clampU64, ensureStorageSize, saturatingSub } from '../prop-sim/nano' -import type { PropAfterSwapInstruction, PropStrategyRef, PropStrategyRuntime, PropSwapInstruction } from '../prop-sim/types' -import { STARTER_CODE_LINES, STARTER_STRATEGY_SOURCE } from './starterSource' - -interface PropBuiltinStrategy { - id: string - name: string - modelUsed: string - code: string - computeSwap: (instruction: PropSwapInstruction) => bigint - afterSwap: (instruction: PropAfterSwapInstruction) => Uint8Array | void -} +import type { PropBuiltinStrategy, PropComputeSwapInput } from '../prop-sim/types' -const STARTER_FEE_NUMERATOR = 950n -const STARTER_FEE_DENOMINATOR = 1000n +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}; -function starterComputeSwap(instruction: PropSwapInstruction): bigint { - const reserveX = clampU64(instruction.reserveXNano) - const reserveY = clampU64(instruction.reserveYNano) - const inputAmount = clampU64(instruction.inputAmountNano) +/// Required: displayed on the leaderboard. +const NAME: &str = "Starter (500 bps)"; +const MODEL_USED: &str = "None"; - if (reserveX === 0n || reserveY === 0n) { - return 0n - } +const FEE_NUMERATOR: u128 = 950; +const FEE_DENOMINATOR: u128 = 1000; +const STORAGE_SIZE: usize = 1024; - const k = reserveX * reserveY +#[derive(wincode::SchemaRead)] +struct ComputeSwapInstruction { + side: u8, + input_amount: u64, + reserve_x: u64, + reserve_y: u64, + _storage: [u8; STORAGE_SIZE], +} - if (instruction.side === 0) { - const netY = (inputAmount * STARTER_FEE_NUMERATOR) / STARTER_FEE_DENOMINATOR - const newReserveY = reserveY + netY - const kDiv = ceilDiv(k, newReserveY) - return saturatingSub(reserveX, kDiv) - } +#[cfg(not(feature = "no-entrypoint"))] +entrypoint!(process_instruction); - if (instruction.side === 1) { - const netX = (inputAmount * STARTER_FEE_NUMERATOR) / STARTER_FEE_DENOMINATOR - const newReserveX = reserveX + netX - const kDiv = ceilDiv(k, newReserveX) - return saturatingSub(reserveY, kDiv) - } +pub fn process_instruction( + _program_id: &Pubkey, _accounts: &[AccountInfo], instruction_data: &[u8], +) -> ProgramResult { + if instruction_data.is_empty() { + return Ok(()); + } - return 0n + 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(()) } -function starterAfterSwap(instruction: PropAfterSwapInstruction): Uint8Array { - return ensureStorageSize(instruction.storage) +pub fn get_model_used() -> &'static str { + MODEL_USED } -const BUILTIN_STRATEGIES: PropBuiltinStrategy[] = [ - { - id: 'starter', - name: 'Starter (500 bps)', - modelUsed: 'GPT-5.3-Codex', - code: STARTER_STRATEGY_SOURCE, - computeSwap: starterComputeSwap, - afterSwap: starterAfterSwap, - }, -] +pub fn compute_swap(data: &[u8]) -> u64 { + let decoded: ComputeSwapInstruction = match wincode::deserialize(data) { + Ok(decoded) => decoded, + Err(_) => return 0, + }; -export const PROP_BUILTIN_STRATEGIES = BUILTIN_STRATEGIES.map((strategy) => ({ - kind: 'builtin' as const, - id: strategy.id, - name: strategy.name, -})) + 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; -export function getPropBuiltinStrategy(ref: PropStrategyRef): PropStrategyRuntime { - const strategy = BUILTIN_STRATEGIES.find((item) => item.id === ref.id) - if (!strategy) { - throw new Error(`Builtin strategy '${ref.id}' not found.`) - } + 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, + } +}` - return { - ref, - name: strategy.name, - code: strategy.code, - modelUsed: strategy.modelUsed, - computeSwap: strategy.computeSwap, - afterSwap: strategy.afterSwap, +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 function getStarterCodeLines(side: 0 | 1): number[] { - return side === 0 ? STARTER_CODE_LINES.buyBranch : STARTER_CODE_LINES.sellBranch +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/prop-strategies/starterSource.ts b/lib/prop-strategies/starterSource.ts deleted file mode 100644 index 9130bb2..0000000 --- a/lib/prop-strategies/starterSource.ts +++ /dev/null @@ -1,94 +0,0 @@ -export const STARTER_STRATEGY_SOURCE = `use pinocchio::{account_info::AccountInfo, entrypoint, pubkey::Pubkey, ProgramResult}; -use prop_amm_submission_sdk::{set_return_data_bytes, set_return_data_u64}; - -const NAME: &str = "My Strategy"; -const MODEL_USED: &str = "GPT-5.3-Codex"; // Use "None" for fully human-written submissions. -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] { - // tag 0 or 1 = compute_swap (side) - 0 | 1 => { - let output = compute_swap(instruction_data); - set_return_data_u64(output); - } - // tag 2 = after_swap (no-op for starter) - 2 => { - // No storage updates needed for basic CFMM - } - // tag 3 = get_name (for leaderboard display) - 3 => set_return_data_bytes(NAME.as_bytes()), - // tag 4 = get_model_used (for metadata display) - 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 => { - 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; - reserve_x.saturating_sub(k_div) as u64 - } - 1 => { - 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; - reserve_y.saturating_sub(k_div) as u64 - } - _ => 0, - } -} -` - -export const STARTER_CODE_LINES = { - reserveGuard: [66, 67], - buyBranch: [73, 74, 75, 76], - sellBranch: [79, 80, 81, 82], -} diff --git a/store/usePropPlaybackStore.ts b/store/usePropPlaybackStore.ts deleted file mode 100644 index 4fe2d33..0000000 --- a/store/usePropPlaybackStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -'use client' - -import { create } from 'zustand' -import type { PropWorkerUiState } from '../lib/prop-sim/types' - -interface PropPlaybackStoreState { - workerState: PropWorkerUiState | null - workerError: string | null - setWorkerState: (state: PropWorkerUiState) => void - setWorkerError: (message: string | null) => void -} - -export const usePropPlaybackStore = create((set) => ({ - workerState: null, - workerError: null, - setWorkerState: (workerState) => set({ workerState }), - setWorkerError: (workerError) => set({ workerError }), -})) diff --git a/store/usePropUiStore.ts b/store/usePropUiStore.ts deleted file mode 100644 index 3becc0a..0000000 --- a/store/usePropUiStore.ts +++ /dev/null @@ -1,54 +0,0 @@ -'use client' - -import { create } from 'zustand' -import { persist } from 'zustand/middleware' -import { PROP_DEFAULT_STEPS } from '../lib/prop-sim/constants' -import type { PropStrategyRef } from '../lib/prop-sim/types' - -interface PropUiStoreState { - playbackSpeed: number - maxTapeRows: number - nSteps: number - strategyRef: PropStrategyRef - showCodeExplanation: boolean - chartAutoZoom: boolean - setPlaybackSpeed: (value: number) => void - setMaxTapeRows: (value: number) => void - setNSteps: (value: number) => void - setStrategyRef: (value: PropStrategyRef) => void - setShowCodeExplanation: (value: boolean) => void - setChartAutoZoom: (value: boolean) => void -} - -export const usePropUiStore = create()( - persist( - (set) => ({ - playbackSpeed: 3, - maxTapeRows: 20, - nSteps: PROP_DEFAULT_STEPS, - strategyRef: { - kind: 'builtin', - id: 'starter', - }, - showCodeExplanation: true, - chartAutoZoom: true, - setPlaybackSpeed: (playbackSpeed) => set({ playbackSpeed }), - setMaxTapeRows: (maxTapeRows) => set({ maxTapeRows }), - setNSteps: (nSteps) => set({ nSteps: Math.max(1, Math.trunc(nSteps) || PROP_DEFAULT_STEPS) }), - setStrategyRef: (strategyRef) => set({ strategyRef }), - setShowCodeExplanation: (showCodeExplanation) => set({ showCodeExplanation }), - setChartAutoZoom: (chartAutoZoom) => set({ chartAutoZoom }), - }), - { - name: 'ammvisualizer-prop-ui-v1', - partialize: (state) => ({ - playbackSpeed: state.playbackSpeed, - maxTapeRows: state.maxTapeRows, - nSteps: state.nSteps, - strategyRef: state.strategyRef, - showCodeExplanation: state.showCodeExplanation, - chartAutoZoom: state.chartAutoZoom, - }), - }, - ), -) diff --git a/tests/prop-engine-determinism.test.ts b/tests/prop-engine-determinism.test.ts deleted file mode 100644 index fb037c7..0000000 --- a/tests/prop-engine-determinism.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PropSimulationEngine } from '../lib/prop-sim/engine' -import { PROP_DEFAULT_STEPS } from '../lib/prop-sim/constants' -import { PropRng } from '../lib/prop-sim/rng' -import { getPropBuiltinStrategy, PROP_BUILTIN_STRATEGIES } from '../lib/prop-strategies/builtins' -import type { PropSimulationConfig } from '../lib/prop-sim/types' - -const baseConfig: PropSimulationConfig = { - seed: 1337, - strategyRef: { kind: 'builtin', id: 'starter' }, - playbackSpeed: 3, - maxTapeRows: 20, - nSteps: PROP_DEFAULT_STEPS, -} - -function runSeries(seed: number): Array<{ step: number; fairPrice: number; edge: number; storageDelta: number }> { - const config = { ...baseConfig, seed } - const runtime = getPropBuiltinStrategy(config.strategyRef) - const engine = new PropSimulationEngine(config, runtime) - const rng = new PropRng(seed) - - engine.reset(rng) - - const series: Array<{ step: number; fairPrice: number; edge: number; storageDelta: number }> = [] - let guard = 0 - - while (series.length < 30 && guard < 400) { - guard += 1 - const advanced = engine.stepOne(rng) - if (!advanced) { - break - } - - const state = engine.toUiState(PROP_BUILTIN_STRATEGIES, false) - series.push({ - step: state.snapshot.step, - fairPrice: Number(state.snapshot.fairPrice.toFixed(8)), - edge: Number(state.snapshot.edge.total.toFixed(8)), - storageDelta: state.snapshot.storage.lastChangedBytes, - }) - } - - return series -} - -describe('prop simulation engine determinism', () => { - it('replays same sequence for same seed', () => { - const first = runSeries(1337) - const second = runSeries(1337) - expect(first).toEqual(second) - }) - - it('produces a different sequence for different seeds', () => { - const first = runSeries(1337) - const second = runSeries(1338) - expect(first).not.toEqual(second) - }) - - it('maintains finite positive reserves in short runs', () => { - const config = { ...baseConfig, seed: 42 } - const runtime = getPropBuiltinStrategy(config.strategyRef) - const engine = new PropSimulationEngine(config, runtime) - const rng = new PropRng(config.seed) - - engine.reset(rng) - - for (let index = 0; index < 80; index += 1) { - engine.stepOne(rng) - const state = engine.toUiState(PROP_BUILTIN_STRATEGIES, false) - expect(Number.isFinite(state.snapshot.submission.x)).toBe(true) - expect(Number.isFinite(state.snapshot.submission.y)).toBe(true) - expect(Number.isFinite(state.snapshot.normalizer.x)).toBe(true) - expect(Number.isFinite(state.snapshot.normalizer.y)).toBe(true) - expect(state.snapshot.submission.x).toBeGreaterThan(0) - expect(state.snapshot.submission.y).toBeGreaterThan(0) - expect(state.snapshot.normalizer.x).toBeGreaterThan(0) - expect(state.snapshot.normalizer.y).toBeGreaterThan(0) - } - }) -}) diff --git a/tests/prop-nano.test.ts b/tests/prop-nano.test.ts deleted file mode 100644 index bb762e9..0000000 --- a/tests/prop-nano.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PROP_U64_MAX } from '../lib/prop-sim/constants' -import { ceilDiv, encodeU16Le, fromNano, readU16Le, saturatingSub, toNano } from '../lib/prop-sim/nano' - -describe('prop nano helpers', () => { - it('converts f64 values to nano with floor semantics', () => { - expect(toNano(1)).toBe(1_000_000_000n) - expect(toNano(1.2345678919)).toBe(1_234_567_891n) - expect(toNano(0)).toBe(0n) - expect(toNano(-5)).toBe(0n) - expect(toNano(Number.NaN)).toBe(0n) - expect(toNano(Number.POSITIVE_INFINITY)).toBe(PROP_U64_MAX) - }) - - it('converts nano back to f64', () => { - expect(fromNano(1_000_000_000n)).toBeCloseTo(1, 9) - expect(fromNano(123_456_789n)).toBeCloseTo(0.123456789, 12) - }) - - it('supports ceil division and saturating subtraction', () => { - expect(ceilDiv(10n, 3n)).toBe(4n) - expect(ceilDiv(9n, 3n)).toBe(3n) - expect(saturatingSub(10n, 5n)).toBe(5n) - expect(saturatingSub(5n, 10n)).toBe(0n) - }) - - it('encodes and decodes u16 little-endian values for storage', () => { - const encoded = encodeU16Le(80) - expect(encoded.length).toBe(2) - - const storage = new Uint8Array(1024) - storage.set(encoded, 0) - expect(readU16Le(storage, 0)).toBe(80) - }) -}) diff --git a/tests/prop-starter-runtime.test.ts b/tests/prop-starter-runtime.test.ts deleted file mode 100644 index 2635453..0000000 --- a/tests/prop-starter-runtime.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ceilDiv, saturatingSub } from '../lib/prop-sim/nano' -import { getPropBuiltinStrategy } from '../lib/prop-strategies/builtins' -import type { PropSwapInstruction } from '../lib/prop-sim/types' - -function expectedStarterSwap(side: 0 | 1, input: bigint, reserveX: bigint, reserveY: bigint): bigint { - if (reserveX === 0n || reserveY === 0n) { - return 0n - } - - const k = reserveX * reserveY - - if (side === 0) { - const netY = (input * 950n) / 1000n - const newReserveY = reserveY + netY - return saturatingSub(reserveX, ceilDiv(k, newReserveY)) - } - - const netX = (input * 950n) / 1000n - const newReserveX = reserveX + netX - return saturatingSub(reserveY, ceilDiv(k, newReserveX)) -} - -describe('starter builtin strategy runtime', () => { - const runtime = getPropBuiltinStrategy({ kind: 'builtin', id: 'starter' }) - - it('matches starter buy-side compute_swap behavior', () => { - const instruction: PropSwapInstruction = { - side: 0, - inputAmountNano: 10_000_000_000n, - reserveXNano: 100_000_000_000n, - reserveYNano: 10_000_000_000_000n, - storage: new Uint8Array(1024), - } - - const actual = runtime.computeSwap(instruction) - const expected = expectedStarterSwap(0, instruction.inputAmountNano, instruction.reserveXNano, instruction.reserveYNano) - expect(actual).toBe(expected) - }) - - it('matches starter sell-side compute_swap behavior', () => { - const instruction: PropSwapInstruction = { - side: 1, - inputAmountNano: 2_500_000_000n, - reserveXNano: 100_000_000_000n, - reserveYNano: 10_000_000_000_000n, - storage: new Uint8Array(1024), - } - - const actual = runtime.computeSwap(instruction) - const expected = expectedStarterSwap(1, instruction.inputAmountNano, instruction.reserveXNano, instruction.reserveYNano) - expect(actual).toBe(expected) - }) - - it('keeps storage unchanged in afterSwap (starter no-op)', () => { - const storage = new Uint8Array(1024) - storage[0] = 45 - storage[12] = 200 - - const next = runtime.afterSwap({ - side: 0, - inputAmountNano: 1n, - outputAmountNano: 1n, - reserveXNano: 1n, - reserveYNano: 1n, - step: 1, - storage, - }) - - const output = next ?? storage - expect(output[0]).toBe(45) - expect(output[12]).toBe(200) - }) -}) diff --git a/workers/prop-messages.ts b/workers/prop-messages.ts deleted file mode 100644 index fcca67d..0000000 --- a/workers/prop-messages.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { PropSimulationConfig, PropWorkerUiState } from '../lib/prop-sim/types' - -export type PropWorkerInboundMessage = - | { - type: 'INIT_PROP_SIM' - payload?: { - config?: Partial - } - } - | { - type: 'SET_PROP_CONFIG' - payload: { - config: Partial - } - } - | { - type: 'STEP_PROP_ONE' - } - | { - type: 'PLAY_PROP' - } - | { - type: 'PAUSE_PROP' - } - | { - type: 'RESET_PROP' - } - -export type PropWorkerOutboundMessage = - | { - type: 'PROP_STATE' - payload: { - state: PropWorkerUiState - } - } - | { - type: 'PROP_ERROR' - payload: { - message: string - } - } diff --git a/workers/prop-simulation.worker.ts b/workers/prop-simulation.worker.ts index 504e4d8..bfe2891 100644 --- a/workers/prop-simulation.worker.ts +++ b/workers/prop-simulation.worker.ts @@ -1,225 +1,240 @@ import { PropSimulationEngine } from '../lib/prop-sim/engine' -import { PROP_DEFAULT_STEPS, PROP_SPEED_PROFILE } from '../lib/prop-sim/constants' -import { PropRng } from '../lib/prop-sim/rng' -import type { PropSimulationConfig } from '../lib/prop-sim/types' -import { getPropBuiltinStrategy, PROP_BUILTIN_STRATEGIES } from '../lib/prop-strategies/builtins' -import type { PropWorkerInboundMessage, PropWorkerOutboundMessage } from './prop-messages' - -const worker = self as unknown as { - postMessage: (message: PropWorkerOutboundMessage) => void - onmessage: ((event: MessageEvent) => void) | null - setInterval: typeof setInterval - clearInterval: typeof clearInterval -} - -const DEFAULT_CONFIG: PropSimulationConfig = { - seed: 1337, - strategyRef: { - kind: 'builtin', - id: 'starter', - }, - playbackSpeed: 3, - maxTapeRows: 20, - nSteps: PROP_DEFAULT_STEPS, -} - -let config: PropSimulationConfig = { ...DEFAULT_CONFIG } -let engine: PropSimulationEngine | null = null -let rng = new PropRng(config.seed) +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 playTimer: ReturnType | null = null -let stepping = false -let messageQueue: Promise = Promise.resolve() - -worker.onmessage = (event) => { - const inbound = event.data - messageQueue = messageQueue - .then(async () => { - await handleMessage(inbound) - }) - .catch((error) => { - emitError(error) - }) -} - -async function handleMessage(message: PropWorkerInboundMessage): Promise { - switch (message.type) { - case 'INIT_PROP_SIM': { - config = { - ...config, - ...message.payload?.config, - } - config.nSteps = config.nSteps || PROP_DEFAULT_STEPS - rng.reset(config.seed) - await ensureEngineInitialized() - await resetEngine() - emitState() - break - } - - case 'SET_PROP_CONFIG': { - const previous = config - const next: PropSimulationConfig = { - ...config, - ...message.payload.config, - nSteps: message.payload.config.nSteps ?? config.nSteps, - } - config = next +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 + } +} - const seedChanged = next.seed !== previous.seed - const strategyChanged = - next.strategyRef.kind !== previous.strategyRef.kind || - next.strategyRef.id !== previous.strategyRef.id - const nStepsChanged = next.nSteps !== previous.nSteps +let rng = mulberry32(rngSeed) - await ensureEngineInitialized() - engine!.setConfig(next) +function seedRng(seed: number): void { + rngSeed = seed + rng = mulberry32(seed) +} - if (seedChanged) { - rng.reset(next.seed) - } +function randomBetween(min: number, max: number): number { + return min + rng() * (max - min) +} - if (strategyChanged || seedChanged || nStepsChanged) { - await resetEngine() - } +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) +} - if (isPlaying && next.playbackSpeed !== previous.playbackSpeed) { - restartPlaybackTimer() +// ============================================================================ +// 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) + }, + } +} - emitState() +// ============================================================================ +// 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 'STEP_PROP_ONE': { - stopPlayback() - await ensureEngineInitialized() - engine!.stepOne(rng) - emitState() + case 'setConfig': + handleSetConfig(msg.config) break - } - - case 'PLAY_PROP': { - await ensureEngineInitialized() - startPlayback() - emitState() + case 'setStrategy': + handleSetStrategy(msg.strategyRef) break - } - - case 'PAUSE_PROP': { - stopPlayback() - emitState() + case 'play': + handlePlay() break - } - - case 'RESET_PROP': { - stopPlayback() - await ensureEngineInitialized() - await resetEngine() - emitState() + case 'pause': + handlePause() + break + case 'step': + handleStep() + break + case 'reset': + handleReset() + break + case 'getState': + postState() break - } - - default: { - const unsupported: never = message - throw new Error(`Unsupported Prop worker message: ${JSON.stringify(unsupported)}`) - } } } -async function ensureEngineInitialized(): Promise { - if (engine) { - return - } - - const strategy = getPropBuiltinStrategy(config.strategyRef) +function handleInit(config: PropSimulationConfig): void { + seedRng(config.seed) + const strategy = createActiveStrategyRuntime(config.strategyRef) engine = new PropSimulationEngine(config, strategy) + engine.reset(randomBetween) + postState() } -async function resetEngine(): Promise { - if (!engine) { - return +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() +} - const strategy = getPropBuiltinStrategy(config.strategyRef) +function handleSetStrategy(strategyRef: PropStrategyRef): void { + if (!engine) return + const strategy = createActiveStrategyRuntime(strategyRef) engine.setStrategy(strategy) - engine.setConfig(config) - rng.reset(config.seed) - engine.reset(rng) + engine.reset(randomBetween) + postState() } -function startPlayback(): void { - if (!engine || isPlaying) { - return - } - +function handlePlay(): void { + if (!engine || isPlaying) return isPlaying = true - restartPlaybackTimer() + + const config = engine['state'].config + const speed = PROP_SPEED_PROFILE[config.playbackSpeed] ?? PROP_SPEED_PROFILE[3] + playbackInterval = setInterval(tick, speed.ms) + + postState() } -function stopPlayback(): void { +function handlePause(): void { isPlaying = false - - if (playTimer) { - worker.clearInterval(playTimer) - playTimer = null + if (playbackInterval) { + clearInterval(playbackInterval) + playbackInterval = null } + postState() } -function restartPlaybackTimer(): void { - if (!engine) { - return - } - - if (playTimer) { - worker.clearInterval(playTimer) - playTimer = null +function handleStep(): void { + if (!engine) return + if (isPlaying) { + handlePause() } + engine.stepOne(randomBetween, gaussianRandom) + postState() +} - const profile = PROP_SPEED_PROFILE[config.playbackSpeed] ?? PROP_SPEED_PROFILE[3] - - playTimer = worker.setInterval(() => { - if (!engine || !isPlaying || stepping) { - return - } +function handleReset(): void { + if (!engine) return + handlePause() + seedRng(engine['state'].config.seed) + engine.reset(randomBetween) + postState() +} - try { - stepping = true - const advanced = engine.stepOne(rng) - if (!advanced) { - stopPlayback() - } - emitState() - } catch (error) { - stopPlayback() - emitError(error) - } finally { - stepping = false - } - }, profile.ms) -} - -function emitState(): void { - if (!engine) { - return - } +function tick(): void { + if (!engine || !isPlaying) return + engine.stepOne(randomBetween, gaussianRandom) + postState() +} - worker.postMessage({ - type: 'PROP_STATE', - payload: { - state: engine.toUiState(PROP_BUILTIN_STRATEGIES, isPlaying), - }, - }) +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 }) } -function emitError(error: unknown): void { - const message = error instanceof Error ? error.message : String(error) +// ============================================================================ +// Worker Entry +// ============================================================================ - worker.postMessage({ - type: 'PROP_ERROR', - payload: { - message, - }, - }) +self.onmessage = (event: MessageEvent) => { + handleMessage(event.data) } + +// Signal ready +self.postMessage({ type: 'ready' }) From c31e3081202cfcf4e10d8a54f2fbde5b2dabd389 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sun, 15 Feb 2026 18:36:07 -0500 Subject: [PATCH 4/7] fix: resolve prop snapshot typing in chart trail tracking --- lib/prop-sim/engine.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/prop-sim/engine.ts b/lib/prop-sim/engine.ts index 7ebba69..0366df7 100644 --- a/lib/prop-sim/engine.ts +++ b/lib/prop-sim/engine.ts @@ -225,7 +225,10 @@ export class PropSimulationEngine { this.state.lastEvent = event this.state.currentSnapshot = event.snapshot - this.state.reserveTrail = trackReservePoint(this.state.reserveTrail, event.snapshot) + this.state.reserveTrail = trackReservePoint( + this.state.reserveTrail, + event.snapshot as unknown as Parameters[1], + ) this.refreshViewWindow() this.state.history.unshift(event) From 704cd0ec656a3e595cd9934d5a5a6cb42a32fac5 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sun, 15 Feb 2026 19:26:41 -0500 Subject: [PATCH 5/7] Fix prop-amm nav/chart UI and footer icon sizing --- app/globals.css | 118 +++++++++++++++++++++++++++++++++ app/prop-amm/page.tsx | 2 +- components/AmmChart.tsx | 54 ++++++++++----- components/FooterLinks.tsx | 2 +- components/HeaderActions.tsx | 13 ++++ components/PropMarketPanel.tsx | 1 + lib/sim/constants.ts | 45 +++++++++++++ 7 files changed, 216 insertions(+), 19 deletions(-) diff --git a/app/globals.css b/app/globals.css index 3891937..48c6cba 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); @@ -202,6 +234,57 @@ h3 { font-size: 0.74rem; } +.site-footer { + flex: 0 0 auto; + margin-top: 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 2px 4px 0; + color: var(--ink-soft); +} + +.footer-link { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 10px; + background: linear-gradient(180deg, var(--panel-strong) 0%, var(--panel-soft) 100%); + color: inherit; + text-decoration: none; + font-family: 'Space Mono', 'Courier New', monospace; + font-size: 0.68rem; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.footer-link:hover { + color: var(--accent); + border-color: var(--accent-soft); +} + +.x-logo { + width: 18px; + height: 18px; + border: 1px solid var(--line); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--panel-strong) 86%, white 14%); + flex: 0 0 auto; +} + +.x-logo-icon { + width: 10px; + height: 10px; + fill: currentColor; + display: block; +} + .layout { flex: 1 1 auto; min-height: 0; @@ -948,6 +1031,20 @@ h3 { background: linear-gradient(180deg, var(--chart-bg-top) 0%, var(--chart-bg-bottom) 100%); } +.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%); +} + +.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 +1435,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%; @@ -1348,6 +1455,17 @@ h3 { width: 100%; } + .site-footer { + padding: 4px 0 0; + gap: 6px; + } + + .footer-link { + font-size: 0.64rem; + letter-spacing: 0.05em; + padding: 5px 8px; + } + .panel-head { flex-direction: column; align-items: flex-start; diff --git a/app/prop-amm/page.tsx b/app/prop-amm/page.tsx index f25218e..7ed1ae4 100644 --- a/app/prop-amm/page.tsx +++ b/app/prop-amm/page.tsx @@ -125,7 +125,7 @@ export default function PropAmmPage() { return ( <>
    -
    +
    setTheme(theme === 'dark' ? 'light' : 'dark')} diff --git a/components/AmmChart.tsx b/components/AmmChart.tsx index da448ea..3956864 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,11 @@ 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' ? PROP_CHART_THEME[theme] : CHART_THEME[theme] const uid = useId().replace(/:/g, '') const clipId = `plotClip-${uid}` const markerId = `arrowHead-${uid}` @@ -114,6 +115,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 93e254c..dcb36ca 100644 --- a/components/HeaderActions.tsx +++ b/components/HeaderActions.tsx @@ -1,5 +1,8 @@ 'use client' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + import type { ThemeMode } from '../lib/sim/types' interface HeaderActionsProps { @@ -26,6 +29,8 @@ function GitHubIcon() { } 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' @@ -44,6 +49,14 @@ export function HeaderActions({ theme, onToggleTheme, subtitle, subtitleLink }:
    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', + }, +} From 318c7370e2ed4e34fdcdaf18a9d5ba965ba88574 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sun, 15 Feb 2026 19:33:18 -0500 Subject: [PATCH 6/7] Align prop UI with main panel and scope dark chart styling --- app/globals.css | 66 +--------------- app/prop-amm/page.tsx | 2 - components/PropCodePanel.tsx | 138 +++++++++++++++++++-------------- components/PropMarketPanel.tsx | 2 +- 4 files changed, 81 insertions(+), 127 deletions(-) diff --git a/app/globals.css b/app/globals.css index 48c6cba..a6bf521 100644 --- a/app/globals.css +++ b/app/globals.css @@ -234,57 +234,6 @@ h3 { font-size: 0.74rem; } -.site-footer { - flex: 0 0 auto; - margin-top: 6px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 2px 4px 0; - color: var(--ink-soft); -} - -.footer-link { - display: inline-flex; - align-items: center; - gap: 8px; - border: 1px solid var(--line); - border-radius: 999px; - padding: 5px 10px; - background: linear-gradient(180deg, var(--panel-strong) 0%, var(--panel-soft) 100%); - color: inherit; - text-decoration: none; - font-family: 'Space Mono', 'Courier New', monospace; - font-size: 0.68rem; - letter-spacing: 0.07em; - text-transform: uppercase; -} - -.footer-link:hover { - color: var(--accent); - border-color: var(--accent-soft); -} - -.x-logo { - width: 18px; - height: 18px; - border: 1px solid var(--line); - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - background: color-mix(in srgb, var(--panel-strong) 86%, white 14%); - flex: 0 0 auto; -} - -.x-logo-icon { - width: 10px; - height: 10px; - fill: currentColor; - display: block; -} - .layout { flex: 1 1 auto; min-height: 0; @@ -1031,14 +980,14 @@ h3 { background: linear-gradient(180deg, var(--chart-bg-top) 0%, var(--chart-bg-bottom) 100%); } -.prop-amm-page .chart-wrap { +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%); } -.prop-amm-page #curveChart { +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%), @@ -1455,17 +1404,6 @@ h3 { width: 100%; } - .site-footer { - padding: 4px 0 0; - gap: 6px; - } - - .footer-link { - font-size: 0.64rem; - letter-spacing: 0.05em; - padding: 5px 8px; - } - .panel-head { flex-direction: column; align-items: flex-start; diff --git a/app/prop-amm/page.tsx b/app/prop-amm/page.tsx index 7ed1ae4..91848d7 100644 --- a/app/prop-amm/page.tsx +++ b/app/prop-amm/page.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react' import { HeaderActions } from '../../components/HeaderActions' -import { FooterLinks } from '../../components/FooterLinks' import { PropCodePanel } from '../../components/PropCodePanel' import { PropMarketPanel } from '../../components/PropMarketPanel' import { usePropSimulationWorker } from '../../hooks/usePropSimulationWorker' @@ -177,7 +176,6 @@ export default function PropAmmPage() { /> -
    ) diff --git a/components/PropCodePanel.tsx b/components/PropCodePanel.tsx index 70dd348..129f401 100644 --- a/components/PropCodePanel.tsx +++ b/components/PropCodePanel.tsx @@ -1,6 +1,8 @@ 'use client' -import { useMemo } from 'react' +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 { @@ -24,73 +26,89 @@ export function PropCodePanel({ onSelectStrategy, onToggleExplanationOverlay, }: PropCodePanelProps) { - const lines = useMemo(() => code.split('\n'), [code]) - const highlightSet = useMemo(() => new Set(highlightedLines), [highlightedLines]) + 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 Code (Rust)

    -
    - - +
    +
    +
    +

    Strategy

    +
    + +
    + +
    -
    -
    -          
    -            {lines.map((line, i) => {
    -              const lineNum = i + 1
    -              const isHighlighted = highlightSet.has(lineNum)
    -              return (
    -                
    - {lineNum} - {line || ' '} -
    - ) - })} -
    -
    +
    + {lines.map((line, index) => { + const lineNumber = index + 1 + const active = lineSet.has(lineNumber) + + return ( +
    + {String(lineNumber).padStart(2, '0')} + +
    + ) + })}
    - {showExplanationOverlay ? ( -
    -

    What the code is doing

    -

    {codeExplanation}

    -
    - Note: Prop AMM strategies define a custom compute_swap function that returns{' '} - output_amount directly, rather than just adjusting fees on a constant-product curve. -
    -
    - ) : null} +
    + + +
    ) } diff --git a/components/PropMarketPanel.tsx b/components/PropMarketPanel.tsx index d215245..d93dd96 100644 --- a/components/PropMarketPanel.tsx +++ b/components/PropMarketPanel.tsx @@ -215,7 +215,7 @@ export function PropMarketPanel({ viewWindow={state.viewWindow} autoZoom={autoZoom} chartSize={chartSize} - variant="prop" + variant={theme === 'dark' ? 'prop' : 'classic'} />
    From 489e36e871aea41f114b66b2b975a68fe8283058 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sun, 15 Feb 2026 19:36:32 -0500 Subject: [PATCH 7/7] Use prop chart nomenclature in light mode with classic palette --- components/AmmChart.tsx | 6 +++++- components/PropMarketPanel.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/components/AmmChart.tsx b/components/AmmChart.tsx index 3956864..fce413b 100644 --- a/components/AmmChart.tsx +++ b/components/AmmChart.tsx @@ -24,7 +24,11 @@ interface AmmChartProps { } export function AmmChart({ snapshot, reserveTrail, lastEvent, theme, viewWindow, autoZoom, chartSize, variant = 'classic' }: AmmChartProps) { - const palette = variant === 'prop' ? PROP_CHART_THEME[theme] : CHART_THEME[theme] + 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}` diff --git a/components/PropMarketPanel.tsx b/components/PropMarketPanel.tsx index d93dd96..d215245 100644 --- a/components/PropMarketPanel.tsx +++ b/components/PropMarketPanel.tsx @@ -215,7 +215,7 @@ export function PropMarketPanel({ viewWindow={state.viewWindow} autoZoom={autoZoom} chartSize={chartSize} - variant={theme === 'dark' ? 'prop' : 'classic'} + variant="prop" />