UNPKG

@kinetiq-research/markets-assets

Version:

Asset metadata (descriptions, oracle references) for Kinetiq markets

119 lines (113 loc) • 6.05 kB
import { PERP_SYMBOL_OVERRIDES } from './perp-symbol-overrides' // The one place that decides what a listing is CALLED. // // WHY IT IS ITS OWN MODULE. Two universes are built from the same Hyperliquid // payloads: `normalizePerpsMeta` for the trade app, and Pulse's // `buildTickerUniverse` for the news and brief pipeline. When only the first // resolved a display name, the trade app called the Nasdaq-100 listing `QQQ` // while Pulse briefs called it `USTECH` — one instrument, two names, in the // same product. Both now call this, so the PRECEDENCE cannot drift. One rule // still lives outside it and is called out at the bottom of this comment. // // A DISPLAY NAME IS NOT AN IDENTITY. It is not unique (`xyz:VIX` is annotated // `VVV`, and `VVV` is itself a real symbol) and upstream may change it at any // time. Look one up FROM a symbol to render it; never parse one BACK to decide // which asset was meant. Database rows, `tickers` arrays and trust checks all // key on the symbol. // // THE TRADE URL IS THE EXCEPTION, and it is not ours to change: `/trade/…` // resolves the DISPLAY name (`/trade/wtioil`, not `/trade/cl`) — see // `to-trade-url-symbol.ts`. So an upstream annotation edit does move a route // key. That predates this module; it is recorded here because "everything uses // the symbol" is the obvious summary and it is false. // // THE SHADOW RULE IS PULSE-SIDE ONLY. `buildTickerUniverse` refuses a name that // belongs to another symbol; `normalizePerpsMeta` does not, because that would // change live trade-app labels and route keys. So for a shadowed listing the // two surfaces still disagree — `xyz:VIX` reads `VIX` in a Pulse brief and // `VVV` on the trade chip. Sharing this function removes the drift for every // OTHER listing, which is most of them; it does not remove it for that case. const toOverride = (key: string): string | undefined => // `Object.hasOwn`, not a bare index. `PERP_SYMBOL_OVERRIDES` is an object // literal typed `Record<string, string>`, so a symbol named `constructor` // would read a FUNCTION off the prototype chain and `??` would happily // return it. The typechecker cannot see this — the index signature types the // read as `string`. Object.hasOwn(PERP_SYMBOL_OVERRIDES, key) ? PERP_SYMBOL_OVERRIDES[key] : undefined // A display name is a MARKET NAME, and the live annotation is free text typed // by whoever deployed the listing — including HIP-3 dexes we do not operate. // It reaches an LLM prompt, a Telegram `<code>` block and a trade URL, so the // shape is constrained here, once, rather than at each of those sinks: // // - single line, no spaces — a newline inside the Telegram snapshot table // draws a whole fabricated price row, and a blank-padded name (`' '`) // renders a row with a price and no market at all; // - ASCII letters and digits, plus exactly the two punctuation marks the // live data uses: `&` (`S&P500`) and `.` (`BTC.D`). Checked against all // 108 annotated listings — nothing else appears. Restricting to ASCII // also stops a Cyrillic homoglyph impersonating another listing's ticker; // - bounded length, as a sanity limit on a free-text field. // // NO `/`, DELIBERATELY. A display name becomes a trade URL path segment // (`to-trade-url-symbol.ts`), and `ROUTES.TRADE_SYMBOL` interpolates it // without encoding — so `a/../../vault` would render an href the browser // resolves to `/vault`. Dot segments need a slash to be segments at all, so // refusing `/` removes the whole class rather than trying to spot `..`. // // AND NOTE WHY THE ORDER MATTERS: normalization runs FIRST, so the charset // test sees the normalized string. That is not merely tidy — NFKC MAPS some // characters INTO the allowed set (U+2105 `℅` becomes `c/o`), so testing the // raw input would let a rejected character launder itself into a slash. // // Anything else is upstream failing to name the market, so the symbol wins. // Rejecting is always safe: the symbol is what we showed before any of this. const MAX_DISPLAY_LENGTH = 16 const DISPLAY_SAFE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9&.]*$/ // Exported for `to-spot-display-symbol.ts`: spot token annotations reach the // same sinks (trade URLs, LLM prompts, Telegram tables), so they pass the // same gate. Keep the constraints in this one place. export const toValidDisplay = (candidate: string): string | undefined => { // NFKC first: it folds the compatibility forms that render like ASCII but // are not, so the charset test below sees what a reader would see. const normalized = candidate.normalize('NFKC').trim() return normalized.length > 0 && normalized.length <= MAX_DISPLAY_LENGTH && DISPLAY_SAFE_PATTERN.test(normalized) ? normalized : undefined } /** * Resolve the name to show for a perp listing. * * Precedence: curated override (by raw Hyperliquid name, then by clean symbol) * → the exchange's own live annotation → the clean symbol itself. The result is * always a non-empty string, so callers render it unconditionally and compare * it against `symbol` when they want to know whether it says anything new. * * A live annotation that is not a plausible market name is refused and the * symbol is used instead — see `toValidDisplay`. Curated overrides skip that * check because we write them. */ export const toDisplaySymbol = ({ annotationDisplayName, hyperliquidSymbol, symbol, }: { /** `perpConciseAnnotations`' `displayName` for this listing, if any. */ readonly annotationDisplayName?: string | undefined /** The raw universe name, dex prefix included (`mkts:USTECH`). */ readonly hyperliquidSymbol: string /** The cleaned, unprefixed symbol (`USTECH`). */ readonly symbol: string }): string => { const override = toOverride(hyperliquidSymbol) ?? toOverride(symbol) if (override !== undefined) { return override } if (annotationDisplayName === undefined) { return symbol } return toValidDisplay(annotationDisplayName) ?? symbol }