src/orders/rules.test.ts
v0.1.0 · 5.1 KB
import { describe, expect, it } from 'vitest';
import { createQuant } from '../numbers/quant.js';
import { decodeWriteAnswer } from '../signer/decode.js';
import { signature } from '../testing/fixtures.js';
import {
cancelOutcomeFor,
chunkCancelIds,
clampIocToBand,
createCancelBatcher,
createIntentIds,
iocLimitTicks,
planReduceOnly,
resolveIocFill,
} from './rules.js';
const q = createQuant({ tickSize: 100n, baseLotsDecimals: 4 });
const cpi = { priceInTicks: 0n, seq: 0n, basePosted: 0n, filledBaseLots: 2n, filledQuoteLots: 300n };
describe('order rules', () => {
it('uses caller-defined identity prefixes and never truncates the unique suffix', () => {
const gen = createIntentIds({ prefix: 'example' });
const ids = new Set(Array.from({ length: 100 }, () => gen.next('order', 'BTC')));
expect(ids.size).toBe(100);
for (const id of ids) expect(id).toMatch(/^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/);
expect(() => createIntentIds({ prefix: '' })).toThrow();
});
it('rounds caps in the protective direction', () => {
expect(iocLimitTicks(q, 800.5, true)).toBe(800n);
expect(iocLimitTicks(q, 800.5, false)).toBe(801n);
});
it('never weakens the caller cap when clamping an optional band', () => {
expect(clampIocToBand(q, 25n, true, { min: 10, max: 20 })).toBe(20n);
expect(clampIocToBand(q, 5n, true, { min: 10, max: 20 })).toBe(0n);
expect(clampIocToBand(q, 5n, false, { min: 10, max: 20 })).toBe(10n);
expect(clampIocToBand(q, 25n, false, { min: 10, max: 20 })).toBe(0n);
});
it('never sends reduce-only without a reducible position', () => {
expect(planReduceOnly({ positionLots: 0n, requestedLots: 1n, isBuy: false, fullClose: true }).send).toBe(false);
expect(planReduceOnly({ positionLots: 3n, requestedLots: 1n, isBuy: true, fullClose: false }).send).toBe(false);
});
it('full close takes exact position lots and partial close clamps', () => {
expect(planReduceOnly({ positionLots: 3n, requestedLots: 0n, isBuy: false, fullClose: true })).toEqual({
send: true,
lots: 3n,
});
expect(planReduceOnly({ positionLots: -3n, requestedLots: 10n, isBuy: true, fullClose: false })).toEqual({
send: true,
lots: 3n,
});
});
it('uses the smaller of transaction evidence and position delta', () => {
expect(
resolveIocFill({ cpi, sentLots: 3n, isBuy: true, beforeLots: 0n, read: { lots: 1n, slot: 10 }, txSlot: 10 }),
).toEqual({ filledLots: 1n });
expect(
resolveIocFill({ cpi, sentLots: 1n, isBuy: true, beforeLots: 0n, read: { lots: 3n, slot: 10 }, txSlot: 10 }),
).toEqual({ filledLots: 1n });
});
it('accepts delta-only at exactly the transaction slot', () => {
expect(
resolveIocFill({
cpi: null,
sentLots: 3n,
isBuy: true,
beforeLots: 0n,
read: { lots: 1n, slot: 10 },
txSlot: 10,
}),
).toEqual({ filledLots: 1n });
expect(
resolveIocFill({
cpi: null,
sentLots: 3n,
isBuy: true,
beforeLots: 0n,
read: { lots: 1n, slot: 11 },
txSlot: 10,
}),
).toHaveProperty('rejected', true);
});
it('rejects an old read and a zero reported transaction fill', () => {
expect(
resolveIocFill({ cpi, sentLots: 3n, isBuy: true, beforeLots: 0n, read: { lots: 1n, slot: 9 }, txSlot: 10 }),
).toHaveProperty('rejected', true);
expect(
resolveIocFill({
cpi: { ...cpi, filledBaseLots: 0n },
sentLots: 3n,
isBuy: true,
beforeLots: 0n,
read: { lots: 1n, slot: 10 },
txSlot: 10,
}),
).toHaveProperty('rejected', true);
});
it('does not count cold/no-op or not-found cancels', () => {
const id = { symbol: 'BTC', priceTicks: '800', seq: '3' };
expect(
cancelOutcomeFor(
decodeWriteAnswer({ status: 'confirmed', signature, slot: 10, cancel: { effective: false, notFound: [] } }),
id,
),
).toBe(false);
expect(
cancelOutcomeFor(
decodeWriteAnswer({
status: 'confirmed',
signature,
slot: 10,
cancel: { effective: true, notFound: [{ priceInTicks: '800', seq: '3', reason: 'OrderNotFound' }] },
}),
id,
),
).toBe(false);
expect(
cancelOutcomeFor(
decodeWriteAnswer({ status: 'confirmed', signature, slot: 10, cancel: { effective: true, notFound: [] } }),
id,
),
).toBe(true);
});
it('batches exact IDs and learns a smaller maximum only on definite size/CU failure', async () => {
expect(
chunkCancelIds(
Array.from({ length: 40 }, (_, i) => i),
30,
).map((c) => c.length),
).toEqual([30, 10]);
const sizes: number[] = [];
const b = createCancelBatcher<number>({
max: 6,
send: async (ids) => {
sizes.push(ids.length);
return ids.length > 3 ? { ok: false, reason: 'ComputationalBudgetExceeded' } : { ok: true };
},
});
const r = await b.cancel([1, 2, 3, 4, 5, 6, 7]);
expect(sizes).toEqual([6, 3, 3, 1]);
expect(r.every((x) => x.result.ok)).toBe(true);
expect(b.max()).toBe(3);
});
});