Files
ritmex-bot/tests/precision-syncer.test.ts
T
discountry 1e4f610c04 refactor(strategy): extract PrecisionSyncer from 8 duplicated copies
Every engine hand-rolled the same ~35-line syncPrecision: fetch getPrecision(),
compare against a 1e-12 epsilon, log, retry in 2s on failure. The copies had
drifted — three bypassed i18n with hardcoded strings, maker-points alone
supported a forced re-sync, and the maker family wrote this.qtyStep while
trend/swing/guardian wrote config.qtyStep.

Extract Class: PrecisionSyncer owns both increments plus min base/quote amounts
and writes through to config, so both read styles keep working. Engines now hold
one collaborator instead of four fields.

Fixes a leak present in all eight copies: the 2s retry timer was never cleared,
so an engine stopped mid-retry kept polling a dead adapter forever. stop() now
cancels it — in the Ink UI that leaked one retry loop per strategy switch.

Also collapses log.trend.precision*/log.guardian.precision* into log.common.*
(the three key pairs held byte-identical text).

8 new tests; 226 pass; tsc --noEmit clean. -260 lines.
2026-07-29 20:32:30 +08:00

167 lines
4.7 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { PrecisionSyncer } from "../src/strategy/common/precision-syncer";
import type { ExchangeAdapter, ExchangePrecision } from "../src/exchanges/adapter";
function makeExchange(getPrecision?: () => Promise<ExchangePrecision | null>): ExchangeAdapter {
return { id: "stub", getPrecision } as unknown as ExchangeAdapter;
}
const MESSAGES = {
synced: (p: ExchangePrecision) => `synced ${p.priceTick}/${p.qtyStep}`,
failed: (error: unknown) => `failed ${String(error)}`,
};
describe("PrecisionSyncer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("seeds from config and writes exchange precision through to config", async () => {
const config = { priceTick: 0.1, qtyStep: 0.001 };
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0.01, qtyStep: 0.1 })),
config,
{ priceTick: config.priceTick, qtyStep: config.qtyStep },
() => {},
MESSAGES
);
expect(syncer.priceTick).toBe(0.1);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.01));
expect(syncer.qtyStep).toBe(0.1);
expect(config.priceTick).toBe(0.01);
expect(config.qtyStep).toBe(0.1);
});
it("logs only when an increment actually moves", async () => {
const logs: string[] = [];
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0.1, qtyStep: 0.001 })),
{ priceTick: 0.1, qtyStep: 0.001 },
{ priceTick: 0.1, qtyStep: 0.001 },
(_type, detail) => logs.push(detail),
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.1));
expect(logs).toEqual([]);
});
it("ignores non-positive increments from the exchange", async () => {
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0, qtyStep: Number.NaN })),
{ priceTick: 0.5, qtyStep: 0.25 },
{ priceTick: 0.5, qtyStep: 0.25 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.5));
expect(syncer.qtyStep).toBe(0.25);
});
it("retries after a failure until the exchange answers", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
if (attempts === 1) throw new Error("boom");
return { priceTick: 0.05, qtyStep: 0.5 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(attempts).toBe(1));
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.05));
});
it("stop() cancels the pending retry so a dead engine stops polling", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
throw new Error("boom");
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(attempts).toBe(1));
syncer.stop();
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts).toBe(1);
});
it("start() is idempotent while a sync is in flight", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
return { priceTick: 0.2, qtyStep: 0.2 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
syncer.start();
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
expect(attempts).toBe(1);
});
it("refresh() refetches after a completed sync", async () => {
let tick = 0.2;
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
return { priceTick: tick, qtyStep: 1 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
tick = 0.4;
syncer.refresh();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.4));
expect(attempts).toBe(2);
});
it("is inert when the adapter cannot report precision", async () => {
const syncer = new PrecisionSyncer(
makeExchange(undefined),
{ priceTick: 0.3, qtyStep: 0.3 },
{ priceTick: 0.3, qtyStep: 0.3 },
() => {},
MESSAGES
);
syncer.start();
await vi.advanceTimersByTimeAsync(5000);
expect(syncer.priceTick).toBe(0.3);
});
});