import type { InstrumentHint, NormalizedObservation, ObservationType, RealtimeStatus, RightsStatus, TimestampTrust } from "@market-atlas/market-model"; import { cryptoHint, type TickerFields } from "./crypto.js"; import { conventionalPair, fxHint } from "../ecb-frankfurter/index.js"; export const FIAT = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "NZD", "SGD", "HKD", "BRL", "MXN", "TRY", "ZAR", "ARS", "AED", "PLN", "SEK", "NOK", "DKK", "CZK", "HUF", "RON", "UAH", "INR", "KRW", "CNY"]); export const STABLES = new Set(["USDT", "USDC", "USD1", "FDUSD", "TUSD", "DAI"]); export interface PairPlan { /** Instrument hint to resolve/create. */ hint: InstrumentHint; /** Nature of the LAST_PRICE value. */ type: ObservationType; /** Multiply venue prices by -1 exponent: when true, canonical value = 1 / venue price. */ invert: boolean; currency: string; } /** * Decide what a venue pair *means* for Market Atlas: * - fiat/fiat (Kraken EUR/USD, Bitstamp EURUSD) → FOREX instrument, TRADE (a real fiat market) * - stable/fiat or fiat/stable (USDT-EUR, EURUSDT) → FOREX instrument, STABLECOIN_PROXY (USDT ≈ USD) * - anything else → CRYPTO instrument, TRADE */ export function planPair(base: string, quote: string, exchangeId: string): PairPlan { const b = base.toUpperCase(); const q = quote.toUpperCase(); if (FIAT.has(b) && FIAT.has(q)) { const [cb, cq] = conventionalPair(b, q); return { hint: fxHint(cb, cq), type: "TRADE", invert: cb !== b, currency: cq }; } const bStable = STABLES.has(b); const qStable = STABLES.has(q); if ((bStable && FIAT.has(q) && q !== "USD") || (qStable && FIAT.has(b) && b !== "USD")) { const fb = bStable ? "USD" : b; const fq = qStable ? "USD" : q; const [cb, cq] = conventionalPair(fb, fq); return { hint: fxHint(cb, cq), type: "STABLECOIN_PROXY", invert: cb !== fb, currency: cq }; } return { hint: cryptoHint(b, q, exchangeId), type: "TRADE", invert: false, currency: q }; } const num = (v: unknown): number | null => { if (v == null || v === "") return null; const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) ? n : null; }; /** * Venue ticker → observations for whatever the pair means (crypto, live FX, stablecoin proxy). * Inverted pairs swap bid/ask and drop venue-specific volumes (they would be in the wrong unit). */ export function pairObservations( symbol: string, base: string, quote: string, exchangeId: string, f: TickerFields, meta: { sourceTimestamp: number | null; timestampTrust: TimestampTrust; sequence?: number | string | null; rightsStatus: RightsStatus; realtimeStatus: RealtimeStatus }, ): NormalizedObservation[] { const plan = planPair(base, quote, exchangeId); const out: NormalizedObservation[] = []; const conv = (v: number) => (plan.invert ? 1 / v : v); const push = (field: NormalizedObservation["field"], v: unknown, type: ObservationType) => { const n = num(v); if (n == null) return; if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return; const value = field === "VOLUME" || field === "BID_SIZE" || field === "ASK_SIZE" ? n : Number(conv(n).toPrecision(10)); out.push({ symbol, instrumentHint: plan.hint, field, value, currency: plan.currency, observationType: type, sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus, meta: plan.type === "STABLECOIN_PROXY" ? { venue_pair: `${base}/${quote}`, proxy: true } : undefined }); }; const quoteType: ObservationType = plan.type === "STABLECOIN_PROXY" ? "STABLECOIN_PROXY" : "QUOTE"; push("LAST_PRICE", f.last, plan.type); if (plan.invert) { // 1/x flips the order: venue ask becomes our bid. push("BID", f.ask, quoteType); push("ASK", f.bid, quoteType); push("HIGH", f.low, plan.type); push("LOW", f.high, plan.type); push("OPEN", f.open, plan.type); } else { push("BID", f.bid, quoteType); push("ASK", f.ask, quoteType); push("HIGH", f.high, plan.type); push("LOW", f.low, plan.type); push("OPEN", f.open, plan.type); push("BID_SIZE", f.bidSize, quoteType); push("ASK_SIZE", f.askSize, quoteType); if (plan.type !== "STABLECOIN_PROXY") push("VOLUME", f.volume, plan.type); if (plan.type === "TRADE" && plan.hint.assetClass === "CRYPTO") push("VWAP", f.vwap, plan.type); } return out; } /** Split a venue symbol using an explicit separator or a list of known quote currencies. */ export function splitVenueSymbol(symbol: string, sep: string | null, quotes: string[] = ["USDT", "USDC", "USD", "EUR", "GBP", "JPY", "BTC", "ETH", "TRY", "BRL", "MXN", "ZAR", "ARS", "AUD", "SGD", "AED", "CAD", "CHF"]): [string, string] | null { if (sep) { const [b, q] = symbol.split(sep); return b && q ? [b.toUpperCase(), q.toUpperCase()] : null; } const s = symbol.toUpperCase(); for (const q of quotes.sort((a, b) => b.length - a.length)) if (s.endsWith(q) && s.length > q.length) return [s.slice(0, -q.length), q]; return null; }