## Purpose

Provides per-asset metadata (oracle URLs, localized descriptions, news-classifier keywords, brand colors, category normalization) for tradeable assets on Kinetiq Markets. Consumed by both the Markets app and Pulse services.

## Public API

- `./get-asset-metadata` — `getAssetMetadata({ symbol })` returns `{ oracleSymbol, oracleUrl }` for a given asset symbol (fields are `undefined` for unknown symbols)
- `./get-asset-name` — `getAssetName({ symbol })` returns the English `description_short` for the asset DESCRIPTION — what the thing IS (e.g., `'NVIDIA'` for `NVDA`, `'Top 100 US tech stocks'` for `USTECH`). Backend / non-localized callers (Pulse briefs, universe seeding) should use this. **Keyed by BARE lowercase symbol, and it takes no `dex`** — see Patterns. For what the trade app CALLS a market (`'QQQ'` for `USTECH`), use `./to-display-symbol` instead.
- `./to-display-symbol` — `toDisplaySymbol({ annotationDisplayName?, hyperliquidSymbol, symbol })` resolves what a perp listing is CALLED: curated `PERP_SYMBOL_OVERRIDES` (by raw HL name, then by clean symbol) → the exchange's live `perpConciseAnnotations` `displayName` → the clean symbol. The single source of truth, shared by `normalizePerpsMeta` (trade app) and pulse-shared's `buildTickerUniverse` (Pulse) so the two cannot name one instrument differently. A display name is for RENDERING only — it is not unique (`xyz:VIX` is annotated `VVV`, itself a real symbol), so never parse one back to an asset.
- `./get-token-icon-url` — `getTokenIconUrl(symbol)` builds the `/icons/<SYMBOL>.svg` token icon URL
- `./has-news-keywords` — `hasNewsKeywords({ dex, symbol })` returns true iff the asset has an entry in `assetNewsKeywords`. Used by Pulse's news-tracked allowlist gate as the "tracked by Markets" predicate (HL lists hundreds of perps, but only ~63 have curated keyword coverage).
- `./normalize-asset-category` — `normalizeAssetCategory({ raw, isMainHyperliquid? })` maps Hyperliquid's free-form `perpConciseAnnotations` category string to the narrow `AssetCategory` union (`'commodity' | 'crypto' | 'currency' | 'index' | 'stock'`). Unknown values fall back to `'stock'`; `isMainHyperliquid: true` short-circuits to `'crypto'` (main-HL listings aren't annotated).
- `./constants/asset-colors` — `ASSET_COLORS` map (symbol → hex) and `DEFAULT_CHART_COLORS` fallback palette. Used by Markets stats charts and any per-asset color rendering.
- `./constants/asset-news-keywords` — raw `assetNewsKeywords` map (keyed `${dex}:${symbol.toLowerCase()}` for HIP-3 or `${symbol.toLowerCase()}` for main HL). Use `hasNewsKeywords` for the membership predicate.
- `./lib/asset-key` — `toAssetKey({ dex, symbol })` canonicalizes the lookup key used by `asset-metadata`, `asset-news-keywords`, and related maps
- `./asset` — `SpotAsset`, `PerpsAsset`, `Asset` types (the normalized, display-ready asset shapes used across Markets)
- `./asset-indices` — `SPOT_INDEX_OFFSET`, `HIP3_ASSET_INDEX_BASE`, `HIP3_DEX_INDEX_MULTIPLIER` (Hyperliquid asset-ID encoding constants)
- `./parse-perps-hyperliquid-symbol` — `parsePerpsHyperliquidSymbol({ hyperliquidSymbol })` splits a `dex:symbol` perp name into `{ dex, symbol }`
- `./to-trade-url-symbol` — `toTradeUrlSymbol({ dex?, symbol })` is the SINGLE definition of the `/trade/…` url name: the DISPLAY name, lowercased, with any `dex:` prefix stripped (`mkts:US500` → `us500`, `WTIOIL` → `wtioil`). Idempotent, so it is safe to apply to an already-canonical `tradeUrlSymbol` — `ROUTES.TRADE_SYMBOL` does exactly that, and passes no `dex`. Urls carry no dex except for the listings in `PREFIXED_URL_SYMBOLS`, which are the ones whose ticker is live on a SECOND dex we surface: SanDisk trades on both `xyz` and `io`, so `xyz` keeps `/trade/sndk` and the `io` listing is `/trade/io:sndk`. Pass `dex` from anything normalizing a universe; add a listing to that set (never a blanket prefix rule) when a new collision ships.
- `./get-dex-name-from-meta` — `getDexNameFromMeta({ meta })` detects a DEX name from a perp meta's universe item prefixes (`''` for main HL)
- `./normalize-perps-meta` — `normalizePerpsMeta({ perpsMeta, perpsAnnotations? })` returns `NormalizedPerpsMeta` (each universe item gets a normalized `asset`). Display symbols are resolved by `./to-display-symbol` (shared with pulse-shared's `buildTickerUniverse`): `PERP_SYMBOL_OVERRIDES` (static, wins) over the live `perpsAnnotations` over the raw cleaned name, with a blank annotation treated as no name rather than as the empty string.
- `./perp-symbol-overrides` — `PERP_SYMBOL_OVERRIDES` curated static perp display-symbol map; an additive layer over the live `perpConciseAnnotations` API, not a replacement

## Internal Structure

```
src/
  _/
    asset-metadata.ts          # Static metadata map (dex:symbol -> { oracleSymbol, oracleUrl })
    types.ts                   # LocaleMessages type
  constants/
    asset-colors.ts            # ASSET_COLORS + DEFAULT_CHART_COLORS
    asset-news-keywords.ts     # assetNewsKeywords (dex:symbol -> keyword list)
  icons/
    <SYMBOL>.svg               # Per-asset SVG token icons (served at /icons/<SYMBOL>.svg)
  lib/
    asset-key.ts               # toAssetKey helper for `${dex}:${symbol}` lookups (dex-keyed maps ONLY)
  get-asset-metadata.ts
  get-asset-name.ts            # English description_short lookup, BARE-symbol keyed (backend / non-localized)
  get-token-icon-url.ts
  to-display-symbol.ts         # What a listing is CALLED — shared by normalizePerpsMeta + Pulse's buildTickerUniverse
  normalize-asset-category.ts  # normalizeAssetCategory + AssetCategory union
scripts/
  fetch-hip3-icons.ts          # `pnpm icons:fetch` — downloads HIP-3 SVG icons from Hyperliquid
messages/
  {locale}.json                # 17 locale files with per-asset descriptions
```

## Patterns

- Pure data package — no React, no hooks, no side effects
- Symbol lookups are case-insensitive (lowercased before map access)
- Missing symbols return `undefined` fields rather than throwing
- Locale messages are statically imported (not loaded at runtime)
- Per-asset lookup keys come in TWO families, and using the wrong one fails SILENTLY (the lookup just returns `undefined` and callers fall back to the raw symbol). Check which map you are reading before you build a key:
  - **Dex-keyed** — `src/_/asset-metadata.ts` (oracle) and `constants/asset-news-keywords.ts`: `${dex}:${symbol.toLowerCase()}` for HIP-3 listings, `${symbol.toLowerCase()}` for main HL. Use `toAssetKey` to canonicalize. Its only production caller is `hasNewsKeywords`.
  - **Bare-keyed** — the locale `messages/*.json` `asset-metadata` map read by `getAssetName`: `${symbol.toLowerCase()}`, no dex dimension at all (all 121 entries, zero colons; `apps/markets` reads it the same way in five places). **Do not apply `toAssetKey` to it.** Doing so is what made `getAssetName` return `undefined` for every HIP-3 equity and index — so briefs said "NVDA reports" instead of "NVIDIA reports" — while main-HL crypto kept working because its keys are bare anyway.

## Adding New Exports

1. Create source file in `src/`
2. Add subpath export to `package.json` under `"exports"`

## Adding a New Asset

1. Add entry in `src/_/asset-metadata.ts` keyed by `${dex}:${symbol.toLowerCase()}` (e.g. `'mkts:nvda'`)
2. Add `description_medium` and `description_short` to each locale JSON under `messages/`
3. If Pulse should classify news against this asset, add a keyword list to `src/constants/asset-news-keywords.ts`
