mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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.
167 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|