Skip to content
markpaper

src/orders/mids.ts

v0.3.0 · 3.8 KB

Download file
// Mid price lookup for "market" orders.

import type { AssetInfo } from '../assets/index.js';
import { stripDexPrefix } from '../assets/index.js';
import { normalizeDecimal } from '../format/index.js';
import type { InfoCallOptions, InfoRequester } from '../transport/types.js';

/** `allMids` is a light /info request. */
export const ALL_MIDS_WEIGHT = 2;

/** Options for /info reads made by this module. */
export interface OrdersInfoOptions {
  signal?: AbortSignal;
  timeoutMs?: number;
}

/** @internal */
export function infoOptions(opts: OrdersInfoOptions | undefined, weight: number): InfoCallOptions {
  const out: InfoCallOptions = { weight };
  if (opts?.signal) out.signal = opts.signal;
  if (opts?.timeoutMs !== undefined) out.timeoutMs = opts.timeoutMs;
  return out;
}

/** @internal Strict mid validation: a positive finite price not above MAX_SAFE_INTEGER, else `null`. */
export function parseMid(value: unknown): string | null {
  if (typeof value !== 'string' && typeof value !== 'number') return null;
  if (typeof value === 'string' && value.trim() === '') return null;
  let text: string;
  try {
    text = normalizeDecimal(value);
  } catch {
    return null;
  }
  const n = Number(text);
  if (!Number.isFinite(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER) return null;
  return text;
}

/**
 * Reads the mid price of one asset from `allMids` of its dex (weight 2).
 *
 * - HIP-3 mids are only in `{ type: 'allMids', dex }`; the main-dex answer has no `xyz:` keys.
 *   Why: an `allMids` without `dex` for an xyz coin reads as "no mid" and the order is skipped
 *   silently. Keys are looked up as `xyz:TSLA` and, as a fallback, bare `TSLA`.
 * - A missing, non-positive, non-finite or absurd (`> MAX_SAFE_INTEGER`) value gives `null`.
 *   Why: an `Infinity` mid that passes through can leave a position without protection. An answer that
 *   is not an object also gives `null` (degraded read).
 * - Transport errors are rethrown.
 *
 * Fetch the map once per tick per dex when pricing many coins; {@link placeOrders} does that.
 */
export async function fetchMid(
  info: InfoRequester,
  asset: Pick<AssetInfo, 'coin' | 'dex'>,
  opts: OrdersInfoOptions = {},
): Promise<string | null> {
  const mids = await fetchAllMids(info, asset.dex, opts);
  return mids === null ? null : pickMid(mids, asset.coin);
}

/** @internal */
export async function fetchAllMids(
  info: InfoRequester,
  dex: string,
  opts: OrdersInfoOptions | undefined,
): Promise<Record<string, unknown> | null> {
  const body = dex ? { type: 'allMids', dex } : { type: 'allMids' };
  const raw = await info<unknown>(body, infoOptions(opts, ALL_MIDS_WEIGHT));
  return typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
}

/** @internal */
export function pickMid(mids: Record<string, unknown>, coin: string): string | null {
  const own = Object.prototype.hasOwnProperty;
  if (own.call(mids, coin)) return parseMid(mids[coin]);
  const bare = stripDexPrefix(coin);
  return bare !== coin && own.call(mids, bare) ? parseMid(mids[bare]) : null;
}

/** @internal Per-call mid source with one `allMids` request per dex (single-flight). */
export function createMidCache(
  info: InfoRequester | undefined,
  opts: OrdersInfoOptions | undefined,
): ((asset: Pick<AssetInfo, 'coin' | 'dex'>) => Promise<string | null>) | undefined {
  if (!info) return undefined;
  const byDex = new Map<string, Promise<Record<string, unknown> | null>>();
  return async (asset) => {
    let p = byDex.get(asset.dex);
    if (!p) {
      p = fetchAllMids(info, asset.dex, opts);
      byDex.set(asset.dex, p);
      // A failed read is not cached: the next order of the same dex asks again.
      p.catch(() => byDex.delete(asset.dex));
    }
    const mids = await p;
    return mids === null ? null : pickMid(mids, asset.coin);
  };
}
All files