src/orders/mids.ts
v0.3.0 · 3.8 KB
// 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);
};
}