Skip to content
markpaper

src/format/size.test.ts

v0.3.0 · 6.7 KB

Download file
import { describe, expect, it } from 'vitest';
import { HlFormatError } from './decimal.js';
import {
  assertSzDecimals,
  formatSize,
  hasHiddenRemainder,
  isFlatPosition,
  isFullyFilled,
  isValidSize,
  positionMatchesExpected,
  sizeFromNotional,
  szStep,
} from './size.js';

describe('szStep / assertSzDecimals', () => {
  it('returns the lot 10^-szDecimals as a canonical string', () => {
    expect(szStep(0)).toBe('1');
    expect(szStep(2)).toBe('0.01');
    expect(szStep(8)).toBe('0.00000001');
  });

  it('rejects szDecimals outside integer 0..8 (broken meta)', () => {
    for (const bad of [-1, 9, 1.5, NaN, Infinity]) {
      expect(() => szStep(bad)).toThrow(HlFormatError);
      expect(() => assertSzDecimals(bad)).toThrow(HlFormatError);
    }
  });
});

describe('formatSize', () => {
  it('floors 0.29 at szDecimals=2 to 0.29 (epsilon rule)', () => {
    expect(formatSize(0.29, 2)).toBe('0.29');
    expect(formatSize(0.29 * 100, 0)).toBe('29');
  });

  it('absorbs float artifacts from arithmetic', () => {
    expect(Math.floor(0.57 * 100) / 100).toBe(0.56); // the naive bug: 56.99999999999999
    expect(formatSize(0.57, 2)).toBe('0.57');
    expect(formatSize(0.3 - 0.1, 2)).toBe('0.2'); // 0.19999999999999998
    expect(formatSize(3.3 * 0.9, 2)).toBe('2.97'); // 2.9699999999999998
    expect(formatSize(0.1 + 0.2, 2)).toBe('0.3');
    expect(formatSize(0.1 + 0.2, 2, 'ceil')).toBe('0.3');
  });

  it('floor vs ceil on a partial reduce: 0.2 at szDecimals=0', () => {
    expect(formatSize(0.2, 0)).toBe('0');
    expect(formatSize(0.2, 0, 'ceil')).toBe('1');
  });

  it('ceil for a full close covers the position (0.017 at szDecimals=2 -> 0.02)', () => {
    expect(formatSize(0.017, 2, 'ceil')).toBe('0.02');
    expect(formatSize(0.2345, 4, 'ceil')).toBe('0.2345');
  });

  it('ceil keeps exact values and only moves on real excess', () => {
    expect(formatSize(0.29, 2, 'ceil')).toBe('0.29');
    expect(formatSize('0.2900001', 2, 'ceil')).toBe('0.3');
  });

  it('round is half-up on the decimal literal', () => {
    expect(formatSize(1.005, 2, 'round')).toBe('1.01');
    expect(formatSize(0.125, 2, 'round')).toBe('0.13');
    expect(formatSize(0.124, 2, 'round')).toBe('0.12');
  });

  it('emits canonical strings without trailing zeros or exponent', () => {
    expect(formatSize(5, 2)).toBe('5');
    expect(formatSize('5.00', 2)).toBe('5');
    expect(formatSize(1.5, 3)).toBe('1.5');
    expect(formatSize(1e-7, 8)).toBe('0.0000001');
    expect(formatSize('1e3', 0)).toBe('1000');
  });

  it("returns '0' below one lot", () => {
    expect(formatSize(0.004, 2)).toBe('0');
    expect(formatSize(0, 2)).toBe('0');
  });

  it('keeps lot precision on large sizes', () => {
    expect(formatSize('12345678.999999995', 0)).toBe('12345679');
    expect(formatSize(12345678.999999998, 0)).toBe('12345679');
    expect(formatSize('987654321.123456', 3)).toBe('987654321.123');
  });

  it('throws on negative, non-finite or malformed sizes', () => {
    for (const bad of [-0.5, NaN, Infinity, 'abc', '']) {
      expect(() => formatSize(bad, 2)).toThrow(HlFormatError);
    }
  });
});

describe('sizeFromNotional', () => {
  it('sizes by the order price and floors by default', () => {
    expect(sizeFromNotional(10, 3000, 3)).toBe('0.003');
    expect(sizeFromNotional(10, 3000, 3, 'ceil')).toBe('0.004');
    expect(sizeFromNotional(10.15, 35, 2)).toBe('0.29');
    expect(sizeFromNotional(0, 35, 2)).toBe('0');
  });

  it('rejects a non-positive price', () => {
    expect(() => sizeFromNotional(10, 0, 2)).toThrow(HlFormatError);
    expect(() => sizeFromNotional(-1, 10, 2)).toThrow(HlFormatError);
  });
});

describe('isValidSize', () => {
  it('checks decimals without rounding', () => {
    expect(isValidSize('0.29', 2)).toBe(true);
    expect(isValidSize('1.50', 1)).toBe(true);
    expect(isValidSize('0', 2)).toBe(true);
    expect(isValidSize('0.291', 2)).toBe(false);
    expect(isValidSize(-1, 2)).toBe(false);
    expect(isValidSize('x', 2)).toBe(false);
  });
});

describe('isFullyFilled', () => {
  it('tolerates one lot of rounding', () => {
    expect(isFullyFilled(1, 0.99, 2)).toBe(true);
    expect(isFullyFilled('1.2', '1.2', 1)).toBe(true);
    expect(isFullyFilled(1, 0.98, 2)).toBe(false);
    expect(isFullyFilled(5, 3, 0)).toBe(false);
  });

  it('treats non-positive requested size as filled', () => {
    expect(isFullyFilled(0, 0, 2)).toBe(true);
    expect(isFullyFilled(-1, 0, 2)).toBe(true);
  });
});

describe('hasHiddenRemainder', () => {
  it('flags a real position larger than filled + lot', () => {
    expect(hasHiddenRemainder('-0.05', '0.03', 2)).toBe(true);
    expect(hasHiddenRemainder('0.04', '0.03', 2)).toBe(false);
    expect(hasHiddenRemainder('0.03', '0.03', 2)).toBe(false);
    expect(hasHiddenRemainder(0, '0.03', 2)).toBe(false);
  });
});

describe('API size strings with float noise (live spot l2Book: "2817666.6600000001" at szDecimals=2)', () => {
  it('rounds noisy strings back onto the lot and does not treat them as wire-valid', () => {
    expect(formatSize('2817666.6600000001', 2)).toBe('2817666.66');
    expect(formatSize('4216117.2599999998', 2)).toBe('4216117.26');
    expect(formatSize('4216117.2599999998', 2, 'ceil')).toBe('4216117.26');
    expect(isValidSize('2817666.6600000001', 2)).toBe(false);
    expect(isFullyFilled('4216117.26', '4216117.2599999998', 2)).toBe(true);
    expect(hasHiddenRemainder('2817666.6600000001', '2817666.66', 2)).toBe(false);
  });

  it('keeps huge integer lot counts of low-price spot tokens exact', () => {
    expect(formatSize('123456789.0', 0)).toBe('123456789');
    expect(sizeFromNotional(100, '0.00000011', 0)).toBe('909090909');
  });
});

describe('isFlatPosition', () => {
  it('is flat below one lot, not at exactly one lot', () => {
    expect(isFlatPosition('0', 3)).toBe(true);
    expect(isFlatPosition('-0.0009', 3)).toBe(true);
    expect(isFlatPosition('0.0000000001', 0)).toBe(true);
    expect(isFlatPosition('0.001', 3)).toBe(false);
    expect(isFlatPosition('-0.001', 3)).toBe(false);
    expect(isFlatPosition(1, 0)).toBe(false);
    expect(() => isFlatPosition('x', 3)).toThrow(HlFormatError);
    expect(() => isFlatPosition(0, -1)).toThrow(HlFormatError);
  });
});

describe('positionMatchesExpected', () => {
  it('accepts |actual - expected| <= 0.5 lot + 1e-12, signed', () => {
    expect(positionMatchesExpected('1.005', '1', 2)).toBe(true); // exactly half a lot
    expect(positionMatchesExpected('1.005000000001', '1', 2)).toBe(true); // + 1e-12
    expect(positionMatchesExpected('1.005000000002', '1', 2)).toBe(false);
    expect(positionMatchesExpected('-1', '1', 2)).toBe(false); // sign matters
    expect(positionMatchesExpected(0.1 + 0.2, '0.3', 1)).toBe(true);
    // snapshot still shows the pre-IoC position
    expect(positionMatchesExpected('0', '0.5', 2)).toBe(false);
  });
});
All files