Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 45 additions & 32 deletions tests/engine/fx-conservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ function dater(): () => string {
};
}

/** Decimal-safe monetary input (decimal string, never a JS number — see the
* Financial Precision rule). `toEvents` wraps each in `new Decimal(...)`. */
type Money = string;

type Op =
| ["fund", string, number, number]
| ["conv", string, number, number]
| ["buy", string, number] // cost (rate irrelevant — buy parks, never realizes)
| ["sell", string, number, number, number]; // cost, proceeds, saleRate
| ["fund", string, Money, Money]
| ["conv", string, Money, Money]
| ["buy", string, Money] // cost (rate irrelevant — buy parks, never realizes)
| ["sell", string, Money, Money, Money]; // cost, proceeds, saleRate

/** Build the FxEvent stream for a list of ops, one ascending date per op. */
function toEvents(ops: Op[]): FxEvent[] {
Expand Down Expand Up @@ -76,27 +80,27 @@ function run(ops: Op[]): { engine: FxFifoEngine; gain: Decimal } {

describe("FX conservation self-check — HOLDS (no mismatch) for every legitimate scenario", () => {
it("simple acquire + convert", () => {
const { engine, gain } = run([["fund", "USD", 1000, 0.9], ["conv", "USD", 1000, 1.05]]);
const { engine, gain } = run([["fund", "USD", "1000", "0.9"], ["conv", "USD", "1000", "1.05"]]);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("150.00"); // sanity: 1000 × (1.05 − 0.90)
});

it("partial conversion (pool left with an unconverted remainder)", () => {
const { engine } = run([["fund", "USD", 1000, 0.9], ["conv", "USD", 400, 1.05]]);
const { engine } = run([["fund", "USD", "1000", "0.9"], ["conv", "USD", "400", "1.05"]]);
expect(mismatches(engine)).toHaveLength(0); // 600 stays in the pool, still balances
});

it("full carry-basis round-trip (acquire → buy → sell → convert) — S1", () => {
const ops: Op[] = [["fund", "USD", 1000, 0.9], ["buy", "USD", 1000], ["sell", "USD", 1000, 1200, 1.0], ["conv", "USD", 1200, 1.05]];
const ops: Op[] = [["fund", "USD", "1000", "0.9"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1200", "1.0"], ["conv", "USD", "1200", "1.05"]];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("160.00"); // the validated S1 figure
});

it("two carry-basis round-trips → S2 (320.00), NO mismatch", () => {
const ops: Op[] = [
["fund", "USD", 1000, 0.9], ["buy", "USD", 1000], ["sell", "USD", 1000, 1100, 1.0], ["conv", "USD", 1100, 1.05],
["fund", "USD", 1000, 1.1], ["buy", "USD", 1000], ["sell", "USD", 1000, 1300, 1.2], ["conv", "USD", 1300, 1.25],
["fund", "USD", "1000", "0.9"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1100", "1.0"], ["conv", "USD", "1100", "1.05"],
["fund", "USD", "1000", "1.1"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1300", "1.2"], ["conv", "USD", "1300", "1.25"],
];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
Expand All @@ -106,7 +110,7 @@ describe("FX conservation self-check — HOLDS (no mismatch) for every legitimat
it("loss-sell (discard path) — no conversion, principal discarded", () => {
// fund $1000@1.20, buy $1000, sell $1000→$800 @0.80 (a $200 USD loss). The 200
// discarded principal is on the lhs (discarded) and balances.
const ops: Op[] = [["fund", "USD", 1000, 1.2], ["buy", "USD", 1000], ["sell", "USD", 1000, 800, 0.8]];
const ops: Op[] = [["fund", "USD", "1000", "1.2"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "800", "0.8"]];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("0.00"); // nothing converted → no FX realized
Expand All @@ -116,7 +120,7 @@ describe("FX conservation self-check — HOLDS (no mismatch) for every legitimat
// A conversion with NO prior acquisition lot: the engine floors gain to 0 and
// warns fx.missing_prior_lots — but conservation must NOT additionally fire
// (the phantom-acquire term reconciles the floored disposal).
const { engine, gain } = run([["conv", "USD", 1000, 1.05]]);
const { engine, gain } = run([["conv", "USD", "1000", "1.05"]]);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("0.00");
// The missing-lot INFO is still emitted (proves we took the floor path).
Expand All @@ -126,14 +130,14 @@ describe("FX conservation self-check — HOLDS (no mismatch) for every legitimat
it("partial missing-lot (pool covers only part of the conversion)", () => {
// fund $400, convert $1000: 400 covered (real dispose) + 600 floored. The 600
// floored leg is a phantom acquire — still balances.
const { engine } = run([["fund", "USD", 400, 0.9], ["conv", "USD", 1000, 1.05]]);
const { engine } = run([["fund", "USD", "400", "0.9"], ["conv", "USD", "1000", "1.05"]]);
expect(mismatches(engine)).toHaveLength(0);
expect(engine.messages.some((m) => m.id === "fx.missing_prior_lots")).toBe(true);
});

it("uncovered park then unpark at the sale rate (buy/sell with no funding)", () => {
// A1: buy (uncovered park) → sell → convert. Funding-absent no-op path.
const ops: Op[] = [["buy", "USD", 1000], ["sell", "USD", 1000, 1200, 1.0], ["conv", "USD", 1200, 1.05]];
const ops: Op[] = [["buy", "USD", "1000"], ["sell", "USD", "1000", "1200", "1.0"], ["conv", "USD", "1200", "1.05"]];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("60.00");
Expand All @@ -142,30 +146,30 @@ describe("FX conservation self-check — HOLDS (no mismatch) for every legitimat
it("uncovered park left OPEN at the period boundary (never sold)", () => {
// A lone uncovered buy: parks 1000 uncovered, nothing else. The phantom-acquire
// term must balance the parked-side FCY that had no real acquisition.
const { engine } = run([["buy", "USD", 1000]]);
const { engine } = run([["buy", "USD", "1000"]]);
expect(mismatches(engine)).toHaveLength(0);
});

it("sell-only (position bought outside the data window) — unmatched re-add", () => {
// A3: the unmatched-sell re-add path (phantom acquire on the pool side).
const ops: Op[] = [["sell", "USD", 1000, 1200, 1.0], ["conv", "USD", 1200, 1.05]];
const ops: Op[] = [["sell", "USD", "1000", "1200", "1.0"], ["conv", "USD", "1200", "1.05"]];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("60.00");
});

it("partial funding (pool covers only part of the buy) — A5", () => {
const ops: Op[] = [["fund", "USD", 500, 0.9], ["buy", "USD", 1000], ["sell", "USD", 1000, 1200, 1.0], ["conv", "USD", 1200, 1.05]];
const ops: Op[] = [["fund", "USD", "500", "0.9"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1200", "1.0"], ["conv", "USD", "1200", "1.05"]];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
expect(gain.toFixed(2)).toBe("110.00");
});

it("documented residual partial-w/loss (S7) — still balances", () => {
const ops: Op[] = [
["fund", "USD", 2000, 0.9], ["buy", "USD", 2000],
["sell", "USD", 1000, 1200, 1.0], ["conv", "USD", 1200, 1.05],
["sell", "USD", 1000, 900, 1.1], ["conv", "USD", 900, 1.15],
["fund", "USD", "2000", "0.9"], ["buy", "USD", "2000"],
["sell", "USD", "1000", "1200", "1.0"], ["conv", "USD", "1200", "1.05"],
["sell", "USD", "1000", "900", "1.1"], ["conv", "USD", "900", "1.15"],
];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
Expand Down Expand Up @@ -194,7 +198,7 @@ describe("FX conservation self-check — HOLDS (no mismatch) for every legitimat
});

it("the check is SILENT — emits NO message at all on a clean simple flow", () => {
const { engine } = run([["fund", "USD", 1000, 0.9], ["conv", "USD", 1000, 1.05]]);
const { engine } = run([["fund", "USD", "1000", "0.9"], ["conv", "USD", "1000", "1.05"]]);
// A fully-covered single round-trip warns about nothing.
expect(engine.messages).toHaveLength(0);
});
Expand All @@ -205,8 +209,8 @@ describe("FX conservation self-check — the identity (numeric guard on the vali
// The S2 carry-basis scenario from fx-fifo-carry-basis.test.ts. We assert the
// identity numerically on the engine's own end-of-run accumulators + balances.
const ops: Op[] = [
["fund", "USD", 1000, 0.9], ["buy", "USD", 1000], ["sell", "USD", 1000, 1100, 1.0], ["conv", "USD", 1100, 1.05],
["fund", "USD", 1000, 1.1], ["buy", "USD", 1000], ["sell", "USD", 1000, 1300, 1.2], ["conv", "USD", 1300, 1.25],
["fund", "USD", "1000", "0.9"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1100", "1.0"], ["conv", "USD", "1100", "1.05"],
["fund", "USD", "1000", "1.1"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1300", "1.2"], ["conv", "USD", "1300", "1.25"],
];
const engine = new FxFifoEngine();
engine.processEvents(toEvents(ops));
Expand Down Expand Up @@ -245,10 +249,10 @@ describe("FX conservation self-check — the identity (numeric guard on the vali

it("S8 two-gains-one-conversion: identity holds and the check is silent", () => {
const ops: Op[] = [
["fund", "USD", 2000, 0.9],
["buy", "USD", 1000], ["sell", "USD", 1000, 1200, 1.0],
["buy", "USD", 1000], ["sell", "USD", 1000, 1400, 1.1],
["conv", "USD", 2600, 1.2],
["fund", "USD", "2000", "0.9"],
["buy", "USD", "1000"], ["sell", "USD", "1000", "1200", "1.0"],
["buy", "USD", "1000"], ["sell", "USD", "1000", "1400", "1.1"],
["conv", "USD", "2600", "1.2"],
];
const { engine, gain } = run(ops);
expect(mismatches(engine)).toHaveLength(0);
Expand Down Expand Up @@ -283,7 +287,7 @@ describe("FX conservation self-check — FIRES on a genuine desync (artificial b
it("emits fx.conservation_mismatch (warning) when the pool diverges from the books", () => {
const engine = new BreakableEngine();
// A clean run first (no mismatch yet).
engine.processEvents(toEvents([["fund", "USD", 1000, 0.9], ["conv", "USD", 1000, 1.05]]));
engine.processEvents(toEvents([["fund", "USD", "1000", "0.9"], ["conv", "USD", "1000", "1.05"]]));
expect(engine.messages.filter((m) => m.id === MISMATCH_ID)).toHaveLength(0);

// Now corrupt the pool by 500 units and re-run the real check.
Expand All @@ -303,7 +307,7 @@ describe("FX conservation self-check — FIRES on a genuine desync (artificial b

it("stays silent for a sub-epsilon (dust) imbalance", () => {
const engine = new BreakableEngine();
engine.processEvents(toEvents([["fund", "USD", 1000, 0.9], ["conv", "USD", 1000, 1.05]]));
engine.processEvents(toEvents([["fund", "USD", "1000", "0.9"], ["conv", "USD", "1000", "1.05"]]));
// 0.001 < CONSERVATION_EPS (0.01) → no warning (dust is fine).
engine.forceMismatch("USD", 0.001);
expect(engine.messages.filter((m) => m.id === MISMATCH_ID)).toHaveLength(0);
Expand All @@ -314,13 +318,22 @@ describe("FX conservation self-check — DIAGNOSTIC ONLY (never changes a tax fi
it("the disposals/gain are byte-identical whether or not a mismatch is emitted", () => {
// The check runs at the END of processEvents and only reads state + emits a
// message. The disposals returned are the same object the engine built before
// the check ran — proving no casilla figure depends on the diagnostic.
const ops: Op[] = [["fund", "USD", 1000, 0.9], ["buy", "USD", 1000], ["sell", "USD", 1000, 1200, 1.0], ["conv", "USD", 1200, 1.05]];
const engine = new FxFifoEngine();
// the check ran — proving no casilla figure depends on the diagnostic. We test
// BOTH branches (clean run AND after a real mismatch is emitted), per the title.
const ops: Op[] = [["fund", "USD", "1000", "0.9"], ["buy", "USD", "1000"], ["sell", "USD", "1000", "1200", "1.0"], ["conv", "USD", "1200", "1.05"]];
const engine = new BreakableEngine();
const disposals = engine.processEvents(toEvents(ops));
const totalBefore = disposals.reduce((s, d) => s.plus(d.gainLossEur), new Decimal(0)).toFixed(2);
expect(totalBefore).toBe("160.00");
// getDisposals() returns the same array the check never touched.
// Clean run: no mismatch yet.
expect(engine.messages.filter((m) => m.id === MISMATCH_ID)).toHaveLength(0);

// Now FORCE a real mismatch (the emitted-mismatch branch the title promises).
engine.forceMismatch("USD", 500);
expect(engine.messages.some((m) => m.id === MISMATCH_ID)).toBe(true);

// The disposals/gain are UNCHANGED by the emission — same array, same total.
expect(disposals.reduce((s, d) => s.plus(d.gainLossEur), new Decimal(0)).toFixed(2)).toBe("160.00");
expect(engine.getDisposals().reduce((s, d) => s.plus(d.gainLossEur), new Decimal(0)).toFixed(2)).toBe("160.00");
});
});