src/leverage/index.ts
v0.1.0 · 3.3 KB
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)
);
}