feat(assets): varlık lotları, ortalama maliyet ve kâr/zarar servisi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:17:44 +03:00
parent baec70d324
commit 890ba9f521
14 changed files with 1415 additions and 3 deletions
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio";
const buy = (quantity: number, amountMinor: number): MovementInput => ({
kind: "buy",
quantity,
amountMinor,
});
describe("ağırlıklı ortalama maliyet", () => {
it("örnek senaryo: 10 gr 25.000 TL'ye, her ay ekleme", () => {
// 10 gr × 2.500, 2 gr × 2.600, 2 gr × 2.700 → 14 gr, 35.600 TL; ort. 2.542,857…
const run = runMovements([buy(10_000, 25_000_00), buy(2_000, 5_200_00), buy(2_000, 5_400_00)]);
expect(run.quantity).toBe(14_000);
expect(run.costMinor).toBe(35_600_00);
expect(run.investedMinor).toBe(35_600_00);
const s = holdingSummary(run, 3_000_00);
expect(s.avgCostMinor).toBe(2_542_86);
expect(s.currentValueMinor).toBe(42_000_00);
expect(s.unrealizedMinor).toBe(6_400_00);
// 6.400 / 35.600 = %17,98
expect(s.unrealizedBp).toBe(1798);
});
it("satış: ortalama maliyetle düşer, gerçekleşmiş kâr/zarar", () => {
const run = runMovements([
buy(10_000, 30_000_00),
buy(10_000, 40_000_00),
{ kind: "sell", quantity: 5_000, amountMinor: 20_000_00 },
]);
// ort. 3.500 → 5 gr maliyet 17.500; satış 20.000 → +2.500
expect(run.steps[2]).toMatchObject({ costOutMinor: 17_500_00, realizedMinor: 2_500_00 });
expect(run.quantity).toBe(15_000);
expect(run.costMinor).toBe(52_500_00);
expect(run.proceedsMinor).toBe(20_000_00);
// Kalan ortalama değişmez.
expect(holdingSummary(run, null).avgCostMinor).toBe(3_500_00);
});
it("hediye çıkışı K/Z yazmaz; gelen hediye yatırım sayılmaz; tamamı çıkınca maliyet sıfır", () => {
const run = runMovements([
buy(3_000, 9_000_00),
{ kind: "gift_in", quantity: 1_000, amountMinor: 3_300_00 },
{ kind: "gift_out", quantity: 2_000, amountMinor: 0 },
{ kind: "sell", quantity: 2_000, amountMinor: 7_000_00 },
]);
expect(run.investedMinor).toBe(9_000_00);
expect(run.giftInMinor).toBe(3_300_00);
// ort. 3.075 → çıkış 6.150, kalan 6.150; satış tümü → K/Z 850
expect(run.giftOutCostMinor).toBe(6_150_00);
expect(run.realizedMinor).toBe(850_00);
expect(run.quantity).toBe(0);
expect(run.costMinor).toBe(0);
const s = holdingSummary(run, 3_500_00);
expect(s).toMatchObject({ avgCostMinor: null, currentValueMinor: 0, unrealizedBp: null });
});
it("eldekinden fazla çıkış işaretlenir", () => {
const run = runMovements([
buy(1_000, 100_00),
{ kind: "sell", quantity: 2_000, amountMinor: 1 },
]);
expect(run.negativeAt).toBe(1);
expect(runMovements([buy(1_000, 100_00)]).negativeAt).toBeNull();
});
it("oran biçimi", () => {
expect(formatBp(1798)).toBe("+%17,98");
expect(formatBp(-350)).toBe("−%3,5");
expect(formatBp(0)).toBe("%0");
});
});