Skip to content
markpaper

src/leverage/index.ts

v0.1.0 · 3.3 KB

Download file
import { decToNumber, parseDec } from '../numbers/index.js';
import type { RestClient } from '../rest/index.js';
import type { TradeSession } from '../session/index.js';
export function createLeverageClient(options: {
  rest: RestClient;
  session: Pick<TradeSession, 'getUserLeverage' | 'getAvailableLeverageLevels'>;
  accountId: string;
  timeoutMs: number;
  now?: () => number;
  unknownLockMs: number;
  canChange: (symbol: string) => boolean | Promise<boolean>;
}) {
  if (
    !options?.rest ||
    !options.session ||
    !options.accountId ||
    !(options.timeoutMs > 0) ||
    !(options.unknownLockMs > 0) ||
    typeof options.canChange !== 'function'
  )
    throw new TypeError('Required leverage sources and policy');
  const now = options.now ?? Date.now;
  const locks = new Map<string, number>();
  const inflight = new Set<string>();
  const read = () => options.session.getUserLeverage(options.timeoutMs);
  const levels = () => options.session.getAvailableLeverageLevels(options.timeoutMs);
  async function set(
    symbol: string,
    leverage: number,
  ): Promise<{ kind: 'set'; leverage: number } | { kind: 'unknown' } | { kind: 'locked' } | { kind: 'unsupported' }> {
    if (!Number.isFinite(leverage) || leverage <= 0) throw new TypeError('Invalid leverage');
    if (inflight.has(symbol) || now() < (locks.get(symbol) ?? 0)) return { kind: 'locked' };
    inflight.add(symbol);
    let sent = false;
    try {
      if (!(await options.canChange(symbol))) return { kind: 'locked' };
      const available = await levels();
      if (
        !available.some(
          (row) => row.symbol === symbol && decToNumber(parseDec(row.leverage) ?? { mant: 0n, exp: 0 }) === leverage,
        )
      )
        return { kind: 'unsupported' };
      if (!(await options.canChange(symbol))) return { kind: 'locked' };
      locks.set(symbol, now() + options.unknownLockMs);
      sent = true;
      await options.rest.userPost('/user/leverage', options.accountId, { symbol, leverage });
      const actual = await read();
      if (actual.get(symbol) === leverage) {
        locks.delete(symbol);
        return { kind: 'set', leverage };
      }
      return { kind: 'unknown' };
    } catch (error) {
      if (!sent) throw error;
      return { kind: 'unknown' };
    } finally {
      inflight.delete(symbol);
    }
  }
  return { read, levels, set, lockUntil: (symbol: string) => locks.get(symbol) ?? null };
}
/** Changing leverage requires a fresh, complete, flat symbol with no intervening or unknown write. */
export function leverageChangeAllowed(
  state: {
    at: number;
    positions: ReadonlyMap<string, { mant: bigint; exp: number }>;
    orders: readonly { symbol: string }[];
    ordersComplete: boolean;
    unknownSymbols: ReadonlySet<string>;
    lastWriteAt: number;
  },
  symbol: string,
  policy: { now: number; maxAgeMs: number },
): boolean {
  if (!(policy.maxAgeMs > 0)) throw new TypeError('Required exposure freshness policy');
  const quantity = state.positions.get(symbol);
  return (
    state.ordersComplete &&
    policy.now >= state.at &&
    policy.now - state.at <= policy.maxAgeMs &&
    state.lastWriteAt < state.at &&
    !state.unknownSymbols.has(symbol) &&
    (!quantity || quantity.mant === 0n) &&
    !state.orders.some((row) => row.symbol === symbol)
  );
}
All files