From 1b138c95ec3d8523f98d44f21b265d060c4adf9a Mon Sep 17 00:00:00 2001 From: gustavobftorres Date: Fri, 6 Mar 2026 13:58:14 -0300 Subject: [PATCH 1/7] add formatted number for small amounts, features to improve simulation and unit tests to see all possible cases (for a protocol approach) --- .gitignore | 3 + .../simulator/DemandPressureConfig.tsx | 155 +- .../simulator/SimulatorConfig.tsx | 46 +- .../simulator/SimulatorStats.tsx | 20 +- .../simulator/SwapFormSwapTabLive.tsx | 3 +- .../simulator/SwapFormTWAPTabLive.tsx | 7 +- .../simulator/tabs/PriceChartTab.tsx | 7 +- .../lbp-simulator/simulator/tabs/SwapsTab.tsx | 3 +- lib/lbp-math.ts | 39 +- lib/utils.ts | 93 +- package.json | 3 +- public/workers/simulation-runner.js | 171 ++- scripts/run-scenario.mjs | 205 +++ store/useSimulatorStore.ts | 6 +- test/project-scenarios.test.ts | 1324 +++++++++++++++++ test/simulation.integration.test.ts | 75 +- 16 files changed, 2114 insertions(+), 46 deletions(-) create mode 100644 scripts/run-scenario.mjs create mode 100644 test/project-scenarios.test.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..1fbac26 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Scenarios +/scenarios \ No newline at end of file diff --git a/components/lbp-simulator/simulator/DemandPressureConfig.tsx b/components/lbp-simulator/simulator/DemandPressureConfig.tsx index 9426a42..442a157 100644 --- a/components/lbp-simulator/simulator/DemandPressureConfig.tsx +++ b/components/lbp-simulator/simulator/DemandPressureConfig.tsx @@ -263,27 +263,140 @@ function DemandPressureConfigComponent() {

-
- - - setLocalConfig((prev) => ({ - ...prev, - multiplier: Number(e.target.value), - })) - } - /> -

- Fine-tune the curve’s magnitude (e.g. 0.5x, 2x). -

-
- - - +
+ + + setLocalConfig((prev) => ({ + ...prev, + multiplier: Number(e.target.value), + })) + } + /> +

+ Fine-tune the curve’s magnitude (e.g. 0.5x, 2x). +

+
+ +
+ +
+
+ + + setLocalConfig((prev) => ({ + ...prev, + priceElasticity: Number(e.target.value), + })) + } + /> +
+ +
+ + +
+
+ +
+ + + setLocalConfig((prev) => ({ + ...prev, + priceElasticityReferenceMultiplier: Number(e.target.value), + })) + } + /> +
+ +
+
+ + +
+ +
+ + + setLocalConfig((prev) => ({ + ...prev, + priceElasticityBacklogMaxSpendMultiplier: Number(e.target.value), + })) + } + /> +
+
+ +

+ Scales per-step buys by (P_ref / P_now)^e, + where P_ref is the initial spot price times this multiplier. + Use <1 to model users waiting for a cheaper price. +

+
+ + + ); diff --git a/components/lbp-simulator/simulator/SimulatorConfig.tsx b/components/lbp-simulator/simulator/SimulatorConfig.tsx index 4556e64..544c5dd 100644 --- a/components/lbp-simulator/simulator/SimulatorConfig.tsx +++ b/components/lbp-simulator/simulator/SimulatorConfig.tsx @@ -29,9 +29,16 @@ import { import { useSimulatorStore } from "@/store/useSimulatorStore"; import { DemandPressureConfig } from "./DemandPressureConfig"; import { SellPressureConfig } from "./SellPressureConfig"; -import { useState, useEffect, useTransition, memo, useCallback } from "react"; +import { + useState, + useEffect, + useTransition, + memo, + useCallback, + useRef, +} from "react"; import { useDebounce } from "@/lib/useDebounce"; -import { LBPConfig } from "@/lib/lbp-math"; +import type { SellPressureConfig as SellPressureConfigType } from "@/lib/lbp-math"; import { useShallow } from "zustand/shallow"; import { TokenLogo } from "@/components/ui/TokenLogo"; import { formatNumber } from "@/lib/utils"; @@ -49,6 +56,7 @@ function SimulatorConfigComponent() { simulationSpeed, setSimulationSpeed, updateSellPressureConfig, + sellPressureConfig, } = useSimulatorStore( useShallow((state) => ({ config: state.config, @@ -60,6 +68,7 @@ function SimulatorConfigComponent() { simulationSpeed: state.simulationSpeed, setSimulationSpeed: state.setSimulationSpeed, updateSellPressureConfig: state.updateSellPressureConfig, + sellPressureConfig: state.sellPressureConfig, })), ); @@ -93,8 +102,14 @@ function SimulatorConfigComponent() { config.usdcBalanceIn, ); const [pressureMode, setPressureMode] = useState<"buy-and-sell" | "buy-only">( - "buy-and-sell", + () => { + if (sellPressureConfig.preset === "loyal") { + return sellPressureConfig.loyalSoldPct <= 0 ? "buy-only" : "buy-and-sell"; + } + return sellPressureConfig.greedySellPct <= 0 ? "buy-only" : "buy-and-sell"; + }, ); + const sellConfigBeforeBuyOnlyRef = useRef(null); // Update local state when store config changes useEffect(() => { @@ -282,7 +297,26 @@ function SimulatorConfigComponent() { onValueChange={(value: "buy-and-sell" | "buy-only") => { setPressureMode(value); if (value === "buy-only") { - updateSellPressureConfig({ loyalSoldPct: 0 }); + // Buy-only should disable sell pressure regardless of the current preset + // (e.g. if the user previously selected "greedy", that still sells). + if (sellConfigBeforeBuyOnlyRef.current == null) { + sellConfigBeforeBuyOnlyRef.current = sellPressureConfig; + } + updateSellPressureConfig({ + preset: "loyal", + loyalSoldPct: 0, + greedySellPct: 0, + }); + // Clear prior Sales rows + prevent ticking against stale snapshots. + restartSimulation(); + } else { + const prev = sellConfigBeforeBuyOnlyRef.current; + if (prev != null) { + sellConfigBeforeBuyOnlyRef.current = null; + updateSellPressureConfig(prev); + } + // New sell config means a new deterministic path; restart for a clean run. + restartSimulation(); } }} className="flex" @@ -314,7 +348,9 @@ function SimulatorConfigComponent() { : "opacity-0 pointer-events-none" }`} > - + {pressureMode === "buy-and-sell" ? ( + + ) : null} diff --git a/components/lbp-simulator/simulator/SimulatorStats.tsx b/components/lbp-simulator/simulator/SimulatorStats.tsx index a0c83c1..b9fd42a 100644 --- a/components/lbp-simulator/simulator/SimulatorStats.tsx +++ b/components/lbp-simulator/simulator/SimulatorStats.tsx @@ -5,6 +5,7 @@ import { StatCard } from "./StatCard"; import { useShallow } from "zustand/react/shallow"; import { memo, useEffect } from "react"; import { calcTVLUSD } from "@/lib/lbp-math"; +import { formatPrice } from "@/lib/utils"; function isEthOrWeth( token: string, @@ -85,11 +86,11 @@ const STAT_VALUE_SELECTORS: ValueSelector[] = [ }, (state) => { const d = getDerived(state); - return `$${(d.startPrice * d.collateralUsd).toFixed(2)}`; + return formatPrice(d.startPrice * d.collateralUsd); }, (state) => { const d = getDerived(state); - return `$${d.tokenPriceUsd.toFixed(2)}`; + return formatPrice(d.tokenPriceUsd); }, (state) => { const d = getDerived(state); @@ -99,6 +100,14 @@ const STAT_VALUE_SELECTORS: ValueSelector[] = [ const d = getDerived(state); return `$${(d.tvlUsd / 1_000_000).toFixed(2)}M`; }, + (state) => { + const d = getDerived(state); + const last = + state.simulationData[state.simulationData.length - 1] ?? + state.simulationData[0]; + const priceInUsd = (last?.price ?? 0) * d.collateralUsd; + return formatPrice(priceInUsd); + }, ]; /** Subscribes to store with a selector; only this (the value) re-renders when value changes. */ @@ -142,6 +151,11 @@ const STAT_META = [ description: "Total value locked in the pool: collateral balance (e.g. USDC) plus token balance valued at current spot price in collateral.", }, + { + label: "Final (0 swaps)", + description: + "Final price if there were no trades at all (weight shift only; balances stay constant).", + }, ] as const; function SimulatorStatsComponent() { @@ -160,7 +174,7 @@ function SimulatorStatsComponent() { }, [config.collateralToken, ethPriceUsd, fetchEthPrice]); return ( -
+
{STAT_META.map((meta, i) => ( Price: - ${priceUsd.toFixed(4)} {config.tokenSymbol}/{config.collateralToken} + {formatPrice(priceUsd)} {config.tokenSymbol}/{config.collateralToken}
diff --git a/components/lbp-simulator/simulator/SwapFormTWAPTabLive.tsx b/components/lbp-simulator/simulator/SwapFormTWAPTabLive.tsx index a7c9c67..2a0d239 100644 --- a/components/lbp-simulator/simulator/SwapFormTWAPTabLive.tsx +++ b/components/lbp-simulator/simulator/SwapFormTWAPTabLive.tsx @@ -5,6 +5,7 @@ import { useShallow } from "zustand/react/shallow"; import { useMemo, memo } from "react"; import { Button } from "@/components/ui/button"; import type { LBPConfig } from "@/lib/lbp-math"; +import { formatPrice } from "@/lib/utils"; /** * Subscribes only to step-changing state (currentPrice) and userUsdcBalance. @@ -75,7 +76,8 @@ function SwapFormTWAPTabLiveComponent({ if (part === "currentPrice") { return ( - Current price: {currentPrice.toFixed(4)} {config.collateralToken} /{" "} + Current price: {formatPrice(currentPrice, { currencySymbol: "" })}{" "} + {config.collateralToken} /{" "} {config.tokenSymbol} ); @@ -92,7 +94,8 @@ function SwapFormTWAPTabLiveComponent({ maximumFractionDigits: 4, })}{" "} {config.tokenSymbol} @ ~ - {currentPrice.toFixed(4)} {config.collateralToken} + {formatPrice(currentPrice, { currencySymbol: "" })}{" "} + {config.collateralToken} ); diff --git a/components/lbp-simulator/simulator/tabs/PriceChartTab.tsx b/components/lbp-simulator/simulator/tabs/PriceChartTab.tsx index 10a4b40..3a80733 100644 --- a/components/lbp-simulator/simulator/tabs/PriceChartTab.tsx +++ b/components/lbp-simulator/simulator/tabs/PriceChartTab.tsx @@ -12,6 +12,7 @@ import { Legend, } from "recharts"; import { memo, useMemo } from "react"; +import { formatPrice } from "@/lib/utils"; interface PriceChartTabProps { chartData: any[]; @@ -81,7 +82,7 @@ function PriceChartTabComponent({ domain={yAxisDomain} stroke={axisLabelColor} fontSize={12} - tickFormatter={(val) => `$${val.toFixed(2)}`} + tickFormatter={(val) => formatPrice(Number(val))} axisLine={false} tickLine={false} tick={{ fill: axisLabelColor }} @@ -107,7 +108,7 @@ function PriceChartTabComponent({ potentialPathHigh: "High path", }; const label = name ? labels[name] || name : "Price"; - return [`$${Number(value).toFixed(4)}`, label]; + return [formatPrice(Number(value)), label]; }} /> { const labels: Record = { price: "Spot price", - potentialPathLow: "Potential path (zero demand)", + potentialPathLow: "Potential path (no buys)", potentialPathMedium: "Potential path (medium demand)", potentialPathHigh: "Potential path (high demand)", }; diff --git a/components/lbp-simulator/simulator/tabs/SwapsTab.tsx b/components/lbp-simulator/simulator/tabs/SwapsTab.tsx index 9bc7605..6dfc15e 100644 --- a/components/lbp-simulator/simulator/tabs/SwapsTab.tsx +++ b/components/lbp-simulator/simulator/tabs/SwapsTab.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { useSimulatorStore } from "@/store/useSimulatorStore"; import { useShallow } from "zustand/react/shallow"; +import { formatPrice } from "@/lib/utils"; interface SwapsTabProps { swaps: any[]; @@ -82,7 +83,7 @@ function SwapsTabComponent({ swaps }: SwapsTabProps) { {outToken} - ${swap.price.toFixed(4)} + {formatPrice(Number(swap.price), { tinyCutoff: 1e-6 })} ); diff --git a/lib/lbp-math.ts b/lib/lbp-math.ts index 60da4dd..d1ba461 100644 --- a/lib/lbp-math.ts +++ b/lib/lbp-math.ts @@ -16,7 +16,7 @@ export interface LBPConfig { usdcWeightOut: number; // Final USDC weight (e.g. 90) startDelay: number; // Delay before start (in blocks/time) duration: number; // Duration of LBP (in hours) - swapFee: number; // Swap fee (e.g., 0.01 for 1%) + swapFee: number; // Swap fee (accepts fraction 0.01=1% or percent 1=1%) creatorFee: number; // Creator fee percentage (1-10%) } @@ -45,6 +45,40 @@ export interface DemandPressureConfig { preset: BuyPressurePreset; magnitudeBase: BuyPressureMagnitudeBase; // 10k / 100k / 1M multiplier: number; // fine control (e.g. 0.5x, 2x) + /** + * Optional price-elasticity model for buy pressure. + * + * When set > 0, per-step buy flow is scaled by: + * multiplier = (P_ref / P_now) ^ priceElasticity + * + * Where P_ref is the initial spot price (step 0) and P_now is the current + * spot price at the step before applying buys. + * + * - direction="down-only" (default): multiplier is capped at 1 (buyers don't + * exceed the baseline budget when price is cheaper). + * - direction="symmetric": multiplier can exceed 1 when price is cheaper, + * within [minMultiplier, maxMultiplier]. + */ + priceElasticity?: number; + priceElasticityDirection?: "down-only" | "symmetric"; + /** + * Multiplier applied to the reference price used by the elasticity model. + * + * Example: if initial spot price is $0.50 and you believe the market's + * "willingness to buy" is anchored closer to $0.10 early on, use 0.2. + */ + priceElasticityReferenceMultiplier?: number; + /** + * How price elasticity is applied to per-step buy pressure. + * - "multiplier": flow = baseFlow * elasticityMultiplier (may change total volume). + * - "backlog": baseFlow accumulates into a backlog; execution rate depends on price, + * which shifts volume later without increasing the total budget by default. + */ + priceElasticityExecutionModel?: "multiplier" | "backlog"; + /** Caps backlog execution to avoid unrealistic spikes (e.g. 5 = at most 5x baseFlow per step). */ + priceElasticityBacklogMaxSpendMultiplier?: number; + priceElasticityMinMultiplier?: number; + priceElasticityMaxMultiplier?: number; } export const DEFAULT_DEMAND_PRESSURE_CONFIG: DemandPressureConfig = { @@ -405,7 +439,8 @@ export function calculatePotentialPricePaths( sellPressureConfig.loyalConcentrationPct, ); const rawSwapFee = config.swapFee ?? 0; - const swapFeeFraction = rawSwapFee > 1 ? rawSwapFee / 100 : rawSwapFee; + // Treat 1 as 1% (not 100%), since the UI commonly inputs whole percents. + const swapFeeFraction = rawSwapFee >= 1 ? rawSwapFee / 100 : rawSwapFee; // For each scenario, simulate price evolution (from step 0) for (const demandMultiplier of scenarios) { diff --git a/lib/utils.ts b/lib/utils.ts index 3c0df0e..911ce85 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -10,4 +10,95 @@ export function formatNumber(value: number) { minimumFractionDigits: 0, maximumFractionDigits: 2, }).format(value); -} \ No newline at end of file +} + +const SUBSCRIPT_DIGITS: Record = { + "0": "₀", + "1": "₁", + "2": "₂", + "3": "₃", + "4": "₄", + "5": "₅", + "6": "₆", + "7": "₇", + "8": "₈", + "9": "₉", +}; + +function toSubscriptNumber(n: number) { + if (!Number.isFinite(n) || n < 0) return ""; + return String(Math.floor(n)) + .split("") + .map((ch) => SUBSCRIPT_DIGITS[ch] ?? ch) + .join(""); +} + +function trimTrailingZeros(s: string) { + if (!s.includes(".")) return s; + return s.replace(/(?:\.0+|(\.\d+?)0+)$/, "$1"); +} + +type FormatPriceOptions = { + currencySymbol?: string; + /** For abs(value) below this, use the compact 0.0ₙ123 style. */ + tinyCutoff?: number; + /** Significant digits shown after the 0.0ₙ prefix (e.g. 3 => "112"). */ + tinySigDigits?: number; +}; + +/** + * Formats a USD-ish price for display, including very small values. + * + * For very small values (default: < 1e-4), uses a compact representation: + * 0.0₅112 => 0.00000112 (5 leading zeros after the decimal) + * + * This uses Unicode subscripts so it works in charts/tooltips as plain text. + */ +export function formatPrice(value: number, opts: FormatPriceOptions = {}) { + const currencySymbol = opts.currencySymbol ?? "$"; + const tinyCutoff = + typeof opts.tinyCutoff === "number" && opts.tinyCutoff > 0 + ? opts.tinyCutoff + : 1e-4; + const tinySigDigits = + typeof opts.tinySigDigits === "number" && opts.tinySigDigits >= 2 + ? Math.floor(opts.tinySigDigits) + : 3; + + if (!Number.isFinite(value)) return `${currencySymbol}${String(value)}`; + + const sign = value < 0 ? "-" : ""; + const abs = Math.abs(value); + if (abs === 0) return `${currencySymbol}0`; + + // Normal range: keep existing feel (2 decimals for >= 1, more detail for < 1). + if (abs >= 1) { + return `${sign}${currencySymbol}${abs.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + } + + if (abs >= 0.01) { + return `${sign}${currencySymbol}${trimTrailingZeros(abs.toFixed(4))}`; + } + + if (abs >= tinyCutoff) { + return `${sign}${currencySymbol}${trimTrailingZeros(abs.toFixed(6))}`; + } + + // Tiny: 0.0ₙXYZ style. + const zeros = Math.max(0, Math.ceil(-Math.log10(abs)) - 1); + const scalePow = zeros + 1; + let scaled = abs * 10 ** scalePow; // ~ [1, 10) + let digitsStr = scaled.toFixed(tinySigDigits - 1).replace(".", ""); + if (digitsStr.startsWith("10")) { + // Handle rounding that pushes 9.99.. -> 10.00, which effectively reduces zero count by 1. + const nextZeros = Math.max(0, zeros - 1); + scaled = abs * 10 ** (nextZeros + 1); + digitsStr = scaled.toFixed(tinySigDigits - 1).replace(".", ""); + return `${sign}${currencySymbol}0.0${toSubscriptNumber(nextZeros)}${digitsStr}`; + } + + return `${sign}${currencySymbol}0.0${toSubscriptNumber(zeros)}${digitsStr}`; +} diff --git a/package.json b/package.json index f9f74b1..b714461 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:ui": "vitest --ui", "validate-quick": "tsx test/validade-quick.js", "prepare": "husky", - "compare-real-lbp": "tsx test/compare-real-lbp.ts" + "compare-real-lbp": "tsx test/compare-real-lbp.ts", + "scenario": "node scripts/run-scenario.mjs" }, "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", diff --git a/public/workers/simulation-runner.js b/public/workers/simulation-runner.js index bedc146..d3360fe 100644 --- a/public/workers/simulation-runner.js +++ b/public/workers/simulation-runner.js @@ -32,7 +32,69 @@ function clampNumber(v, min, max) { function normalizeSwapFee(swapFee) { if (!swapFee) return 0; - return swapFee > 1 ? swapFee / 100 : swapFee; + // UI uses percent (e.g. 2 => 2%). Also accept fractions (e.g. 0.01 => 1%). + // Treat 1 as 1% (not 100%), since that's the common UI input. + return swapFee >= 1 ? swapFee / 100 : swapFee; +} + +function getPriceElasticityMultiplier(demandConfig, priceNow, priceRef) { + const elasticity = Number(demandConfig && demandConfig.priceElasticity != null ? demandConfig.priceElasticity : 0); + if (!Number.isFinite(elasticity) || elasticity <= 0) return 1; + if (!Number.isFinite(priceNow) || priceNow <= 0) return 1; + if (!Number.isFinite(priceRef) || priceRef <= 0) return 1; + + const direction = + demandConfig && demandConfig.priceElasticityDirection + ? demandConfig.priceElasticityDirection + : "down-only"; + const refMult = Number( + demandConfig && demandConfig.priceElasticityReferenceMultiplier != null + ? demandConfig.priceElasticityReferenceMultiplier + : 1, + ); + const minMult = Number( + demandConfig && demandConfig.priceElasticityMinMultiplier != null + ? demandConfig.priceElasticityMinMultiplier + : 0, + ); + const defaultMax = direction === "down-only" ? 1 : 5; + const maxMult = Number( + demandConfig && demandConfig.priceElasticityMaxMultiplier != null + ? demandConfig.priceElasticityMaxMultiplier + : defaultMax, + ); + + const effectiveRef = priceRef * (Number.isFinite(refMult) && refMult > 0 ? refMult : 1); + const ratio = effectiveRef / priceNow; + let mult = Math.pow(ratio, elasticity); + if (direction === "down-only") mult = Math.min(1, mult); + + const lo = Number.isFinite(minMult) ? minMult : 0; + const hi = Number.isFinite(maxMult) ? maxMult : defaultMax; + return clampNumber(mult, lo, hi); +} + +function getPriceElasticityExecutionRate(demandConfig, priceNow, priceRef) { + // Execution rate is always in [0, 1] and is intended for backlog execution (wait-until-cheaper). + const elasticity = Number( + demandConfig && demandConfig.priceElasticity != null + ? demandConfig.priceElasticity + : 0, + ); + if (!Number.isFinite(elasticity) || elasticity <= 0) return 1; + if (!Number.isFinite(priceNow) || priceNow <= 0) return 1; + if (!Number.isFinite(priceRef) || priceRef <= 0) return 1; + + const refMult = Number( + demandConfig && demandConfig.priceElasticityReferenceMultiplier != null + ? demandConfig.priceElasticityReferenceMultiplier + : 1, + ); + const effectiveRef = + priceRef * (Number.isFinite(refMult) && refMult > 0 ? refMult : 1); + const ratio = effectiveRef / priceNow; + const rate = Math.pow(ratio, elasticity); + return clampNumber(rate, 0, 1); } function getCumulativeBuyPressureCurve(hours, steps, config) { @@ -254,6 +316,24 @@ export function runDeterministicSimulation( sellConfig.loyalConcentrationPct, ); + const initialSpotPrice = calculateSpotPrice( + usdcBalanceIn, + usdcWeightIn, + tknBalanceIn, + tknWeightIn, + ); + + const executionModel = + demandConfig && demandConfig.priceElasticityExecutionModel + ? demandConfig.priceElasticityExecutionModel + : "multiplier"; + const backlogMaxSpendMult = Number( + demandConfig && demandConfig.priceElasticityBacklogMaxSpendMultiplier != null + ? demandConfig.priceElasticityBacklogMaxSpendMultiplier + : 5, + ); + let buyBacklog = 0; + for (let i = 0; i <= safeSteps; i++) { const progress = i / safeSteps; const time = progress * duration; @@ -270,7 +350,36 @@ export function runDeterministicSimulation( let stepSellTKN = 0; // --- BUY PRESSURE (community buys) --- - const flowUSDC = buyFlowCurve[i] || 0; + const priceBeforeBuys = calculateSpotPrice( + usdcBalance, + currentUsdcWeight, + tknBalance, + currentTknWeight, + ); + const flowUSDCBase = buyFlowCurve[i] || 0; + let flowUSDC = 0; + if (executionModel === "backlog") { + buyBacklog += flowUSDCBase; + const execRate = getPriceElasticityExecutionRate( + demandConfig, + priceBeforeBuys, + initialSpotPrice, + ); + const desired = buyBacklog * execRate; + const maxSpend = + flowUSDCBase > 0 && Number.isFinite(backlogMaxSpendMult) && backlogMaxSpendMult > 0 + ? flowUSDCBase * backlogMaxSpendMult + : 0; + flowUSDC = Math.min(desired, maxSpend); + buyBacklog = Math.max(0, buyBacklog - flowUSDC); + } else { + const elasticityMult = getPriceElasticityMultiplier( + demandConfig, + priceBeforeBuys, + initialSpotPrice, + ); + flowUSDC = flowUSDCBase * elasticityMult; + } if (flowUSDC > 0) { const amountOut = calculateOutGivenIn( usdcBalance, @@ -499,6 +608,24 @@ export function calculatePotentialPricePaths( const remainingSteps = Math.max(1, totalSteps - startStep); + const initialSpotPrice = calculateSpotPrice( + config.usdcBalanceIn, + config.usdcWeightIn, + config.tknBalanceIn, + config.tknWeightIn, + ); + + const executionModel = + demandPressureConfig && demandPressureConfig.priceElasticityExecutionModel + ? demandPressureConfig.priceElasticityExecutionModel + : "multiplier"; + const backlogMaxSpendMult = Number( + demandPressureConfig && + demandPressureConfig.priceElasticityBacklogMaxSpendMultiplier != null + ? demandPressureConfig.priceElasticityBacklogMaxSpendMultiplier + : 5, + ); + for (const scenarioFactor of scenarios) { const path = []; @@ -506,6 +633,7 @@ export function calculatePotentialPricePaths( let usdcBalance = startUsdcBalance; let commHeld = communityTokensHeld; let commCost = communityAvgCost; + let buyBacklog = 0; // First point: current step price (no extra flow at junction). const initialProgress = startStep / totalSteps; @@ -538,8 +666,43 @@ export function calculatePotentialPricePaths( 1, Math.max(0, localIdx / remainingSteps), ); - const effectiveFactor = 1 + (scenarioFactor - 1) * transitionProgress; - const flowUSDC = flowUSDCBase * effectiveFactor; + // Scenario semantics: + // - factor=0: "no more buy pressure at all" from the pause point onwards. + // - factor>0: transition smoothly from 1x to factor over the remaining steps. + const effectiveFactor = + scenarioFactor === 0 + ? 0 + : 1 + (scenarioFactor - 1) * transitionProgress; + const priceBeforeBuys = calculateSpotPrice( + usdcBalance, + currentUsdcWeight, + tknBalance, + currentTknWeight, + ); + const elasticityMult = getPriceElasticityMultiplier( + demandPressureConfig, + priceBeforeBuys, + initialSpotPrice, + ); + let flowUSDC = 0; + if (executionModel === "backlog") { + const base = flowUSDCBase * effectiveFactor; + buyBacklog += base; + const execRate = getPriceElasticityExecutionRate( + demandPressureConfig, + priceBeforeBuys, + initialSpotPrice, + ); + const desired = buyBacklog * execRate; + const maxSpend = + base > 0 && Number.isFinite(backlogMaxSpendMult) && backlogMaxSpendMult > 0 + ? base * backlogMaxSpendMult + : 0; + flowUSDC = Math.min(desired, maxSpend); + buyBacklog = Math.max(0, buyBacklog - flowUSDC); + } else { + flowUSDC = flowUSDCBase * effectiveFactor * elasticityMult; + } // --- BUY PRESSURE --- if (flowUSDC > 0) { diff --git a/scripts/run-scenario.mjs b/scripts/run-scenario.mjs new file mode 100644 index 0000000..d59ff39 --- /dev/null +++ b/scripts/run-scenario.mjs @@ -0,0 +1,205 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { runDeterministicSimulation } from "../public/workers/simulation-runner.js"; + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a || !a.startsWith("--")) continue; + const key = a.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + } else { + args[key] = next; + i++; + } + } + return args; +} + +function formatNumber(n, maxFrac = 4) { + if (!Number.isFinite(n)) return String(n); + const abs = Math.abs(n); + if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`; + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(2)}k`; + return n.toFixed(maxFrac); +} + +function normalizeWeights(cfg) { + const next = { ...cfg }; + + if (typeof next.tknWeightIn === "number" && typeof next.usdcWeightIn !== "number") { + next.usdcWeightIn = 100 - next.tknWeightIn; + } + if (typeof next.usdcWeightIn === "number" && typeof next.tknWeightIn !== "number") { + next.tknWeightIn = 100 - next.usdcWeightIn; + } + + if (typeof next.tknWeightOut === "number" && typeof next.usdcWeightOut !== "number") { + next.usdcWeightOut = 100 - next.tknWeightOut; + } + if (typeof next.usdcWeightOut === "number" && typeof next.tknWeightOut !== "number") { + next.tknWeightOut = 100 - next.usdcWeightOut; + } + + return next; +} + +function maybeWarnTknBalanceMismatch(cfg) { + if (!Number.isFinite(cfg.totalSupply) || !Number.isFinite(cfg.percentForSale)) return; + const expected = cfg.totalSupply * (cfg.percentForSale / 100); + if (!Number.isFinite(cfg.tknBalanceIn) || expected === 0) return; + const rel = Math.abs(cfg.tknBalanceIn - expected) / expected; + if (rel > 0.01) { + console.warn( + `[warn] tknBalanceIn (${cfg.tknBalanceIn}) != totalSupply*percentForSale (${expected}). ` + + `UI behavior assumes equality.`, + ); + } +} + +function mergeCase(base, overrides) { + const steps = overrides?.steps ?? base.steps ?? 300; + const lbpConfig = normalizeWeights({ + ...base.lbpConfig, + ...(overrides?.lbpConfig ?? {}), + }); + const demandConfig = { ...base.demandConfig, ...(overrides?.demandConfig ?? {}) }; + const sellConfig = { ...base.sellConfig, ...(overrides?.sellConfig ?? {}) }; + + maybeWarnTknBalanceMismatch(lbpConfig); + + return { steps, lbpConfig, demandConfig, sellConfig }; +} + +function summarizeRun(label, cfg, snapshots) { + const first = snapshots[0]; + const last = snapshots[snapshots.length - 1]; + + let minPrice = Number.POSITIVE_INFINITY; + let maxPrice = 0; + let totalBuys = 0; + let totalSells = 0; + + for (const s of snapshots) { + if (Number.isFinite(s.price)) { + minPrice = Math.min(minPrice, s.price); + maxPrice = Math.max(maxPrice, s.price); + } + totalBuys += s.buyVolumeUSDC ?? 0; + totalSells += s.sellVolumeUSDC ?? 0; + } + + const netRaised = totalBuys - totalSells; + + return { + label, + collateral: cfg.collateralToken, + durationH: cfg.duration, + swapFeePct: cfg.swapFee, + startWeight: `${cfg.tknWeightIn}/${cfg.usdcWeightIn}`, + endWeight: `${cfg.tknWeightOut}/${cfg.usdcWeightOut}`, + initialPrice: first?.price ?? 0, + finalPrice: last?.price ?? 0, + minPrice, + maxPrice, + totalBuys, + totalSells, + netRaised, + finalCollateral: last?.usdcBalance ?? 0, + finalPoolTokens: last?.tknBalance ?? 0, + communityHeld: last?.communityTokensHeld ?? 0, + communityAvgCost: last?.communityAvgCost ?? 0, + finalTVL: last?.tvlUsd ?? 0, + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const fileArg = args.file; + const caseId = typeof args.case === "string" ? args.case : undefined; + const jsonOut = Boolean(args.json); + + if (typeof fileArg !== "string" || fileArg.trim().length === 0) { + const scenariosDir = path.resolve(process.cwd(), "scenarios"); + const files = fs.existsSync(scenariosDir) + ? fs.readdirSync(scenariosDir).filter((f) => f.endsWith(".json")).sort() + : []; + console.error( + `Missing --file.\n\nAvailable scenario files:\n` + + (files.length ? files.map((f) => `- scenarios/${f}`).join("\n") : "(none)"), + ); + process.exitCode = 1; + return; + } + + const filePath = path.resolve(process.cwd(), fileArg); + const raw = fs.readFileSync(filePath, "utf8"); + const scenario = JSON.parse(raw); + + const cases = scenario.cases?.length + ? scenario.cases + : [{ id: "base", label: "Base", overrides: {} }]; + + const selected = caseId ? cases.filter((c) => c.id === caseId) : cases; + if (caseId && selected.length === 0) { + console.error( + `Case not found: ${caseId}\nAvailable cases:\n` + + cases.map((c) => `- ${c.id}${c.label ? ` (${c.label})` : ""}`).join("\n"), + ); + process.exitCode = 1; + return; + } + + const summaries = []; + for (const c of selected) { + const { steps, lbpConfig, demandConfig, sellConfig } = mergeCase( + scenario.base, + c.overrides, + ); + + const snapshots = runDeterministicSimulation( + lbpConfig, + demandConfig, + sellConfig, + steps, + ); + + const label = c.label ? `${c.id}: ${c.label}` : c.id; + summaries.push(summarizeRun(label, lbpConfig, snapshots)); + } + + if (jsonOut) { + console.log(JSON.stringify({ file: fileArg, case: caseId ?? null, summaries }, null, 2)); + return; + } + + console.log(`Scenario: ${path.basename(filePath)}`); + console.table( + summaries.map((s) => ({ + case: s.label, + collateral: s.collateral, + durationH: s.durationH, + feePct: s.swapFeePct, + weights: `${s.startWeight}→${s.endWeight}`, + raisedNet: formatNumber(s.netRaised, 2), + buys: formatNumber(s.totalBuys, 2), + sells: formatNumber(s.totalSells, 2), + price0: formatNumber(s.initialPrice, 8), + priceMin: formatNumber(s.minPrice, 8), + priceEnd: formatNumber(s.finalPrice, 8), + commHeld: formatNumber(s.communityHeld, 2), + commAvgCost: formatNumber(s.communityAvgCost, 8) + })), + ); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); + diff --git a/store/useSimulatorStore.ts b/store/useSimulatorStore.ts index 1b9d908..ba559bd 100644 --- a/store/useSimulatorStore.ts +++ b/store/useSimulatorStore.ts @@ -148,7 +148,7 @@ const DEFAULT_CONFIG: LBPConfig = { percentForSale: 10, // 10% of total supply collateralToken: "USDC", - tknBalanceIn: 50_000_000, // 50% of 100M + tknBalanceIn: 10_000_000, // auto-derived: totalSupply * percentForSale tknWeightIn: 90, usdcBalanceIn: 1_000_000, // 1M start usdcWeightIn: 10, @@ -436,6 +436,10 @@ export const useSimulatorStore = create((set, get) => ({ communityAvgCost: 0, priceHistory: new Float64Array(simulationData.map((d) => d.price)), priceHistoryVersion: 0, + // Invalidate worker path so we don't keep ticking against stale snapshots + // while a new worker run is computing (e.g. after changing pressure configs). + baseSnapshots: [], + baseSnapshotsVersion: 0, }); }, diff --git a/test/project-scenarios.test.ts b/test/project-scenarios.test.ts new file mode 100644 index 0000000..9aa4b9e --- /dev/null +++ b/test/project-scenarios.test.ts @@ -0,0 +1,1324 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, it, expect } from "vitest"; +import { zipSync, strToU8 } from "fflate"; + +import type { + DemandPressureConfig, + LBPConfig, + SellPressureConfig, +} from "../lib/lbp-math"; +import { runDeterministicSimulation } from "../public/workers/simulation-runner.js"; + +type ScenarioFile = { + project?: { + id?: string; + name?: string; + chain?: string; + registeredWallets?: number; + notes?: string[]; + }; + base: { + steps?: number; + lbpConfig: LBPConfig; + demandConfig: DemandPressureConfig; + sellConfig: SellPressureConfig; + }; + cases?: Array<{ + id: string; + label?: string; + overrides?: Partial<{ + steps: number; + lbpConfig: Partial; + demandConfig: Partial; + sellConfig: Partial; + }>; + }>; +}; + +function normalizeWeights(cfg: LBPConfig): LBPConfig { + const next = { ...cfg }; + if (typeof next.tknWeightIn === "number" && typeof next.usdcWeightIn !== "number") { + next.usdcWeightIn = 100 - next.tknWeightIn; + } + if (typeof next.tknWeightOut === "number" && typeof next.usdcWeightOut !== "number") { + next.usdcWeightOut = 100 - next.tknWeightOut; + } + return next; +} + +function mergeCase( + base: ScenarioFile["base"], + overrides?: ScenarioFile["cases"][number]["overrides"], +) { + const steps = overrides?.steps ?? base.steps ?? 300; + const lbpConfig = normalizeWeights({ + ...base.lbpConfig, + ...(overrides?.lbpConfig ?? {}), + }); + const demandConfig = { ...base.demandConfig, ...(overrides?.demandConfig ?? {}) }; + const sellConfig = { ...base.sellConfig, ...(overrides?.sellConfig ?? {}) }; + return { steps, lbpConfig, demandConfig, sellConfig }; +} + +function listScenarioFiles() { + const dir = path.resolve(process.cwd(), "scenarios"); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .sort() + .map((f) => path.join(dir, f)); +} + +function summarizeSnapshots(snapshots: any[]) { + const first = snapshots[0]; + const last = snapshots[snapshots.length - 1]; + + let minPrice = Number.POSITIVE_INFINITY; + let maxPrice = 0; + let totalBuys = 0; + let totalSells = 0; + + for (const s of snapshots) { + if (Number.isFinite(s.price)) { + minPrice = Math.min(minPrice, s.price); + maxPrice = Math.max(maxPrice, s.price); + } + totalBuys += s.buyVolumeUSDC ?? 0; + totalSells += s.sellVolumeUSDC ?? 0; + } + + return { + initialPrice: first?.price ?? 0, + finalPrice: last?.price ?? 0, + minPrice, + maxPrice, + totalBuys, + totalSells, + netRaised: totalBuys - totalSells, + finalCollateral: last?.usdcBalance ?? 0, + finalPoolTokens: last?.tknBalance ?? 0, + communityHeld: last?.communityTokensHeld ?? 0, + communityAvgCost: last?.communityAvgCost ?? 0, + finalTVL: last?.tvlUsd ?? 0, + }; +} + +// Estimates "number of swaps" from executed volume by assuming an average trade size in USD. +// NOTE: This is a *derived metric* for reporting/gas intuition. The deterministic price path +// is driven by total net flow per step, not by how that flow is split into individual trades. +// +// Backward-compat: SWAP_EVENT_THRESHOLD_USD applies to both buys and sells. +const SWAP_EVENT_THRESHOLD_USD = Number(process.env.SWAP_EVENT_THRESHOLD_USD ?? NaN); +const AVG_BUY_SWAP_USD = Number(process.env.AVG_BUY_SWAP_USD ?? 250); +const AVG_SELL_SWAP_USD = Number(process.env.AVG_SELL_SWAP_USD ?? 500); + +function estimateSwapEvents( + snapshots: any[], + collateralToken: LBPConfig["collateralToken"], + assumedEthUsd: number, +) { + const collateralUsd = + collateralToken === "ETH" || collateralToken === "wETH" ? assumedEthUsd : 1; + const buyDenom = + Number.isFinite(SWAP_EVENT_THRESHOLD_USD) && SWAP_EVENT_THRESHOLD_USD > 0 + ? SWAP_EVENT_THRESHOLD_USD + : Number.isFinite(AVG_BUY_SWAP_USD) && AVG_BUY_SWAP_USD > 0 + ? AVG_BUY_SWAP_USD + : 250; + const sellDenom = + Number.isFinite(SWAP_EVENT_THRESHOLD_USD) && SWAP_EVENT_THRESHOLD_USD > 0 + ? SWAP_EVENT_THRESHOLD_USD + : Number.isFinite(AVG_SELL_SWAP_USD) && AVG_SELL_SWAP_USD > 0 + ? AVG_SELL_SWAP_USD + : 500; + + let estBuys = 0; + let estSells = 0; + let buyCarryUsd = 0; + let sellCarryUsd = 0; + + for (const s of snapshots) { + const buyUsd = (s.buyVolumeUSDC ?? 0) * collateralUsd; + const sellUsd = (s.sellVolumeUSDC ?? 0) * collateralUsd; + + buyCarryUsd += buyUsd; + const buySwaps = Math.floor(buyCarryUsd / buyDenom); + if (buySwaps > 0) { + estBuys += buySwaps; + buyCarryUsd -= buySwaps * buyDenom; + } + + sellCarryUsd += sellUsd; + const sellSwaps = Math.floor(sellCarryUsd / sellDenom); + if (sellSwaps > 0) { + estSells += sellSwaps; + sellCarryUsd -= sellSwaps * sellDenom; + } + } + + return { estBuys, estSells, estTotal: estBuys + estSells }; +} + +function estimateEndCumulativeBuys(config: DemandPressureConfig) { + const endScale = config.preset === "bearish" ? 0.35 : 1.0; + return config.magnitudeBase * config.multiplier * endScale; +} + +function formatNumber(n: number, maxFrac = 4) { + if (!Number.isFinite(n)) return String(n); + const abs = Math.abs(n); + if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`; + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(2)}k`; + return n.toFixed(maxFrac); +} + +function pickMagnitudeBaseForTarget(targetUnits: number) { + const bases = [10_000, 100_000, 1_000_000] as const; + let bestBase: (typeof bases)[number] = 100_000; + let bestScore = Number.POSITIVE_INFINITY; + + for (const base of bases) { + const mult = targetUnits / base; + // Prefer multipliers in a "nice" range. + const penaltyLow = mult < 0.1 ? 10 + (0.1 - mult) * 50 : 0; + const penaltyHigh = mult > 20 ? 10 + (mult - 20) : 0; + const score = penaltyLow + penaltyHigh + Math.abs(Math.log10(Math.max(1e-9, mult))); + if (score < bestScore) { + bestScore = score; + bestBase = base; + } + } + + return bestBase; +} + +function csvEscape(v: unknown) { + if (v == null) return ""; + const s = String(v); + if (s.includes('"') || s.includes(",") || s.includes("\n") || s.includes("\r")) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +function writeCsvFile(filePath: string, rows: Array>) { + if (rows.length === 0) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "", "utf8"); + return; + } + + const headers = Object.keys(rows[0] ?? {}); + const lines = [headers.map(csvEscape).join(",")]; + for (const r of rows) { + lines.push(headers.map((h) => csvEscape((r as any)[h])).join(",")); + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, lines.join("\n") + "\n", "utf8"); +} + +function xmlEscape(s: string) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toExcelColumnName(colIndex0: number) { + let n = colIndex0 + 1; + let name = ""; + while (n > 0) { + const rem = (n - 1) % 26; + name = String.fromCharCode(65 + rem) + name; + n = Math.floor((n - 1) / 26); + } + return name; +} + +function isFiniteNumber(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +function buildSheetXml(sheetName: string, headers: string[], rows: Array>) { + const ns = + 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' + + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'; + + const emitCell = (col0: number, row1: number, value: unknown) => { + const ref = `${toExcelColumnName(col0)}${row1}`; + if (isFiniteNumber(value)) { + return `${value}`; + } + if (typeof value === "boolean") { + return `${value ? 1 : 0}`; + } + const s = value == null ? "" : String(value); + return `${xmlEscape(s)}`; + }; + + let sheetData = ""; + + // Header row + sheetData += ``; + for (let c = 0; c < headers.length; c++) { + sheetData += emitCell(c, 1, headers[c]); + } + sheetData += ``; + + // Data rows + for (let r = 0; r < rows.length; r++) { + const rowNum = r + 2; + sheetData += ``; + const row = rows[r] ?? {}; + for (let c = 0; c < headers.length; c++) { + const key = headers[c]!; + sheetData += emitCell(c, rowNum, (row as any)[key]); + } + sheetData += ``; + } + + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `${sheetData}` + + `` + + `` + ); +} + +type WorkbookSheet = { + name: string; + rows: Array>; +}; + +function writeXlsxWorkbook(filePath: string, sheets: WorkbookSheet[]) { + const safeSheets = sheets.length ? sheets : [{ name: "Results", rows: [] }]; + + const now = new Date(); + const iso = now.toISOString(); + + const workbookXml = + `` + + `` + + `` + + `` + + safeSheets + .map( + (s, i) => + ``, + ) + .join("") + + `` + + ``; + + const workbookRelsXml = + `` + + `` + + safeSheets + .map( + (_s, i) => + ``, + ) + .join("") + + `` + + ``; + + const rootRelsXml = + `` + + `` + + `` + + `` + + `` + + ``; + + const contentTypesXml = + `` + + `` + + `` + + `` + + `` + + safeSheets + .map( + (_s, i) => + ``, + ) + .join("") + + `` + + `` + + `` + + ``; + + // Minimal styles (required by some Excel versions) + const stylesXml = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + + const coreXml = + `` + + `` + + `LBP Simulator Sweep` + + `lbp-simulator` + + `lbp-simulator` + + `${iso}` + + `${iso}` + + ``; + + const appXml = + `` + + `` + + `lbp-simulator` + + `0` + + `false` + + `` + + `Worksheets` + + `${safeSheets.length}` + + `` + + `` + + safeSheets + .map((s) => `${xmlEscape(s.name)}`) + .join("") + + `` + + `` + + `false` + + `false` + + `false` + + `16.0000` + + ``; + + const zippedEntries: Record = { + "[Content_Types].xml": strToU8(contentTypesXml), + "_rels/.rels": strToU8(rootRelsXml), + "docProps/core.xml": strToU8(coreXml), + "docProps/app.xml": strToU8(appXml), + "xl/workbook.xml": strToU8(workbookXml), + "xl/_rels/workbook.xml.rels": strToU8(workbookRelsXml), + "xl/styles.xml": strToU8(stylesXml), + }; + + for (let i = 0; i < safeSheets.length; i++) { + const s = safeSheets[i]!; + const headers = s.rows.length ? Object.keys(s.rows[0] ?? {}) : []; + const sheetXml = buildSheetXml(s.name, headers, s.rows); + zippedEntries[`xl/worksheets/sheet${i + 1}.xml`] = strToU8(sheetXml); + } + + const zipped = zipSync(zippedEntries, { level: 6 }); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, Buffer.from(zipped)); +} + +function writeXlsxFile(filePath: string, rows: Array>) { + writeXlsxWorkbook(filePath, [{ name: "Results", rows }]); +} + +describe("Project scenario files", () => { + const files = listScenarioFiles(); + + it("should have at least one scenario file", () => { + expect(files.length).toBeGreaterThan(0); + }); + + for (const filePath of files) { + it(`runs without NaNs: ${path.basename(filePath)}`, () => { + const raw = fs.readFileSync(filePath, "utf8"); + const scenario = JSON.parse(raw) as ScenarioFile; + + const cases = scenario.cases?.length + ? scenario.cases + : [{ id: "base", label: "Base", overrides: {} }]; + + for (const c of cases) { + const { steps, lbpConfig, demandConfig, sellConfig } = mergeCase( + scenario.base, + c.overrides, + ); + + const snapshots = runDeterministicSimulation( + lbpConfig, + demandConfig, + sellConfig, + steps, + ); + + expect(snapshots.length).toBe(steps + 1); + expect(snapshots[0].time).toBe(0); + expect(snapshots[snapshots.length - 1].time).toBe(lbpConfig.duration); + + for (const s of snapshots) { + expect(Number.isFinite(s.price)).toBe(true); + expect(s.price).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(s.tknBalance)).toBe(true); + expect(Number.isFinite(s.usdcBalance)).toBe(true); + expect(s.tknBalance).toBeGreaterThanOrEqual(-1e-9); + expect(s.usdcBalance).toBeGreaterThanOrEqual(-1e-9); + expect(s.tknWeight).toBeGreaterThanOrEqual(0); + expect(s.tknWeight).toBeLessThanOrEqual(100); + expect(s.usdcWeight).toBeGreaterThanOrEqual(0); + expect(s.usdcWeight).toBeLessThanOrEqual(100); + } + } + }); + } +}); + +describe("AmplifyWorld parameter sweep summary", () => { + it( + "runs multiple parameter variations and prints a compact summary", + { timeout: 20_000 }, + () => { + const filePath = path.resolve(process.cwd(), "scenarios/amplifyworld.json"); + if (!fs.existsSync(filePath)) { + // No scenario file in this repo checkout — don't fail CI for that. + return; + } + + const raw = fs.readFileSync(filePath, "utf8"); + const scenario = JSON.parse(raw) as ScenarioFile; + + const base = scenario.base; + const wallets = scenario.project?.registeredWallets ?? 12_000; + const steps = base.steps ?? 300; + + const baseCfg = normalizeWeights(base.lbpConfig); + const baseDemand = base.demandConfig; + const baseSell = base.sellConfig; + + const BUY_ONLY_SELL_CONFIG: SellPressureConfig = { + preset: "loyal", + loyalSoldPct: 0, + loyalConcentrationPct: 60, + greedySpreadPct: 2, + greedySellPct: 0, + }; + + const ASSUMED_ETH_USD = 3000; + const toCollateralUnits = (collateralToken: LBPConfig["collateralToken"], amountUsd: number) => { + if (collateralToken === "ETH" || collateralToken === "wETH") { + return amountUsd / ASSUMED_ETH_USD; + } + return amountUsd; + }; + + const collateralTokens: Array = ["USDC", "ETH"]; + + const startWeights = [98, 95, 90]; + const endWeights = [50, 30, 10]; // includes classic 90/10 -> 10/90 style + const swapFeesPct = [1, 2, 3]; + const initialCollateralUsdByToken = (collateralToken: LBPConfig["collateralToken"]) => { + if (collateralToken === "ETH" || collateralToken === "wETH") { + // Larger ranges are useful with volatile collateral (ETH). + return [1_000_000, 3_000_000, 10_000_000, 30_000_000, 100_000_000]; + } + // Seedless-compatible: include larger starting collateral so initial price isn't tiny. + return [300_000, 1_000_000, 3_000_000, 10_000_000, 30_000_000, 100_000_000]; + }; + + const demandLevelsUsd: Array<{ id: string; preset: DemandPressureConfig["preset"]; endUsd: number }> = [ + { id: "d50k", preset: "bullish", endUsd: 50_000 }, + { id: "d100k", preset: "bullish", endUsd: 100_000 }, + { id: "d250k", preset: "bullish", endUsd: 250_000 }, + { id: "d500k", preset: "bullish", endUsd: 500_000 }, + { id: "d1m", preset: "bullish", endUsd: 1_000_000 }, + { id: "d2m", preset: "bullish", endUsd: 2_000_000 }, + { id: "d5m", preset: "bullish", endUsd: 5_000_000 }, + { id: "d10m", preset: "bullish", endUsd: 10_000_000 }, + { id: "bear1m", preset: "bearish", endUsd: 1_000_000 }, + { id: "bear5m", preset: "bearish", endUsd: 5_000_000 }, + ]; + + const sellModes: Array<{ id: string; sell: SellPressureConfig }> = [ + // Loyal: vary total sold and concentration. + { id: "loyal2c40", sell: { ...baseSell, preset: "loyal", loyalSoldPct: 2, loyalConcentrationPct: 40 } }, + { id: "loyal5c60", sell: { ...baseSell, preset: "loyal", loyalSoldPct: 5, loyalConcentrationPct: 60 } }, + { id: "loyal10c80", sell: { ...baseSell, preset: "loyal", loyalSoldPct: 10, loyalConcentrationPct: 80 } }, + { id: "loyal20c80", sell: { ...baseSell, preset: "loyal", loyalSoldPct: 20, loyalConcentrationPct: 80 } }, + // Greedy: vary trigger spread and sell fraction. + { id: "greedy2_25", sell: { ...baseSell, preset: "greedy", greedySpreadPct: 2, greedySellPct: 25 } }, + { id: "greedy5_50", sell: { ...baseSell, preset: "greedy", greedySpreadPct: 5, greedySellPct: 50 } }, + { id: "greedy10_100", sell: { ...baseSell, preset: "greedy", greedySpreadPct: 10, greedySellPct: 100 } }, + ]; + + const summaries: Array = []; + + for (const collateralToken of collateralTokens) { + for (const w of startWeights) { + for (const wOut of endWeights) { + for (const fee of swapFeesPct) { + for (const collUsd of initialCollateralUsdByToken(collateralToken)) { + const collIn = toCollateralUnits(collateralToken, collUsd); + for (const d of demandLevelsUsd) { + const endUnits = toCollateralUnits(collateralToken, d.endUsd); + const magBase = pickMagnitudeBaseForTarget(endUnits); + const demand: DemandPressureConfig = { + ...baseDemand, + preset: d.preset, + magnitudeBase: magBase, + multiplier: endUnits / magBase, + }; + + for (const s of sellModes) { + const cfg: LBPConfig = normalizeWeights({ + ...baseCfg, + collateralToken, + tknWeightIn: w, + usdcWeightIn: 100 - w, + tknWeightOut: wOut, + usdcWeightOut: 100 - wOut, + usdcBalanceIn: collIn, + swapFee: fee, + }); + + const snapshots = runDeterministicSimulation( + cfg, + demand, + s.sell, + steps, + ); + const snapshotsBuyOnly = runDeterministicSimulation( + cfg, + demand, + BUY_ONLY_SELL_CONFIG, + steps, + ); + + expect(snapshots.length).toBe(steps + 1); + expect(snapshots[0].time).toBe(0); + expect(snapshots[snapshots.length - 1].time).toBe(cfg.duration); + + const summary = summarizeSnapshots(snapshots); + const summaryBuyOnly = summarizeSnapshots(snapshotsBuyOnly); + const swapsEst = estimateSwapEvents( + snapshots, + collateralToken, + ASSUMED_ETH_USD, + ); + const swapsEstBuyOnly = estimateSwapEvents( + snapshotsBuyOnly, + collateralToken, + ASSUMED_ETH_USD, + ); + expect(Number.isFinite(summary.finalPrice)).toBe(true); + expect(Number.isFinite(summary.netRaised)).toBe(true); + + const demandEndUnits = estimateEndCumulativeBuys(demand); + const demandEndUsd = + collateralToken === "ETH" || collateralToken === "wETH" + ? demandEndUnits * ASSUMED_ETH_USD + : demandEndUnits; + + summaries.push({ + scenarioId: `coll${collateralToken}_w${w}_e${wOut}_fee${fee}_c${Math.round(collUsd / 1000)}k_${d.id}_${s.id}`, + collateralToken, + startWeightTknPct: w, + endWeightTknPct: wOut, + swapFeePct: fee, + initialCollateralUsd: collUsd, + initialCollateral: collIn, + demandInputUsd: d.endUsd, + demandInputUnits: endUnits, + demandEndCumulativeUsd: demandEndUsd, + demandEndCumulative: demandEndUnits, + sellBehavior: s.id, + netRaised: summary.netRaised, + netRaisedBuyOnly: summaryBuyOnly.netRaised, + initialPrice: summary.initialPrice, + minPrice: summary.minPrice, + finalPrice: summary.finalPrice, + minPriceBuyOnly: summaryBuyOnly.minPrice, + finalPriceBuyOnly: summaryBuyOnly.finalPrice, + communityHeld: summary.communityHeld, + communityAvgCost: summary.communityAvgCost, + communityHeldBuyOnly: summaryBuyOnly.communityHeld, + communityAvgCostBuyOnly: summaryBuyOnly.communityAvgCost, + estBuySwaps: swapsEst.estBuys, + estSellSwaps: swapsEst.estSells, + estSwaps: swapsEst.estTotal, + estBuySwapsBuyOnly: swapsEstBuyOnly.estBuys, + estSellSwapsBuyOnly: swapsEstBuyOnly.estSells, + estSwapsBuyOnly: swapsEstBuyOnly.estTotal, + }); + } + } + } + } + } + } + } + + // Wallet-based demand examples (maps "12k wallets" into a few buy-volume targets). + const participationRates = [0.01, 0.05, 0.1]; + const ticketsUsd = [100, 250, 500]; + + for (const collateralToken of collateralTokens) { + for (const p of participationRates) { + for (const t of ticketsUsd) { + const targetUsd = wallets * p * t; + const targetUnits = toCollateralUnits(collateralToken, targetUsd); + const magBase = pickMagnitudeBaseForTarget(targetUnits); + const demand: DemandPressureConfig = { + ...baseDemand, + preset: "bullish", + magnitudeBase: magBase, + multiplier: targetUnits / magBase, + }; + + const cfg: LBPConfig = normalizeWeights({ + ...baseCfg, + collateralToken, + tknWeightIn: 95, + usdcWeightIn: 5, + tknWeightOut: 50, + usdcWeightOut: 50, + usdcBalanceIn: toCollateralUnits(collateralToken, 300_000), + swapFee: 2, + }); + + const snapshots = runDeterministicSimulation( + cfg, + demand, + { ...baseSell, preset: "loyal", loyalSoldPct: 5, loyalConcentrationPct: 60 }, + steps, + ); + const snapshotsBuyOnly = runDeterministicSimulation( + cfg, + demand, + BUY_ONLY_SELL_CONFIG, + steps, + ); + const summary = summarizeSnapshots(snapshots); + const summaryBuyOnly = summarizeSnapshots(snapshotsBuyOnly); + const swapsEst = estimateSwapEvents( + snapshots, + collateralToken, + ASSUMED_ETH_USD, + ); + const swapsEstBuyOnly = estimateSwapEvents( + snapshotsBuyOnly, + collateralToken, + ASSUMED_ETH_USD, + ); + + const demandEndUnits = estimateEndCumulativeBuys(demand); + const demandEndUsd = + collateralToken === "ETH" || collateralToken === "wETH" + ? demandEndUnits * ASSUMED_ETH_USD + : demandEndUnits; + + summaries.push({ + scenarioId: `coll${collateralToken}_wallets_${Math.round(p * 100)}pct_ticket${t}`, + collateralToken, + startWeightTknPct: 95, + endWeightTknPct: 50, + swapFeePct: 2, + initialCollateralUsd: 300_000, + initialCollateral: cfg.usdcBalanceIn, + demandInputUsd: targetUsd, + demandInputUnits: targetUnits, + demandEndCumulativeUsd: demandEndUsd, + demandEndCumulative: demandEndUnits, + sellBehavior: "loyal5", + netRaised: summary.netRaised, + netRaisedBuyOnly: summaryBuyOnly.netRaised, + initialPrice: summary.initialPrice, + minPrice: summary.minPrice, + finalPrice: summary.finalPrice, + minPriceBuyOnly: summaryBuyOnly.minPrice, + finalPriceBuyOnly: summaryBuyOnly.finalPrice, + communityHeld: summary.communityHeld, + communityAvgCost: summary.communityAvgCost, + communityHeldBuyOnly: summaryBuyOnly.communityHeld, + communityAvgCostBuyOnly: summaryBuyOnly.communityAvgCost, + estBuySwaps: swapsEst.estBuys, + estSellSwaps: swapsEst.estSells, + estSwaps: swapsEst.estTotal, + estBuySwapsBuyOnly: swapsEstBuyOnly.estBuys, + estSellSwapsBuyOnly: swapsEstBuyOnly.estSells, + estSwapsBuyOnly: swapsEstBuyOnly.estTotal, + }); + } + } + } + + // NOTE: Initial price depends only on initial balances + weights (not on demand curve). + const MIN_INITIAL_PRICE_USD = Number(process.env.MIN_INITIAL_PRICE_USD ?? 0.02); + const MAX_INITIAL_PRICE_USD = + process.env.MAX_INITIAL_PRICE_USD != null + ? Number(process.env.MAX_INITIAL_PRICE_USD) + : Number.POSITIVE_INFINITY; + const MIN_INITIAL_COLLATERAL_USD = Number( + process.env.MIN_INITIAL_COLLATERAL_USD ?? 1_000_000, + ); + const MAX_INITIAL_COLLATERAL_USD = Number( + process.env.MAX_INITIAL_COLLATERAL_USD ?? 100_000_000, + ); + const TARGET_DEMAND_INPUT_USD = Number( + process.env.TARGET_DEMAND_INPUT_USD ?? 100_000, + ); + const DEMAND_INPUT_TOLERANCE_PCT = Number( + process.env.DEMAND_INPUT_TOLERANCE_PCT ?? 0, + ); + const MIN_DEMAND_INPUT_USD = Number(process.env.MIN_DEMAND_INPUT_USD ?? 50_000); + const MAX_DEMAND_INPUT_USD = Number(process.env.MAX_DEMAND_INPUT_USD ?? 500_000); + const RECOMMENDATION_MODE = (process.env.RECOMMENDATION_MODE ?? + "buy-and-sell") as "buy-and-sell" | "buy-only"; + const RECOMMENDATION_PICK = (process.env.RECOMMENDATION_PICK ?? + "diverse") as "diverse" | "strict"; + const FIX_START_WEIGHT_TKN_PCT = + process.env.FIX_START_WEIGHT_TKN_PCT != null + ? Number(process.env.FIX_START_WEIGHT_TKN_PCT) + : undefined; + const FIX_END_WEIGHT_TKN_PCT = + process.env.FIX_END_WEIGHT_TKN_PCT != null + ? Number(process.env.FIX_END_WEIGHT_TKN_PCT) + : undefined; + + const sellBehaviorMeaning = (sellBehavior: string) => { + if (sellBehavior.startsWith("loyal")) { + // loyal{soldPct}c{concentration} + const m = sellBehavior.match(/^loyal(\d+(?:\.\d+)?)c(\d+(?:\.\d+)?)$/); + if (m) { + const soldPct = Number(m[1]); + const conc = Number(m[2]); + return `Loyal: sells ${soldPct}% total; ${conc}% weight at start+end`; + } + return "Loyal community (see loyal params)"; + } + if (sellBehavior.startsWith("greedy")) { + // greedy{spread}_{sellPct} + const m = sellBehavior.match(/^greedy(\d+(?:\.\d+)?)_(\d+(?:\.\d+)?)$/); + if (m) { + const spread = Number(m[1]); + const sellPct = Number(m[2]); + return `Greedy: triggers at +${spread}% over avg cost; sells ${sellPct}% per trigger`; + } + return "Greedy community (see greedy params)"; + } + return sellBehavior; + }; + + const toUsd = (collateralToken: LBPConfig["collateralToken"], amountInCollateral: number) => { + if (collateralToken === "ETH" || collateralToken === "wETH") return amountInCollateral * ASSUMED_ETH_USD; + return amountInCollateral; + }; + + const initialPriceUsd = (r: any) => toUsd(r.collateralToken, Number(r.initialPrice ?? 0)); + const finalPriceUsd = (r: any) => toUsd(r.collateralToken, Number(r.finalPrice ?? 0)); + const netRaisedUsd = (r: any) => toUsd(r.collateralToken, Number(r.netRaised ?? 0)); + const minPriceUsdBuyOnly = (r: any) => + toUsd(r.collateralToken, Number(r.minPriceBuyOnly ?? 0)); + const finalPriceUsdBuyOnly = (r: any) => + toUsd(r.collateralToken, Number(r.finalPriceBuyOnly ?? 0)); + const netRaisedUsdBuyOnly = (r: any) => + toUsd(r.collateralToken, Number(r.netRaisedBuyOnly ?? 0)); + + const pickRecommended = (collateralToken: LBPConfig["collateralToken"]) => { + const isBuyOnly = RECOMMENDATION_MODE === "buy-only"; + const allowedSell = new Set([ + "loyal5c60", + "loyal10c80", + "greedy2_25", + "greedy5_50", + ]); + const sellPreference = new Map([ + ["loyal5c60", 0], + ["loyal10c80", 1], + ["greedy2_25", 2], + ["greedy5_50", 3], + ]); + + const candidates = summaries + .filter((r) => r.collateralToken === collateralToken) + .filter((r) => (isBuyOnly ? true : allowedSell.has(String(r.sellBehavior)))) + .filter((r) => Number(r.initialCollateralUsd) >= MIN_INITIAL_COLLATERAL_USD) + .filter((r) => Number(r.initialCollateralUsd) <= MAX_INITIAL_COLLATERAL_USD) + .filter((r) => Number(r.demandInputUsd) >= MIN_DEMAND_INPUT_USD) + .filter((r) => Number(r.demandInputUsd) <= MAX_DEMAND_INPUT_USD) + .filter((r) => initialPriceUsd(r) >= MIN_INITIAL_PRICE_USD) + .filter((r) => + FIX_START_WEIGHT_TKN_PCT == null + ? true + : Number(r.startWeightTknPct) === FIX_START_WEIGHT_TKN_PCT, + ) + .filter((r) => + FIX_END_WEIGHT_TKN_PCT == null + ? true + : Number(r.endWeightTknPct) === FIX_END_WEIGHT_TKN_PCT, + ); + const candidatesWithMax = candidates.filter( + (r) => initialPriceUsd(r) <= MAX_INITIAL_PRICE_USD, + ); + + const targetLo = + TARGET_DEMAND_INPUT_USD * (1 - DEMAND_INPUT_TOLERANCE_PCT / 100); + const targetHi = + TARGET_DEMAND_INPUT_USD * (1 + DEMAND_INPUT_TOLERANCE_PCT / 100); + + const targetDemandRaw = candidatesWithMax.filter((r) => { + const v = Number(r.demandInputUsd); + if (!Number.isFinite(v)) return false; + return v >= targetLo && v <= targetHi; + }); + + // In buy-only mode, sell behavior has no effect on the outcome, so dedupe it out + // to avoid returning multiple identical parameter sets. + const targetDemand = (() => { + if (!isBuyOnly) return targetDemandRaw; + const byKey = new Map(); + for (const r of targetDemandRaw) { + const key = [ + r.collateralToken, + r.startWeightTknPct, + r.endWeightTknPct, + r.swapFeePct, + r.initialCollateralUsd, + r.demandInputUsd, + ].join("|"); + if (!byKey.has(key)) byKey.set(key, r); + } + return Array.from(byKey.values()); + })(); + + const sorted = [...targetDemand].sort((a, b) => { + // Objective 1: higher initial price (anti "cheap token" sniping). + const p0 = initialPriceUsd(b) - initialPriceUsd(a); + if (p0 !== 0) return p0; + + if (isBuyOnly) { + // Objective 2 (buy-only): higher final price with sells disabled. + const pf = finalPriceUsdBuyOnly(b) - finalPriceUsdBuyOnly(a); + if (pf !== 0) return pf; + // Objective 3: higher minimum price (avoid big dips during weight shift). + const pm = minPriceUsdBuyOnly(b) - minPriceUsdBuyOnly(a); + if (pm !== 0) return pm; + // Objective 4: higher net raised (should be ~demand in buy-only). + return netRaisedUsdBuyOnly(b) - netRaisedUsdBuyOnly(a); + } + + // Secondary (buy+sell): prefer more realistic sell behaviors (loyal first), then net raised. + const pa = sellPreference.get(String(a.sellBehavior)) ?? 999; + const pb = sellPreference.get(String(b.sellBehavior)) ?? 999; + if (pa !== pb) return pa - pb; + return netRaisedUsd(b) - netRaisedUsd(a); + }); + + if (RECOMMENDATION_PICK === "strict") { + return sorted.slice(0, 5); + } + + const picked: any[] = []; + const seen = new Set(); + const push = (r: any) => { + if (!r) return; + const id = isBuyOnly + ? [ + r.collateralToken, + r.startWeightTknPct, + r.endWeightTknPct, + r.swapFeePct, + r.initialCollateralUsd, + r.demandInputUsd, + ].join("|") + : String(r.scenarioId); + if (seen.has(id)) return; + seen.add(id); + picked.push(r); + }; + + // Must-include: at least one 90/10 -> 10/90 case if available. + push( + sorted.find( + (r) => + Number(r.startWeightTknPct) === 90 && Number(r.endWeightTknPct) === 10, + ), + ); + + // Prefer diversity across initial collateral levels (seedless setups). + const preferredCollateralUsd = [1_000_000, 3_000_000, 10_000_000, 30_000_000, 100_000_000]; + for (const collUsd of preferredCollateralUsd) { + for (const r of sorted) { + if (picked.length >= 5) break; + if (Number(r.initialCollateralUsd) !== collUsd) continue; + push(r); + break; // only one per collateral bucket + } + if (picked.length >= 5) break; + } + + // Then prefer diversity across end weights (50/30/10). + for (const ew of [50, 30, 10]) { + for (const r of sorted) { + if (picked.length >= 5) break; + if (Number(r.endWeightTknPct) !== ew) continue; + push(r); + } + if (picked.length >= 5) break; + } + + // Fill remaining slots with best remaining. + for (const r of sorted) { + if (picked.length >= 5) break; + push(r); + } + + return picked.slice(0, 5); + }; + + const recommendedUSDC = pickRecommended("USDC"); + const recommendedETH = pickRecommended("ETH"); + + // eslint-disable-next-line no-console + console.log( + `[AmplifyWorld recommended] Constraints: initialPriceUsd>=${MIN_INITIAL_PRICE_USD}, ` + + (Number.isFinite(MAX_INITIAL_PRICE_USD) + ? `initialPriceUsd<=${MAX_INITIAL_PRICE_USD}, ` + : "") + + `initialCollateralUsd=${MIN_INITIAL_COLLATERAL_USD}..${MAX_INITIAL_COLLATERAL_USD}, demandInputUsd=${MIN_DEMAND_INPUT_USD}..${MAX_DEMAND_INPUT_USD}, ` + + `targetDemandInputUsd=${TARGET_DEMAND_INPUT_USD} (+/-${DEMAND_INPUT_TOLERANCE_PCT}%), ` + + (FIX_START_WEIGHT_TKN_PCT != null + ? `startWeightTknPct=${FIX_START_WEIGHT_TKN_PCT}, ` + : "") + + (FIX_END_WEIGHT_TKN_PCT != null ? `endWeightTknPct=${FIX_END_WEIGHT_TKN_PCT}, ` : "") + + `mode=${RECOMMENDATION_MODE}, pick=${RECOMMENDATION_PICK}, ETH=${ASSUMED_ETH_USD} USD.`, + ); + // eslint-disable-next-line no-console + console.log( + `[AmplifyWorld recommended] mode=${RECOMMENDATION_MODE} (Top 5 USDC):`, + ); + // eslint-disable-next-line no-console + console.table( + recommendedUSDC.map((r) => ({ + ScenarioId: r.scenarioId, + Weights: `${r.startWeightTknPct}/${100 - r.startWeightTknPct} -> ${r.endWeightTknPct}/${100 - r.endWeightTknPct}`, + SwapFeePct: r.swapFeePct, + InitialCollateralUsd: formatNumber(r.initialCollateralUsd, 0), + DemandInputUsd: formatNumber(r.demandInputUsd, 0), + SellBehavior: sellBehaviorMeaning(String(r.sellBehavior)), + InitialPriceUsd: initialPriceUsd(r), + NetRaisedUsd: netRaisedUsd(r), + NetRaisedUsdBuyOnly: netRaisedUsdBuyOnly(r), + EstSwaps: Number(r.estSwaps ?? 0), + EstSwapsBuyOnly: Number(r.estSwapsBuyOnly ?? 0), + FinalPriceUsd: finalPriceUsd(r), + FinalPriceUsdBuyOnly: finalPriceUsdBuyOnly(r), + })), + ); + // eslint-disable-next-line no-console + console.log( + `[AmplifyWorld recommended] mode=${RECOMMENDATION_MODE} (Top 5 ETH):`, + ); + // eslint-disable-next-line no-console + console.table( + recommendedETH.map((r) => ({ + ScenarioId: r.scenarioId, + Weights: `${r.startWeightTknPct}/${100 - r.startWeightTknPct} -> ${r.endWeightTknPct}/${100 - r.endWeightTknPct}`, + SwapFeePct: r.swapFeePct, + InitialCollateralUsd: formatNumber(r.initialCollateralUsd, 0), + DemandInputUsd: formatNumber(r.demandInputUsd, 0), + SellBehavior: sellBehaviorMeaning(String(r.sellBehavior)), + InitialPriceUsd: initialPriceUsd(r), + NetRaisedUsd: netRaisedUsd(r), + NetRaisedUsdBuyOnly: netRaisedUsdBuyOnly(r), + EstSwaps: Number(r.estSwaps ?? 0), + EstSwapsBuyOnly: Number(r.estSwapsBuyOnly ?? 0), + FinalPriceUsd: finalPriceUsd(r), + FinalPriceUsdBuyOnly: finalPriceUsdBuyOnly(r), + })), + ); + + if (process.env.EXPORT_XLSX === "1") { + const recommendedRows = [...recommendedUSDC, ...recommendedETH].map((r) => ({ + ScenarioId: r.scenarioId, + PressureMode: RECOMMENDATION_MODE, + CollateralToken: r.collateralToken, + StartWeightTknPct: r.startWeightTknPct, + EndWeightTknPct: r.endWeightTknPct, + SwapFeePct: r.swapFeePct, + InitialCollateralUsd: r.initialCollateralUsd, + DemandInputUsd: r.demandInputUsd, + SellBehavior: + RECOMMENDATION_MODE === "buy-only" + ? "Buy-only (sells disabled)" + : sellBehaviorMeaning(String(r.sellBehavior)), + SellBehaviorOriginal: sellBehaviorMeaning(String(r.sellBehavior)), + InitialPriceUsd: initialPriceUsd(r), + MinPriceUsd: toUsd(r.collateralToken, Number(r.minPrice ?? 0)), + FinalPriceUsd: finalPriceUsd(r), + NetRaisedUsd: netRaisedUsd(r), + MinPriceUsdBuyOnly: minPriceUsdBuyOnly(r), + FinalPriceUsdBuyOnly: finalPriceUsdBuyOnly(r), + NetRaisedUsdBuyOnly: netRaisedUsdBuyOnly(r), + EstSwaps: Number(r.estSwaps ?? 0), + EstSwapsBuyOnly: Number(r.estSwapsBuyOnly ?? 0), + CommunityHeld: r.communityHeld, + CommunityAvgCostUsd: toUsd(r.collateralToken, Number(r.communityAvgCost ?? 0)), + })); + + const now = new Date(); + const stamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); + const outPath = path.resolve( + process.cwd(), + `out/amplifyworld-recommended-${stamp}.xlsx`, + ); + writeXlsxFile(outPath, recommendedRows); + // eslint-disable-next-line no-console + console.log(`[AmplifyWorld recommended] XLSX exported: ${outPath}`); + } + + if (process.env.EXPORT_CSV === "1") { + const now = new Date(); + const stamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); + const outPath = path.resolve( + process.cwd(), + `out/amplifyworld-sweep-${stamp}.csv`, + ); + writeCsvFile(outPath, summaries); + // eslint-disable-next-line no-console + console.log(`[AmplifyWorld sweep] CSV exported: ${outPath}`); + } + + if (process.env.EXPORT_XLSX === "1") { + const now = new Date(); + const stamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); + const outPath = path.resolve( + process.cwd(), + `out/amplifyworld-sweep-${stamp}.xlsx`, + ); + writeXlsxFile(outPath, summaries); + // eslint-disable-next-line no-console + console.log(`[AmplifyWorld sweep] XLSX exported: ${outPath}`); + } + + if (process.env.EXPORT_TABLES_XLSX === "1") { + const tableAIds = [ + "collUSDC_w90_e10_fee3_c300k_d5m_loyal2c40", + "collUSDC_w90_e10_fee3_c300k_d5m_loyal5c60", + "collUSDC_w90_e10_fee3_c300k_d5m_loyal10c80", + "collUSDC_w98_e10_fee3_c1000k_d5m_greedy2_25", + "collUSDC_w98_e10_fee3_c1000k_d5m_greedy5_50", + "collETH_w90_e10_fee3_c1000k_d5m_loyal2c40", + "collETH_w90_e10_fee3_c1000k_d5m_loyal5c60", + "collETH_w90_e10_fee3_c1000k_d5m_loyal10c80", + "collETH_w98_e10_fee3_c3000k_d5m_greedy2_25", + "collETH_w98_e10_fee3_c3000k_d5m_greedy5_50", + ]; + + const tableBIds = [ + "collUSDC_w90_e10_fee3_c100000k_d100k_loyal5c60", + "collUSDC_w98_e10_fee3_c1000k_d100k_loyal5c60", + "collUSDC_w98_e10_fee3_c3000k_d100k_loyal5c60", + "collUSDC_w98_e10_fee3_c10000k_d100k_loyal5c60", + "collUSDC_w95_e10_fee3_c30000k_d100k_loyal5c60", + "collETH_w90_e10_fee3_c100000k_d100k_loyal5c60", + "collETH_w98_e10_fee3_c1000k_d100k_loyal5c60", + "collETH_w98_e10_fee3_c3000k_d100k_loyal5c60", + "collETH_w98_e10_fee3_c10000k_d100k_loyal5c60", + "collETH_w95_e10_fee3_c30000k_d100k_loyal5c60", + ]; + + const tableCIds = [ + "collUSDC_w98_e50_fee1_c100000k_d100k_loyal2c40", + "collUSDC_w98_e50_fee2_c100000k_d100k_loyal2c40", + "collUSDC_w98_e50_fee3_c100000k_d100k_loyal2c40", + "collUSDC_w98_e30_fee1_c100000k_d100k_loyal2c40", + "collUSDC_w98_e30_fee2_c100000k_d100k_loyal2c40", + "collETH_w98_e50_fee1_c100000k_d100k_loyal2c40", + "collETH_w98_e50_fee2_c100000k_d100k_loyal2c40", + "collETH_w98_e50_fee3_c100000k_d100k_loyal2c40", + "collETH_w98_e30_fee1_c100000k_d100k_loyal2c40", + "collETH_w98_e30_fee2_c100000k_d100k_loyal2c40", + ]; + + const weightsLabel = (r: any) => + `${r.startWeightTknPct}/${100 - Number(r.startWeightTknPct)} -> ${r.endWeightTknPct}/${100 - Number(r.endWeightTknPct)}`; + + const byId = new Map(summaries.map((s) => [String(s.scenarioId), s])); + const missing = [...tableAIds, ...tableBIds, ...tableCIds].filter((id) => !byId.has(id)); + if (missing.length) { + // eslint-disable-next-line no-console + console.log(`[AmplifyWorld tables] Missing scenarios (${missing.length}):`, missing); + } + + const tableARows = tableAIds + .map((id) => byId.get(id)) + .filter(Boolean) + .map((r) => ({ + ScenarioId: r.scenarioId, + CollateralToken: r.collateralToken, + Weights: weightsLabel(r), + SwapFeePct: Number(r.swapFeePct), + InitialCollateralUsd: Number(r.initialCollateralUsd), + DemandInputUsd: Number(r.demandInputUsd), + SellBehavior: sellBehaviorMeaning(String(r.sellBehavior)), + NetRaisedUsd: netRaisedUsd(r), + InitialPriceUsd: initialPriceUsd(r), + MinPriceUsd: toUsd(r.collateralToken, Number(r.minPrice ?? 0)), + FinalPriceUsd: finalPriceUsd(r), + EstimatedSwaps: Number(r.estSwaps ?? 0), + EstimatedBuySwaps: Number(r.estBuySwaps ?? 0), + EstimatedSellSwaps: Number(r.estSellSwaps ?? 0), + CommunityHeld: Number(r.communityHeld ?? 0), + CommunityAvgCostUsd: toUsd(r.collateralToken, Number(r.communityAvgCost ?? 0)), + })); + + const tableBRows = tableBIds + .map((id) => byId.get(id)) + .filter(Boolean) + .map((r) => ({ + ScenarioId: r.scenarioId, + CollateralToken: r.collateralToken, + Weights: weightsLabel(r), + SwapFeePct: Number(r.swapFeePct), + InitialCollateralUsd: Number(r.initialCollateralUsd), + DemandInputUsd: Number(r.demandInputUsd), + SellBehavior: sellBehaviorMeaning(String(r.sellBehavior)), + NetRaisedUsd: netRaisedUsd(r), + InitialPriceUsd: initialPriceUsd(r), + FinalPriceUsd: finalPriceUsd(r), + EstimatedSwaps: Number(r.estSwaps ?? 0), + })); + + const tableCRows = tableCIds + .map((id) => byId.get(id)) + .filter(Boolean) + .map((r) => ({ + ScenarioId: r.scenarioId, + CollateralToken: r.collateralToken, + Weights: weightsLabel(r), + SwapFeePct: Number(r.swapFeePct), + InitialCollateralUsd: Number(r.initialCollateralUsd), + DemandInputUsd: Number(r.demandInputUsd), + SellBehavior: sellBehaviorMeaning(String(r.sellBehavior)), + InitialPriceUsd: initialPriceUsd(r), + FinalPriceUsdBuyOnly: finalPriceUsdBuyOnly(r), + NetRaisedUsdBuyOnly: netRaisedUsdBuyOnly(r), + EstimatedSwapsBuyOnly: Number(r.estSwapsBuyOnly ?? 0), + EstimatedBuySwapsBuyOnly: Number(r.estBuySwapsBuyOnly ?? 0), + EstimatedSellSwapsBuyOnly: Number(r.estSellSwapsBuyOnly ?? 0), + })); + + const now = new Date(); + const stamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); + + const outPath = path.resolve( + process.cwd(), + `out/amplifyworld-3tables-${stamp}.xlsx`, + ); + writeXlsxWorkbook(outPath, [ + { name: "Table A (d5m mix)", rows: tableARows }, + { name: "Table B (seedless)", rows: tableBRows }, + { name: "Table C (buy-only)", rows: tableCRows }, + ]); + // eslint-disable-next-line no-console + console.log(`[AmplifyWorld tables] XLSX exported: ${outPath}`); + } + + const topRaised = [...summaries].sort((a, b) => b.netRaised - a.netRaised).slice(0, 12); + const bottomRaised = [...summaries].sort((a, b) => a.netRaised - b.netRaised).slice(0, 12); + + // eslint-disable-next-line no-console + console.log( + `[AmplifyWorld sweep] scenarios=${summaries.length} (wallets=${wallets.toLocaleString()})`, + ); + // eslint-disable-next-line no-console + console.log( + `[AmplifyWorld sweep] Units: prices are collateral/token; NetRaised and DemandEndCumulative are in collateral units. ` + + `ETH conversions assume ${ASSUMED_ETH_USD.toLocaleString()} USD/ETH.`, + ); + // eslint-disable-next-line no-console + console.log("[AmplifyWorld sweep] Top net raised (buys - sells):"); + // eslint-disable-next-line no-console + console.table( + topRaised.map((s) => ({ + ScenarioId: s.scenarioId, + CollateralToken: s.collateralToken, + SwapFeePct: s.swapFeePct, + StartWeightTknPct: s.startWeightTknPct, + InitialCollateralUsd: formatNumber(s.initialCollateralUsd, 0), + InitialCollateral: formatNumber(s.initialCollateral, 6), + DemandEndCumulativeUsd: formatNumber(s.demandEndCumulativeUsd, 0), + DemandEndCumulative: formatNumber(s.demandEndCumulative, 6), + SellBehavior: s.sellBehavior, + NetRaised: formatNumber(s.netRaised, 6), + EstSwaps: Number(s.estSwaps ?? 0), + InitialPrice: formatNumber(s.initialPrice, 10), + MinPrice: formatNumber(s.minPrice, 10), + FinalPrice: formatNumber(s.finalPrice, 10), + CommunityHeld: formatNumber(s.communityHeld, 2), + })), + ); + + // eslint-disable-next-line no-console + console.log("[AmplifyWorld sweep] Bottom net raised:"); + // eslint-disable-next-line no-console + console.table( + bottomRaised.map((s) => ({ + ScenarioId: s.scenarioId, + CollateralToken: s.collateralToken, + SwapFeePct: s.swapFeePct, + StartWeightTknPct: s.startWeightTknPct, + InitialCollateralUsd: formatNumber(s.initialCollateralUsd, 0), + InitialCollateral: formatNumber(s.initialCollateral, 6), + DemandEndCumulativeUsd: formatNumber(s.demandEndCumulativeUsd, 0), + DemandEndCumulative: formatNumber(s.demandEndCumulative, 6), + SellBehavior: s.sellBehavior, + NetRaised: formatNumber(s.netRaised, 6), + EstSwaps: Number(s.estSwaps ?? 0), + InitialPrice: formatNumber(s.initialPrice, 10), + MinPrice: formatNumber(s.minPrice, 10), + FinalPrice: formatNumber(s.finalPrice, 10), + CommunityHeld: formatNumber(s.communityHeld, 2), + })), + ); + }, + ); +}); diff --git a/test/simulation.integration.test.ts b/test/simulation.integration.test.ts index 16e4a49..b33872c 100644 --- a/test/simulation.integration.test.ts +++ b/test/simulation.integration.test.ts @@ -5,7 +5,10 @@ import type { SellPressureConfig, } from "../lib/lbp-math"; import type { SimulationStateSnapshot } from "../lib/simulation-core"; -import { runDeterministicSimulation } from "../public/workers/simulation-runner.js"; +import { + runDeterministicSimulation, + calculatePotentialPricePaths, +} from "../public/workers/simulation-runner.js"; /** * Integration tests for the complete simulation (run in-process; no Worker). @@ -219,6 +222,76 @@ describe("LBP Simulation Integration", () => { }); describe("Scenario Testing", () => { + it("potential low path: no buys from pause point (factor=0)", async () => { + const config = createBaseConfig(); + const demandConfig: DemandPressureConfig = { + preset: "bullish", + magnitudeBase: 100000, + multiplier: 1, + }; + const sellConfig: SellPressureConfig = { + preset: "loyal", + loyalSoldPct: 0, + loyalConcentrationPct: 60, + greedySpreadPct: 2, + greedySellPct: 0, + }; + + const steps = 100; + const paths = calculatePotentialPricePaths( + config, + demandConfig, + sellConfig, + steps, + [0], + 0, + null, + ); + expect(paths.length).toBe(1); + expect(paths[0].length).toBe(steps + 1); + + const noBuyDemand: DemandPressureConfig = { + ...demandConfig, + multiplier: 0, + }; + const snapshots = await runSimulation(config, noBuyDemand, sellConfig, steps); + expect(snapshots.length).toBe(steps + 1); + + for (let i = 0; i <= steps; i++) { + expect(Math.abs(paths[0][i] - snapshots[i].price)).toBeLessThan(1e-9); + } + }); + + it("buy-only (sell pressure disabled): total raised equals executed demand", async () => { + const config = createBaseConfig(); + const demandConfig: DemandPressureConfig = { + preset: "bullish", + magnitudeBase: 100000, + multiplier: 1, + }; + const sellConfig: SellPressureConfig = { + preset: "loyal", + loyalSoldPct: 0, + loyalConcentrationPct: 60, + greedySpreadPct: 2, + greedySellPct: 0, + }; + + const snapshots = await runSimulation( + config, + demandConfig, + sellConfig, + 100, + ); + + const totalBuy = snapshots.reduce((acc, s) => acc + s.buyVolumeUSDC, 0); + const totalSell = snapshots.reduce((acc, s) => acc + s.sellVolumeUSDC, 0); + const netRaised = snapshots[snapshots.length - 1].usdcBalance - config.usdcBalanceIn; + + expect(totalSell).toBeLessThan(1e-9); + expect(Math.abs(netRaised - totalBuy)).toBeLessThan(0.01); + }); + it("no demand scenario: price should only decrease from weight changes", async () => { const config = createBaseConfig(); const demandConfig: DemandPressureConfig = { From bf126b1b1cbc470d66b5eac8b913310083dcb778 Mon Sep 17 00:00:00 2001 From: gustavobftorres Date: Fri, 6 Mar 2026 13:59:42 -0300 Subject: [PATCH 2/7] fix: small type error, typing overrides on simulation as a array of numbers --- test/project-scenarios.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/project-scenarios.test.ts b/test/project-scenarios.test.ts index 9aa4b9e..a4b30e5 100644 --- a/test/project-scenarios.test.ts +++ b/test/project-scenarios.test.ts @@ -49,7 +49,7 @@ function normalizeWeights(cfg: LBPConfig): LBPConfig { function mergeCase( base: ScenarioFile["base"], - overrides?: ScenarioFile["cases"][number]["overrides"], + overrides?: NonNullable[number]["overrides"], ) { const steps = overrides?.steps ?? base.steps ?? 300; const lbpConfig = normalizeWeights({ From 95aac3b4fd57a7c4a899de8c8aa16d3b346e6c16 Mon Sep 17 00:00:00 2001 From: gustavobftorres Date: Fri, 6 Mar 2026 14:17:18 -0300 Subject: [PATCH 3/7] feat: add swaps counter os Sales table --- .../lbp-simulator/simulator/SimulatorMain.tsx | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/components/lbp-simulator/simulator/SimulatorMain.tsx b/components/lbp-simulator/simulator/SimulatorMain.tsx index a275db3..f2b921f 100644 --- a/components/lbp-simulator/simulator/SimulatorMain.tsx +++ b/components/lbp-simulator/simulator/SimulatorMain.tsx @@ -3,10 +3,34 @@ import { useSimulatorStore } from "@/store/useSimulatorStore"; import { useShallow } from "zustand/react/shallow"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useEffect, memo } from "react"; +import { useEffect, useMemo, useState, memo } from "react"; import { useSimulationWorker } from "@/lib/hooks/useSimulationWorker"; import { SimulatorChartArea } from "./SimulatorChartArea"; +function SalesSwapsCountBadge({ isVisible }: { isVisible: boolean }) { + const swapCount = useSimulatorStore((state) => state.swaps.length); + + const label = useMemo(() => { + if (!isVisible) return ""; + return `Swaps: ${swapCount.toLocaleString()}`; + }, [isVisible, swapCount]); + + if (!isVisible) return null; + + return ( +
+ Swaps: + + {swapCount.toLocaleString()} + +
+ ); +} + /** * Chart area shell; subscribes only to static/slow-changing state (config, etc.). * SwapForm is rendered as a sibling in Simulator so it never re-renders when this re-renders. @@ -22,6 +46,10 @@ function SimulatorMainComponent() { })), ); + const [activeTab, setActiveTab] = useState< + "chart" | "swaps" | "demand" | "weights" + >("chart"); + const { snapshots: workerSnapshots } = useSimulationWorker( config, demandPressureConfig, @@ -38,7 +66,13 @@ function SimulatorMainComponent() { }, [workerSnapshots, setBaseSnapshots]); return ( - + + setActiveTab(v as "chart" | "swaps" | "demand" | "weights") + } + className="w-full" + >
-
+
-

- Dynamic price: Decays with - time, rises with demand. Dotted lines show potential price paths - based on different demand scenarios. +

+ Dynamic price: + + Decays with time, rises with demand. Dotted lines show potential + price paths based on different demand scenarios. +

+
From f40afb66177c1b5e4828a6d364cd1ed3f190b007 Mon Sep 17 00:00:00 2001 From: gustavobftorres Date: Fri, 6 Mar 2026 14:25:12 -0300 Subject: [PATCH 4/7] feat: add numeric input to start and final wheight selection --- .../simulator/SimulatorConfig.tsx | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/components/lbp-simulator/simulator/SimulatorConfig.tsx b/components/lbp-simulator/simulator/SimulatorConfig.tsx index 544c5dd..b5733ed 100644 --- a/components/lbp-simulator/simulator/SimulatorConfig.tsx +++ b/components/lbp-simulator/simulator/SimulatorConfig.tsx @@ -104,12 +104,18 @@ function SimulatorConfigComponent() { const [pressureMode, setPressureMode] = useState<"buy-and-sell" | "buy-only">( () => { if (sellPressureConfig.preset === "loyal") { - return sellPressureConfig.loyalSoldPct <= 0 ? "buy-only" : "buy-and-sell"; + return sellPressureConfig.loyalSoldPct <= 0 + ? "buy-only" + : "buy-and-sell"; } - return sellPressureConfig.greedySellPct <= 0 ? "buy-only" : "buy-and-sell"; + return sellPressureConfig.greedySellPct <= 0 + ? "buy-only" + : "buy-and-sell"; }, ); - const sellConfigBeforeBuyOnlyRef = useRef(null); + const sellConfigBeforeBuyOnlyRef = useRef( + null, + ); // Update local state when store config changes useEffect(() => { @@ -300,7 +306,8 @@ function SimulatorConfigComponent() { // Buy-only should disable sell pressure regardless of the current preset // (e.g. if the user previously selected "greedy", that still sells). if (sellConfigBeforeBuyOnlyRef.current == null) { - sellConfigBeforeBuyOnlyRef.current = sellPressureConfig; + sellConfigBeforeBuyOnlyRef.current = + sellPressureConfig; } updateSellPressureConfig({ preset: "loyal", @@ -437,7 +444,7 @@ function SimulatorConfigComponent() {
- {config.tknWeightIn}% + {localTknWeightIn}% - {config.usdcWeightIn}% + {100 - localTknWeightIn}% + { + const next = Number(e.target.value); + if (!Number.isFinite(next)) return; + handleWeightChange( + Math.max(1, Math.min(99, Math.round(next))), + ); + }} + />
- {config.tknWeightOut}% + {localTknWeightOut}% - {config.usdcWeightOut}% + {100 - localTknWeightOut}% + { + const next = Number(e.target.value); + if (!Number.isFinite(next)) return; + handleEndWeightChange( + Math.max(1, Math.min(99, Math.round(next))), + ); + }} + />
@@ -517,7 +554,7 @@ function SimulatorConfigComponent() { - {[1, 2, 3, 4, 5, 6, 7 , 8, 9, 10].map((fee) => ( + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((fee) => ( {fee}% From 2f68e0b1770d43d57667e8ac0049e2c4d45d1ece Mon Sep 17 00:00:00 2001 From: gustavobftorres Date: Fri, 6 Mar 2026 14:40:54 -0300 Subject: [PATCH 5/7] fix: addusdc and usdt logos, by creating their own svg --- components/ui/TokenLogo.tsx | 77 +++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/components/ui/TokenLogo.tsx b/components/ui/TokenLogo.tsx index 2c858e7..bcb633a 100644 --- a/components/ui/TokenLogo.tsx +++ b/components/ui/TokenLogo.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { CollateralToken } from "@/lib/lbp-math"; -import Image from "next/image"; interface TokenLogoProps { token: CollateralToken | string; @@ -32,10 +31,62 @@ const EthLogo = (props: React.SVGProps) => ( ); -const TOKEN_LOGOS: Record = { - USDC: "https://cryptologos.cc/logos/usd-coin-usdc-logo.svg?v=040", - USDT: "https://cryptologos.cc/logos/tether-usdt-logo.svg?v=002" -}; +// Inline SVGs (no remote fetch). Built to resemble common token logos. +// Reference URLs (for shape/branding): cryptologos.cc (USDC/USDT). +const UsdcLogo = (props: React.SVGProps) => ( + + + + + +); + +const UsdtLogo = (props: React.SVGProps) => ( + + + + + + + + + +); export function TokenLogo({ token, size = 24, className = "" }: TokenLogoProps) { // Handle ETH and wETH with inline SVG @@ -53,22 +104,14 @@ export function TokenLogo({ token, size = 24, className = "" }: TokenLogoProps) ); } - const logoUrl = TOKEN_LOGOS[token]; - - if (logoUrl) { + if (token === "USDC" || token === "USDT") { + const Logo = token === "USDC" ? UsdcLogo : UsdtLogo; return (
- {token} +
); } From 38d3b9ebf0a0fb100666619673ce08ce0f141e7d Mon Sep 17 00:00:00 2001 From: gustavobftorres Date: Fri, 6 Mar 2026 14:55:17 -0300 Subject: [PATCH 6/7] ench: improve buy pressure input, by letting the user type the exact final demand value --- .../simulator/DemandPressureConfig.tsx | 228 +++++++++++++++--- 1 file changed, 188 insertions(+), 40 deletions(-) diff --git a/components/lbp-simulator/simulator/DemandPressureConfig.tsx b/components/lbp-simulator/simulator/DemandPressureConfig.tsx index 442a157..68d3695 100644 --- a/components/lbp-simulator/simulator/DemandPressureConfig.tsx +++ b/components/lbp-simulator/simulator/DemandPressureConfig.tsx @@ -40,6 +40,39 @@ import { useDebounce } from "@/lib/useDebounce"; import { useShallow } from "zustand/react/shallow"; import { GiBull } from "react-icons/gi"; import { GiBearFace } from "react-icons/gi"; +import { formatNumber } from "@/lib/utils"; + +const MAGNITUDE_BASES = [10_000, 100_000, 1_000_000] as const; + +function parseNumberInput(raw: string) { + const cleaned = raw.replace(/,/g, "").trim(); + if (cleaned === "") return null; + const n = Number(cleaned); + if (!Number.isFinite(n)) return null; + return n; +} + +function pickMagnitudeBaseForTarget(target: number) { + // Prefer multipliers in a "nice" range so the UI stays readable. + let best = 100_000 as (typeof MAGNITUDE_BASES)[number]; + let bestScore = Number.POSITIVE_INFINITY; + + for (const base of MAGNITUDE_BASES) { + const mult = target / base; + const penaltyLow = mult < 0.1 ? 10 + (0.1 - mult) * 50 : 0; + const penaltyHigh = mult > 20 ? 10 + (mult - 20) : 0; + const score = + penaltyLow + + penaltyHigh + + Math.abs(Math.log10(Math.max(1e-9, mult))); + if (score < bestScore) { + bestScore = score; + best = base; + } + } + + return best; +} function DemandPressureConfigComponent() { const { demandPressureConfig, updateDemandPressureConfig, config } = @@ -54,12 +87,25 @@ function DemandPressureConfigComponent() { // Local state for immediate UI updates const [localConfig, setLocalConfig] = useState(demandPressureConfig); + const [endCumulativeInput, setEndCumulativeInput] = useState(""); + const [isEditingEndCumulative, setIsEditingEndCumulative] = useState(false); // Update local state when store config changes (e.g., reset) useEffect(() => { setLocalConfig(demandPressureConfig); }, [demandPressureConfig]); + const endScale = localConfig.preset === "bearish" ? 0.35 : 1.0; + const endCumulativeValue = + localConfig.magnitudeBase * localConfig.multiplier * endScale; + + // Keep the "single number" input in sync with underlying magnitudeBase * multiplier (and preset scaling), + // but don't fight the user while they're typing. + useEffect(() => { + if (isEditingEndCumulative) return; + setEndCumulativeInput(formatNumber(Math.round(endCumulativeValue))); + }, [endCumulativeValue, isEditingEndCumulative]); + // Debounce the local config before updating the store const debouncedConfig = useDebounce(localConfig, 500); @@ -195,7 +241,22 @@ function DemandPressureConfigComponent() {