import { describe, expect, it } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; import { formatPositionReturnPercent, toAccountPagePositionSections, toUnrealizedReturns, } from "./positions"; import { portfolio, position, second } from "./positions.fixtures"; function makePosition( overrides: Partial, ): Portfolio["positions"][number] { return { ...position, ...overrides }; } function sections(positions: Portfolio["positions"]) { return toAccountPagePositionSections({ ...portfolio, positions, }); } describe("account position sections", () => { it("excludes currency instruments only from display, preserving order and share denominators", () => { const otherTypes = [ "OPTION", "OTHER", "FUTURE", "STRUCTURED_NOTE", "DIGITAL_FINANCIAL_ASSET", "FUND", ] as const; const snapshot: Portfolio = { ...portfolio, cashBalances: [{ currency: "RUB", value: "1000" }], positions: [ ...portfolio.positions, { ...portfolio.positions[0], currentValue: { currency: "RUB", value: "50" }, figi: "FIGI-CURRENCY", type: "CURRENCY", }, ...otherTypes.map( type => ({ ...portfolio.positions[2], currentValue: type === "OPTION" ? { currency: "RUB", value: "50" } : null, figi: `FIGI-${type}-NEXT`, type, }), ), ], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1250" }], }; const original = structuredClone(snapshot); const sections = toAccountPagePositionSections(snapshot); expect(sections.map(section => section.kind)).toEqual([ "stocks", "bonds", "other", ]); expect(sections[2]?.groups.map(group => group.positions[0].figi)).toEqual([ "FIGI-FUND", ...otherTypes.map(type => `FIGI-${type}-NEXT`), ]); expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %"); expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %"); expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %"); expect(snapshot).toEqual(original); }); it("prepares grouped position sections with derived display data", () => { expect( toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ kind, positions: groups.flatMap(group => group.positions), })), ).toMatchObject([ { kind: "stocks", positions: [ { displayName: "Акция", logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", returnPercent: null, returnPercentText: null, returnText: "+10 ₽", returnTone: "positive", shareText: "66,7 %", }, ], }, { kind: "bonds", positions: [ { displayName: "Облигация", logoSrc: null, returnPercent: "-11.111111", returnPercentText: "-11,11 %", returnText: "-5 ₽", returnTone: "negative", shareText: "33,3 %", }, ], }, { kind: "other", positions: [ { displayName: "Фонд", logoSrc: null, returnPercent: null, returnPercentText: null, returnText: null, returnTone: undefined, shareText: null, }, ], }, ]); }); it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { expect( sections([ { ...position, figi: null, }, { ...second, figi: null }, ])[0]?.groups, ).toHaveLength(1); expect( sections([position, { ...second, figi: "OTHER-FIGI" }])[0]?.groups, ).toHaveLength(2); expect( sections([ { ...position, figi: null, isin: null, }, { ...second, figi: null, isin: null, }, ])[0]?.groups, ).toHaveLength(2); }); it("preserves one-account cards and does not merge repeated rows on the same account", () => { expect( sections([position, { ...second, account: position.account }])[0]?.groups, ).toHaveLength(2); expect( sections([ { ...position, account: undefined }, { ...second, account: undefined }, ])[0]?.groups, ).toHaveLength(2); }); it("keeps currencies and purchase prices attached to their source positions", () => { const source = [ position, { ...second, averagePurchasePrice: { currency: "USD", value: "0.99" }, currentValue: { currency: "USD", value: "1.01" }, }, ]; const before = structuredClone(source); const group = sections(source)[0]?.groups[0]; expect( group?.positions.map(item => [ item.quantity, item.currentValue, item.averagePurchasePrice, ]), ).toEqual( source.map(item => [ item.quantity, item.currentValue, item.averagePurchasePrice, ]), ); expect(source).toEqual(before); }); }); describe("account position returns", () => { it.each([ { absoluteReturn: { currency: "RUB", value: "25" }, averagePurchasePrice: { currency: "RUB", value: "280" }, expected: "7.142857", name: "gain", quantity: "1.25", type: "STOCK", }, { absoluteReturn: { currency: "RUB", value: "-25" }, averagePurchasePrice: { currency: "RUB", value: "280" }, expected: "-7.142857", name: "loss", quantity: "1.25", type: "STOCK", }, { absoluteReturn: { currency: "RUB", value: "-5" }, averagePurchasePrice: { currency: "RUB", value: "45" }, expected: "-11.111111", name: "bond without accrued interest", quantity: "1", type: "BOND", }, ] as const)("calculates $name from the clean purchase cost", (testCase) => { const result = sections([makePosition({ absoluteReturn: testCase.absoluteReturn, averagePurchasePrice: testCase.averagePurchasePrice, quantity: testCase.quantity, type: testCase.type, })]); expect(result[0]?.groups[0]?.positions[0].returnPercent) .toBe(testCase.expected); }); it.each([ { name: "zero quantity", overrides: { quantity: "0" } }, { name: "negative quantity", overrides: { quantity: "-1" } }, { name: "missing average price", overrides: { averagePurchasePrice: null } }, { name: "currency mismatch", overrides: { averagePurchasePrice: { currency: "USD", value: "280" } }, }, { name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } }, ])("returns null for $name", ({ overrides }) => { const result = sections([makePosition({ absoluteReturn: { currency: "RUB", value: "25" }, averagePurchasePrice: { currency: "RUB", value: "280" }, quantity: "1.25", ...overrides, })]); expect(result[0]?.groups[0]?.positions[0].returnPercent).toBeNull(); }); it.each([ { absoluteReturn: "1", expected: "0.000001" }, { absoluteReturn: "-1", expected: "-0.000001" }, ])("rounds an exact half away from zero for $absoluteReturn", (testCase) => { const result = sections([makePosition({ absoluteReturn: { currency: "RUB", value: testCase.absoluteReturn }, averagePurchasePrice: { currency: "RUB", value: "200000000" }, quantity: "1", })]); expect(result[0]?.groups[0]?.positions[0].returnPercent) .toBe(testCase.expected); }); it.each([ { expected: "+1.01", value: "1.005" }, { expected: "-1.01", value: "-1.005" }, ])("formats $value for display with a sign and two decimals", (testCase) => { expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected); }); it("sums complete unrealized returns by currency", () => { const result = toUnrealizedReturns([ makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }), makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }), ]); expect(result).toEqual([ { currency: "RUB", value: "0.3" }, { currency: "USD", value: "-2.5" }, ]); }); it("omits only currencies with incomplete returns", () => { const result = toUnrealizedReturns([ makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }), makePosition({ absoluteReturn: null, currentValue: { currency: "RUB", value: "100" }, }), makePosition({ absoluteReturn: { currency: "USD", value: "2" } }), ]); expect(result).toEqual([{ currency: "USD", value: "2" }]); }); });