{"mappings":"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","names":[],"sources":["/assets/app/actions/public/market.ts"],"version":3,"sourcesContent":["/**\n * Market data types, API client, and display formatters.\n *\n * Replaces the former `mock-market.ts` fixture generator. The shapes are\n * unchanged so the UI did not have to be rewritten; the data now comes from the\n * Rust API in `backend/`, which reads the Robinhood Chain ETL.\n */\n\nexport type TimeRange = '1H' | '24H' | '7D'\n\nexport type Pool = {\n  addr: string\n  hook: string\n  type: 'Uniswap v2' | 'Uniswap v3' | 'Uniswap v4' | 'Unknown'\n  /** Rendered percentage, or 'DYNAMIC' for v4 dynamic-fee pools, or '—'. */\n  fee: string\n  /** null when GeckoTerminal has no reserve for this pool. */\n  tvl: number | null\n  /**\n   * True when only one side of the pool could be priced, so `tvl` counts that\n   * side alone and understates depth. Absent on pre-2026-09-07 snapshots.\n   */\n  tvlPartial?: boolean\n  created: number\n  /**\n   * Worth showing: TVL >= $1 or at least one trade in the last 24h. Optional\n   * so a snapshot from an API older than 2026-09-07 still type-checks; absent\n   * is treated as live, never as dead.\n   */\n  live?: boolean\n  /** USD volume in this pool over the last 24h, whatever the page range. */\n  vol24h?: number | null\n  /** Swaps in this pool over the last 24h. */\n  trades24h?: number\n}\n\n/** The pool that represents a pair: deepest, ties broken by 24h volume then age. */\nexport type PrimaryPool = {\n  addr: string\n  type: Pool['type']\n}\n\n/** Pools worth showing. An API that predates the flag reports every pool. */\nexport function isLivePool(pool: Pool): boolean {\n  return pool.live !== false\n}\n\nexport type Pair = {\n  sym: string\n  sname: string\n  saddr: string\n  isin: string\n  meme: string\n  mname: string\n  maddr: string\n  /** null when GeckoTerminal cannot serve this window (no bucket beyond h24). */\n  vol: number | null\n  /**\n   * Routing-adjusted volume over the same window: per (stock, tx) the ETL\n   * keeps max(bought, sold), so a hop through the stock counts once instead of\n   * twice. Absent on pre-2026-09-07 snapshots.\n   */\n  netVol?: number | null\n  trades: number\n  traders: number\n  /** Live pools only (see `isLivePool`); `pools` still carries every one. */\n  poolsN: number\n  /** Every pool we know for this pair. Absent on pre-2026-09-07 snapshots. */\n  poolsTotal?: number\n  /** Deepest pool, used for the chart embed. Absent when the pair has none. */\n  primaryPool?: PrimaryPool\n  qty: number\n  price: number | null\n  /** Market cap in USD (`totalSupply × price`); absent when either is unknown. */\n  mcap?: number\n  /** null when the ETL has no holder count for this token (~82% of memes). */\n  holders: number | null\n  /**\n   * Epoch ms the holder count is current to (the newest block the ETL has\n   * ingested). Shown next to the number so a stale external comparison is\n   * self-diagnosing — the QA thread's \"8.2k holders\" was a two-day-old page.\n   */\n  holdersAsOf?: number\n  supply: string\n  /** Meme token icon (https, ipfs already rewritten), or null for a placeholder. */\n  logo: string | null\n  /** Stock-side icon, so a pair row can draw both halves. */\n  slogo: string | null\n  pools: Pool[]\n  /** Epoch ms the pair was first seen: its OLDEST pool's creation. */\n  created: number\n  pflash?: number\n  /**\n   * Rich metadata from the ETL (token_launch / token_metadata). Each key is\n   * absent (not null) until the metadata worker has reached the token.\n   */\n  launchpad?: string\n  website?: string\n  /**\n   * `tokens.socials` passed through verbatim. The rich resolver writes\n   * `SocialLink[]`; rows from the legacy resolver still carry a flat object\n   * (`{ raw, twitter, website }`), so guard with `Array.isArray` before use.\n   */\n  socials?: SocialLink[] | Record<string, string | null>\n  description?: string\n}\n\nexport type SocialLink = {\n  type: string\n  label?: string | null\n  url: string\n}\n\nexport type Stock = {\n  sym: string\n  name: string\n  /**\n   * Price and volume come from GeckoTerminal only, never blended with ETL\n   * pricing. null means GeckoTerminal has no figure for this window and the UI\n   * must show N/A rather than a plausible-looking number.\n   */\n  price: number | null\n  /** Market cap in USD (`totalSupply × price`); absent when unknown. */\n  mcap?: number\n  chg: number | null\n  vol: number | null\n  /**\n   * Volume of this stock's meme<->stock pools only. This is the basis\n   * CoinGecko's stonks page header uses, unlike `vol` which is every pool.\n   */\n  memeVol: number | null\n  /**\n   * Routing-adjusted volume over the window: per (stock, tx) max(bought, sold),\n   * so a meme buy routed USDG->NVDA->AI counts the NVDA notional once instead\n   * of twice. Absent on pre-2026-09-07 snapshots.\n   */\n  netVol?: number | null\n  /** Memes that traded in the window. Same number as `pairsActive`. */\n  pairsN: number\n  /** Memes that traded in the window. Absent on older snapshots. */\n  pairsActive?: number\n  /** Memes ever paired with this stock, traded or not. CoinGecko counts this. */\n  pairsTotal?: number\n  /** Live pools holding this stock. */\n  poolsN: number\n  /** Every pool holding this stock, live or not. */\n  poolsTotal?: number\n  trades: number\n  isin: string\n  addr: string\n  beacon: string\n  impl: string\n  mult: string\n  spark: string\n  /** Stock icon from the registry, or null for a placeholder. */\n  logo: string | null\n}\n\nexport type Meta = {\n  generated: number\n  block: number\n  rollup_age_s: number\n  range: TimeRange\n  pairs_total: number\n  pairs_truncated: boolean\n  /** Chain slug this snapshot belongs to (e.g. 'robinhood'). Additive. */\n  chain?: string\n}\n\nexport type MarketData = {\n  stocks: Stock[]\n  pairs: Pair[]\n  fees: string[]\n  meta: Meta\n}\n\n/** Range of the daily stock chart (independent of the page's `TimeRange`). */\nexport type SeriesRange = '7D' | '30D' | '90D' | 'ALL'\n\nexport const SERIES_RANGES: readonly SeriesRange[] = ['7D', '30D', '90D', 'ALL']\n\n/**\n * Daily series for one stock token, from `GET /api/series/{symbol}`. All arrays\n * are aligned by `days` and gap-free: a day without trades carries the previous\n * close forward with zero volume. `supplies` / `mcaps` are null when unknown.\n */\nexport interface Series {\n  symbol: string\n  chain: string\n  source: string\n  range: SeriesRange\n  count: number\n  /** ISO calendar days (UTC), e.g. '2026-08-07'. */\n  days: string[]\n  closes: number[]\n  volumes: number[]\n  memeVolumes: number[]\n  /** Routing-adjusted daily volume. Absent on pre-2026-09-07 API builds. */\n  netVolumes?: number[]\n  trades: number[]\n  supplies: (number | null)[]\n  mcaps: (number | null)[]\n}\n\nexport const EMPTY_MARKET: MarketData = {\n  stocks: [],\n  pairs: [],\n  fees: [],\n  meta: {\n    generated: 0,\n    block: 0,\n    rollup_age_s: 0,\n    range: '24H',\n    pairs_total: 0,\n    pairs_truncated: false,\n  },\n}\n\nconst API_BASE = '/api'\n\nexport async function fetchMarket(range: TimeRange, signal?: AbortSignal): Promise<MarketData> {\n  const response = await fetch(`${API_BASE}/market?range=${range}`, {\n    signal,\n    headers: { Accept: 'application/json' },\n  })\n  if (!response.ok) throw new Error(`market ${response.status}`)\n  return (await response.json()) as MarketData\n}\n\n/** URL of the daily series endpoint; `chain` is optional and omitted by default. */\nexport function seriesUrl(symbol: string, range: SeriesRange, chain?: string): string {\n  const query = chain ? `?range=${range}&chain=${encodeURIComponent(chain)}` : `?range=${range}`\n  return `${API_BASE}/series/${encodeURIComponent(symbol)}${query}`\n}\n\nexport async function fetchSeries(\n  symbol: string,\n  range: SeriesRange,\n  signal?: AbortSignal,\n  chain?: string,\n): Promise<Series> {\n  const response = await fetch(seriesUrl(symbol, range, chain), {\n    signal,\n    headers: { Accept: 'application/json' },\n  })\n  if (!response.ok) throw new Error(`series ${response.status}`)\n  return (await response.json()) as Series\n}\n\n// ------------------------------------------------------------ chart helpers\n//\n// Pure geometry for the daily chart, kept out of the component so it can be\n// unit-tested without a DOM. All paths are in a `width × height` viewBox.\n\nexport type SeriesMetric = 'price' | 'mcap'\n\n/**\n * Values plotted on the top panel. Market cap falls back to price when no day\n * has a known cap; `fallback` tells the UI to say so. Days with an unknown cap\n * inside an otherwise known series carry the nearest known cap (previous, or\n * the first known one for leading gaps), so the line stays gap-free like the\n * underlying series.\n */\nexport function seriesValues(\n  series: Series,\n  metric: SeriesMetric,\n): { values: number[]; metric: SeriesMetric; fallback: boolean } {\n  if (metric === 'mcap') {\n    const isKnown = (value: number | null): value is number => value !== null && Number.isFinite(value)\n    const first = series.mcaps.find(isKnown)\n    if (first !== undefined) {\n      let last = first\n      const values = series.mcaps.map((value) => {\n        if (isKnown(value)) last = value\n        return last\n      })\n      return { values, metric: 'mcap', fallback: false }\n    }\n    return { values: series.closes, metric: 'price', fallback: true }\n  }\n  return { values: series.closes, metric: 'price', fallback: false }\n}\n\n/**\n * SVG line and area paths for `values`, spread evenly across `width` with a\n * 14% vertical margin so the extremes do not touch the edges. A flat series\n * gets a 1% band so it still draws as a mid-height line. Non-finite entries\n * are skipped rather than poisoning the whole path.\n */\nexport function seriesPath(\n  values: readonly number[],\n  width = 1000,\n  height = 200,\n): { line: string; area: string; low: number; high: number } {\n  const clean = values.filter((value) => Number.isFinite(value))\n  if (clean.length === 0) return { line: '', area: '', low: 0, high: 0 }\n  const low = Math.min(...clean)\n  const high = Math.max(...clean)\n  const pad = (high - low) * 0.14 || Math.abs(high) * 0.01 || 1\n  const top = high + pad\n  const bottom = low - pad\n  const y = (value: number) => height - ((value - bottom) / (top - bottom)) * height\n  const x = (index: number) => (values.length === 1 ? width / 2 : (index / (values.length - 1)) * width)\n  const points: string[] = []\n  let firstX = 0\n  let lastX = 0\n  for (let index = 0; index < values.length; index++) {\n    const value = values[index]\n    if (!Number.isFinite(value)) continue\n    if (points.length === 0) firstX = x(index)\n    lastX = x(index)\n    points.push((points.length ? 'L' : 'M') + x(index).toFixed(1) + ' ' + y(value).toFixed(1))\n  }\n  const line = points.join(' ')\n  const area = line + ' L' + lastX.toFixed(1) + ' ' + height + ' L' + firstX.toFixed(1) + ' ' + height + ' Z'\n  return { line, area, low, high }\n}\n\n/**\n * SVG path of one bar per entry, scaled against `peak` (so ALL and MEME bars\n * share one scale). Zero-volume days draw nothing rather than a 1px stub, so\n * a carried-forward day is visibly empty.\n */\nexport function seriesBars(\n  values: readonly number[],\n  peak: number,\n  width = 1000,\n  height = 200,\n): string {\n  if (values.length === 0 || !(peak > 0)) return ''\n  const gap = width / values.length\n  const barWidth = gap * 0.7\n  const parts: string[] = []\n  for (let index = 0; index < values.length; index++) {\n    const value = values[index]\n    if (!Number.isFinite(value) || value <= 0) continue\n    const barHeight = Math.max(1, (value / peak) * (height - 4))\n    const xPosition = index * gap + (gap - barWidth) / 2\n    parts.push('M' + xPosition.toFixed(1) + ' ' + (height - barHeight).toFixed(1)\n      + 'h' + barWidth.toFixed(1) + 'v' + barHeight.toFixed(1) + 'h-' + barWidth.toFixed(1) + 'Z')\n  }\n  return parts.join(' ')\n}\n\n/** 'Aug 7' for '2026-08-07'; the raw string when it is not an ISO day. */\nexport function formatDay(day: string): string {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(day)\n  if (!match) return day\n  const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))\n  return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })\n}\n\n/** X-axis labels: first day, middle day, last day (fewer when the range is short). */\nexport function seriesAxis(days: readonly string[]): string[] {\n  if (days.length === 0) return []\n  if (days.length <= 2) return days.map(formatDay)\n  return [formatDay(days[0]), formatDay(days[Math.floor((days.length - 1) / 2)]), formatDay(days[days.length - 1])]\n}\n\nexport interface StockDelta {\n  sym: string\n  price: number | null\n  chg: number | null\n  vol: number | null\n  memeVol: number | null\n  netVol?: number | null\n  trades: number\n}\n\nexport type PairDelta = {\n  sym: string\n  meme: string\n  /** Token addresses: the only unique key for a pair (meme symbols collide). */\n  saddr?: string\n  maddr?: string\n  vol: number | null\n  netVol?: number | null\n  trades: number\n  price: number | null\n}\n\nexport type LiveMessage =\n  | { type: 'resync' }\n  | {\n      type: 'tick'\n      range: TimeRange\n      meta: Meta\n      stocks: StockDelta[]\n      pairs: PairDelta[]\n      resync: boolean\n    }\n\n/**\n * Applies a tick in place and returns the symbols whose price moved, so the UI\n * can flash exactly those rows. Mutates `market` deliberately: the alternative\n * is rebuilding a 2000-entry array on every tick.\n */\nexport function applyTick(\n  market: MarketData,\n  tick: Extract<LiveMessage, { type: 'tick' }>,\n): Record<string, 1 | -1> {\n  const flashes: Record<string, 1 | -1> = {}\n\n  const stocks = new Map(market.stocks.map((stock) => [stock.sym, stock]))\n  for (const delta of tick.stocks) {\n    const stock = stocks.get(delta.sym)\n    if (!stock) continue\n    // Only flash when both sides are known; a value appearing or disappearing\n    // is a state change, not a price move.\n    if (delta.price !== null && stock.price !== null && delta.price !== stock.price) {\n      flashes[delta.sym] = delta.price > stock.price ? 1 : -1\n    }\n    stock.price = delta.price\n    stock.chg = delta.chg\n    stock.vol = delta.vol\n    stock.memeVol = delta.memeVol\n    // Older APIs omit netVol entirely; keep whatever the snapshot had rather\n    // than blanking a figure the tick simply does not carry.\n    if (delta.netVol !== undefined) stock.netVol = delta.netVol\n    stock.trades = delta.trades\n  }\n\n  // Keyed by addresses, not symbols: a 24H snapshot has ~300 (sym, meme)\n  // collisions (ten distinct \"AGI\" tokens against NVDA), and a symbol-keyed\n  // map applied every delta to one arbitrary row. Ticks from an older API\n  // that lack addresses fall back to the symbol key.\n  const byAddress = new Map(market.pairs.map((pair) => [pair.saddr.toLowerCase() + '\\x00' + pair.maddr.toLowerCase(), pair]))\n  const bySymbol = new Map(market.pairs.map((pair) => [pair.sym + '\\x00' + pair.meme, pair]))\n  for (const delta of tick.pairs) {\n    const pair =\n      delta.saddr && delta.maddr\n        ? byAddress.get(delta.saddr.toLowerCase() + '\\x00' + delta.maddr.toLowerCase())\n        : bySymbol.get(delta.sym + '\\x00' + delta.meme)\n    if (!pair) continue\n    pair.vol = delta.vol\n    if (delta.netVol !== undefined) pair.netVol = delta.netVol\n    pair.trades = delta.trades\n    pair.price = delta.price\n  }\n\n  // Structural fields (`poolsN`/`poolsTotal`, `primaryPool`, `pools`,\n  // `holders`/`holdersAsOf`, `created`, `pairsActive`/`pairsTotal`) are not in\n  // the tick by design — they move on pool discovery and holder backfills, not\n  // on every swap. They refresh on a resync or the next full fetch.\n  market.meta = tick.meta\n  return flashes\n}\n\n/**\n * SSE endpoint for the live feed.\n *\n * SSE rather than WebSocket because the public origin is the Node server, which\n * proxies `/api/*` with `fetch()` — and `fetch` cannot forward a WebSocket\n * upgrade. The feed is server-to-client only, so nothing is lost, and\n * `EventSource` handles reconnection itself.\n */\nexport function liveUrl(range: TimeRange): string {\n  return `${API_BASE}/live?range=${range}`\n}\n\n/** Server-side: the list of stock symbols, for route validation. */\nexport async function fetchSymbols(signal?: AbortSignal): Promise<string[]> {\n  const response = await fetch(`${API_BASE}/symbols`, {\n    signal,\n    headers: { Accept: 'application/json' },\n  })\n  if (!response.ok) throw new Error(`symbols ${response.status}`)\n  const body = (await response.json()) as { symbols: string[] }\n  return body.symbols\n}\n\n// ---------------------------------------------------------------- formatters\n\nexport function hash32(value: string): number {\n  let hash = 2166136261\n  for (let index = 0; index < value.length; index++) {\n    hash ^= value.charCodeAt(index)\n    hash = Math.imul(hash, 16777619)\n  }\n  return hash >>> 0\n}\n\n/** Anything GeckoTerminal could not supply renders as N/A, never as 0. */\nexport const NA = 'N/A'\n\nexport function formatUsdMaybe(value: number | null | undefined): string {\n  return value === null || value === undefined ? NA : formatUsd(value)\n}\n\nexport function formatPriceMaybe(value: number | null | undefined): string {\n  return value === null || value === undefined ? NA : formatPrice(value)\n}\n\nexport function formatChangeMaybe(value: number | null | undefined): string {\n  return value === null || value === undefined ? NA : formatChange(value)\n}\n\nexport function formatCompactMaybe(value: number | null | undefined): string {\n  return value === null || value === undefined ? NA : formatCompact(value)\n}\n\n/** Sort key that pushes unavailable values to the end in either direction. */\nexport function sortValue(value: number | null | undefined): number {\n  return value === null || value === undefined ? Number.NEGATIVE_INFINITY : value\n}\n\nexport function formatUsd(value: number): string {\n  const absolute = Math.abs(value)\n  if (absolute >= 1e9) return '$' + (value / 1e9).toFixed(2) + 'B'\n  if (absolute >= 1e6) return '$' + (value / 1e6).toFixed(2) + 'M'\n  if (absolute >= 1e3) return '$' + (value / 1e3).toFixed(1) + 'K'\n  return '$' + value.toFixed(2)\n}\n\nexport function formatPrice(value: number): string {\n  const digits = value >= 100 ? 2 : value >= 1 ? 3 : value >= 0.01 ? 5 : 7\n  return '$' + value.toLocaleString('en-US', {\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n  })\n}\n\nexport function formatNumber(value: number): string {\n  return Math.round(value).toLocaleString('en-US')\n}\n\n/** Formats a value that the ETL may not have, e.g. holder counts. */\nexport function formatMaybe(value: number | null | undefined): string {\n  return value === null || value === undefined ? '—' : formatNumber(value)\n}\n\nexport function formatCompact(value: number): string {\n  const absolute = Math.abs(value)\n  if (absolute >= 1e6) return (value / 1e6).toFixed(2) + 'M'\n  if (absolute >= 1e3) return (value / 1e3).toFixed(1) + 'K'\n  return Math.round(value).toLocaleString('en-US')\n}\n\nexport function formatChange(value: number): string {\n  return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'\n}\n\nexport function tokenHue(symbol: string): string {\n  return 'oklch(0.72 0.15 ' + (hash32(symbol) % 360) + ')'\n}\n\nexport function initials(symbol: string): string {\n  return symbol.replace(/[^A-Z0-9]/gi, '').slice(0, 2).toUpperCase()\n}\n\nexport function shortAddress(value: string): string {\n  return value.slice(0, 6) + '…' + value.slice(-4)\n}\n\nexport function timeAgo(timestamp: number, now = Date.now()): string {\n  if (!timestamp) return '—'\n  const seconds = (now - timestamp) / 1000\n  if (seconds < 60) return Math.max(1, Math.floor(seconds)) + 's ago'\n  if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'\n  if (seconds < 86_400) return Math.floor(seconds / 3600) + 'h ago'\n  return Math.floor(seconds / 86_400) + 'd ago'\n}\n\n/**\n * The pair's primary pool object — the API's own choice (deepest, ties broken\n * by 24h volume then age), resolved against the pool list. `undefined` on a\n * snapshot that predates `primaryPool`, or when the named pool is not in the\n * list; callers pick their own fallback (`marketAgePool`, or none at all).\n */\nexport function primaryPool(pair: Pair): Pool | undefined {\n  const chosen = pair.primaryPool\n  return chosen ? pair.pools.find((pool) => pool.addr === chosen.addr) : undefined\n}\n\n/** Pool used for market age. Unknown liquidity cannot rank multiple pools. */\nexport function marketAgePool(pools: Pool[]): Pool | undefined {\n  if (pools.length === 1) return pools[0]\n  return pools.reduce<Pool | undefined>((best, pool) => {\n    if (pool.tvl === null) return best\n    if (!best || pool.tvl > best.tvl!\n      || (pool.tvl === best.tvl && pool.addr < best.addr)) return pool\n    return best\n  }, undefined)\n}\n\n/** Highest-volume available pair per stock contract, with stable ties. */\nexport function stockLeaders(pairs: Pair[]): Map<string, Pair> {\n  const leaders = new Map<string, Pair>()\n  for (const pair of pairs) {\n    if (pair.vol === null || !Number.isFinite(pair.vol)) continue\n    const stockAddress = pair.saddr.toLowerCase()\n    const leader = leaders.get(stockAddress)\n    if (!leader || pair.vol > leader.vol!\n      || (pair.vol === leader.vol && pair.maddr.toLowerCase() < leader.maddr.toLowerCase())) {\n      leaders.set(stockAddress, pair)\n    }\n  }\n  return leaders\n}\n"]}