src/markets/parse.ts
v0.1.0 · 5.7 KB
export class PhoenixMetaError extends Error {
override name = 'PhoenixMetaError';
}
export const MARKET_STATUSES = ['uninitialized', 'active', 'postOnly', 'paused', 'closed', 'tombstoned'] as const;
export interface PhoenixMarket {
symbol: string;
assetId: number;
status: string;
tickSize: bigint;
baseLotsDecimals: number;
isolatedOnly: boolean;
maxLeverage: number;
afterHours: boolean | null;
executionBand: {
min: number;
max: number;
} | null;
bandIndexExpiresAt: number | null;
nextTransitionAt: number | null;
}
const SYMBOL_RE = /^[A-Za-z0-9._-]{1,32}$/;
function intLike(v: unknown): number | null {
if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
if (typeof v === 'string' && /^-?\d{1,15}$/.test(v)) return Number(v);
return null;
}
function tokenAmountUsd(v: unknown): number | null {
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
const t = v as {
value?: unknown;
decimals?: unknown;
ui?: unknown;
};
const val = intLike(t.value);
const dec = intLike(t.decimals);
const n =
val !== null && dec !== null && dec >= 0 && dec <= 18
? val / 10 ** dec
: typeof t.ui === 'string'
? Number(t.ui)
: NaN;
return Number.isFinite(n) && n >= 0 ? n : null;
}
function decodeExecutionBand(b: unknown): {
min: number;
max: number;
} | null {
if (!b || typeof b !== 'object' || Array.isArray(b)) return null;
const r = b as {
min?: unknown;
max?: unknown;
lower?: unknown;
upper?: unknown;
};
const min = tokenAmountUsd(r.min ?? r.lower);
const max = tokenAmountUsd(r.max ?? r.upper);
return min !== null && max !== null && max > 0 && min < max ? { min, max } : null;
}
function epochMs(v: unknown): number | null {
const n = intLike(v);
if (n === null) return null;
if (n >= 1e9 && n < 1e11) return n * 1000;
if (n >= 1e12 && n < 1e14) return n;
return null;
}
function decodeNextTransition(meta: unknown): number | null {
const cal =
meta && typeof meta === 'object'
? (
meta as {
calendar?: unknown;
}
).calendar
: null;
const s =
cal && typeof cal === 'object'
? (
cal as {
nextMarketTransitionUtc?: unknown;
}
).nextMarketTransitionUtc
: null;
if (typeof s !== 'string' || s.length > 40) return null;
const t = Date.parse(s);
return Number.isFinite(t) && t > 0 ? t : null;
}
export function decodeMarkets(payload: unknown): Map<string, PhoenixMarket> {
const arr = Array.isArray(payload)
? payload
: (
payload as {
markets?: unknown;
} | null
)?.markets;
if (!Array.isArray(arr)) throw new PhoenixMetaError('Market list must be a nonempty array');
const out = new Map<string, PhoenixMarket>();
const seenIds = new Set<number>();
for (const raw of arr as unknown[]) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
throw new PhoenixMetaError('Market record must be an object');
const m = raw as Record<string, unknown>;
const symbol = typeof m.symbol === 'string' ? m.symbol : '';
if (!SYMBOL_RE.test(symbol)) throw new PhoenixMetaError('Invalid market symbol');
const assetId = intLike(m.assetId);
if (assetId === null || assetId < 0) throw new PhoenixMetaError('assetId must be a nonnegative safe integer');
const status = typeof m.marketStatus === 'string' ? m.marketStatus : '';
if (!status) throw new PhoenixMetaError('marketStatus missing');
const tick = intLike(m.tickSize);
if (tick === null || tick <= 0) throw new PhoenixMetaError('tickSize must be a positive safe integer');
const bld = intLike(m.baseLotsDecimals);
if (bld === null || bld < -12 || bld > 18)
throw new PhoenixMetaError('baseLotsDecimals must be an integer in -12..18');
if (typeof m.isolatedOnly !== 'boolean') throw new PhoenixMetaError('isolatedOnly must be boolean');
const tiers = m.leverageTiers;
const lev0 =
Array.isArray(tiers) && tiers.length > 0
? Number(
(
tiers[0] as {
maxLeverage?: unknown;
}
)?.maxLeverage,
)
: NaN;
if (!(Number.isFinite(lev0) && lev0 > 0))
throw new PhoenixMetaError('First leverage tier needs a positive maxLeverage');
const cm = m.commodityMetadata;
const afterHours =
cm &&
typeof cm === 'object' &&
typeof (
cm as {
isAfterHours?: unknown;
}
).isAfterHours === 'boolean'
? (
cm as {
isAfterHours: boolean;
}
).isAfterHours
: null;
const executionBand =
cm && typeof cm === 'object'
? decodeExecutionBand(
(
cm as {
executionPriceBand?: unknown;
}
).executionPriceBand,
)
: null;
const bandIndexExpiresAt =
cm && typeof cm === 'object'
? epochMs(
(
cm as {
lastIndexExpiryTimestamp?: unknown;
}
).lastIndexExpiryTimestamp,
)
: null;
const nextTransitionAt = decodeNextTransition(m.metadata);
if (out.has(symbol)) throw new PhoenixMetaError('Duplicate market symbol');
if (seenIds.has(assetId)) throw new PhoenixMetaError('Duplicate market assetId');
seenIds.add(assetId);
out.set(symbol, {
symbol,
assetId,
status,
tickSize: BigInt(tick),
baseLotsDecimals: bld,
isolatedOnly: m.isolatedOnly,
maxLeverage: Math.max(1, Math.floor(lev0)),
afterHours,
executionBand,
bandIndexExpiresAt,
nextTransitionAt,
});
}
if (out.size === 0) throw new PhoenixMetaError('No markets returned');
return out;
}