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");
});
});
+146
View File
@@ -0,0 +1,146 @@
import { QUANTITY_SCALE } from "./quantity";
/**
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
* gerçekleşmemiş kâr/zarar. Miktarlar binde bir tamsayı, tutarlar kuruş.
*
* - Alım (`buy`): miktar ve maliyet artar; ödenen TL "yatırılan" sayılır.
* - Gelen hediye/miras (`gift_in`): miktar artar; maliyet = girildiği günkü değer (yatırım değil).
* - Satış (`sell`): ortalama maliyetle maliyet düşer; satış tutarı − düşen maliyet = gerçekleşmiş K/Z.
* - Hediye/çıkış (`gift_out`): ortalama maliyetle maliyet düşer; kâr/zarar yazılmaz.
*/
export const MOVEMENT_KINDS = ["buy", "gift_in", "sell", "gift_out"] as const;
export type MovementKind = (typeof MOVEMENT_KINDS)[number];
export const isInflow = (k: MovementKind) => k === "buy" || k === "gift_in";
export type MovementInput = {
kind: MovementKind;
quantity: number;
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta yok sayılır. */
amountMinor: number;
};
export type MovementStep = {
/** Satış/çıkışta ortalama maliyetle düşülen maliyet. */
costOutMinor: number;
/** Yalnızca satışta: satış tutarı − düşülen maliyet. */
realizedMinor: number;
/** Hareketten sonraki miktar ve kalan maliyet. */
quantityAfter: number;
costAfterMinor: number;
};
export type PortfolioRun = {
steps: MovementStep[];
quantity: number;
/** Kalan miktarın maliyet esası. */
costMinor: number;
/** Alımlarda ödenen toplam TL. */
investedMinor: number;
/** Satışlardan alınan toplam TL. */
proceedsMinor: number;
realizedMinor: number;
/** Gelen hediyelerin girişteki değeri. */
giftInMinor: number;
/** Çıkışlarla düşen maliyet. */
giftOutCostMinor: number;
/** Miktarın eksiye düştüğü ilk hareketin sırası (geçerli dizide null). */
negativeAt: number | null;
};
/** Hareketleri kronolojik sırada (çağıran sıralar) işler. */
export function runMovements(movements: MovementInput[]): PortfolioRun {
let quantity = 0;
let cost = 0;
const r: PortfolioRun = {
steps: [],
quantity: 0,
costMinor: 0,
investedMinor: 0,
proceedsMinor: 0,
realizedMinor: 0,
giftInMinor: 0,
giftOutCostMinor: 0,
negativeAt: null,
};
movements.forEach((m, i) => {
let costOut = 0;
let realized = 0;
if (isInflow(m.kind)) {
quantity += m.quantity;
cost += m.amountMinor;
if (m.kind === "buy") r.investedMinor += m.amountMinor;
else r.giftInMinor += m.amountMinor;
} else {
if (m.quantity > quantity) {
if (r.negativeAt === null) r.negativeAt = i;
costOut = cost;
} else {
costOut = m.quantity === quantity ? cost : Math.round((cost * m.quantity) / quantity);
}
quantity = Math.max(0, quantity - m.quantity);
cost -= costOut;
if (m.kind === "sell") {
realized = m.amountMinor - costOut;
r.proceedsMinor += m.amountMinor;
r.realizedMinor += realized;
} else r.giftOutCostMinor += costOut;
}
r.steps.push({
costOutMinor: costOut,
realizedMinor: realized,
quantityAfter: quantity,
costAfterMinor: cost,
});
});
r.quantity = quantity;
r.costMinor = cost;
return r;
}
export type HoldingSummary = {
quantity: number;
costMinor: number;
/** Birim başına ağırlıklı ortalama maliyet (miktar 0 ise null). */
avgCostMinor: number | null;
/** Güncel alış fiyatıyla değer (fiyat yoksa null). */
currentValueMinor: number | null;
unrealizedMinor: number | null;
/** Gerçekleşmemiş K/Z oranı, baz puan (%12,34 → 1234); maliyet 0 ise null. */
unrealizedBp: number | null;
realizedMinor: number;
investedMinor: number;
proceedsMinor: number;
};
/** Özet: `priceMinor` değerleme fiyatıdır (Faz 0: varlıklar alış fiyatıyla). */
export function holdingSummary(run: PortfolioRun, priceMinor: number | null): HoldingSummary {
const avgCostMinor =
run.quantity > 0 ? Math.round((run.costMinor * QUANTITY_SCALE) / run.quantity) : null;
const currentValueMinor =
priceMinor === null ? null : Math.round((run.quantity * priceMinor) / QUANTITY_SCALE);
const unrealizedMinor = currentValueMinor === null ? null : currentValueMinor - run.costMinor;
const unrealizedBp =
unrealizedMinor === null || run.costMinor <= 0
? null
: Math.round((unrealizedMinor * 10_000) / run.costMinor);
return {
quantity: run.quantity,
costMinor: run.costMinor,
avgCostMinor,
currentValueMinor,
unrealizedMinor,
unrealizedBp,
realizedMinor: run.realizedMinor,
investedMinor: run.investedMinor,
proceedsMinor: run.proceedsMinor,
};
}
/** Baz puanı "%12,34" / "−%3,5" biçiminde. */
export function formatBp(bp: number): string {
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { ASSET_KEYS } from "../assets";
import { parseMoney } from "../money";
import { MOVEMENT_KINDS } from "../portfolio";
import { parseQuantity } from "../quantity";
const v = tr.validation;
const a = tr.assets.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
/** Boş → null; dolu → pozitif kuruş. */
const optionalMoney = z.preprocess(
emptyToNull,
z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null || minor <= 0) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
})
.nullable(),
);
/** Varlık (birikim kalemi) formu. */
export const assetFormSchema = z.object({
assetKey: z.enum(ASSET_KEYS),
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
note: z.string().trim().max(500).default(""),
isPrivate: z.boolean().default(false),
});
export type AssetFormInput = z.input<typeof assetFormSchema>;
export type AssetForm = z.output<typeof assetFormSchema>;
/**
* Varlık hareketi. Tutar ve birim fiyat isteğe bağlı: tutar varsa birim fiyat ondan, yalnızca
* birim fiyat varsa tutar ondan hesaplanır; ikisi de boşsa fiyat tablosu kullanılır
* (alım → satış fiyatı, satış ve gelen hediye → alış fiyatı). Hesap yalnızca alım/satışta.
*/
export const movementFormSchema = z
.object({
kind: z.enum(MOVEMENT_KINDS),
date: ymd,
quantity: z
.string()
.trim()
.transform((s, ctx) => {
const q = parseQuantity(s);
if (q === null || q <= 0) {
ctx.addIssue({ code: "custom", message: tr.inKind.validation.quantity });
return z.NEVER;
}
return q;
}),
unitPrice: optionalMoney,
amount: optionalMoney,
accountId: z.preprocess(emptyToNull, objectId.nullable()),
storage: z.string().trim().max(120).default(""),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if ((x.kind === "gift_in" || x.kind === "gift_out") && x.accountId)
ctx.addIssue({ code: "custom", path: ["accountId"], message: a.noAccountForGift });
})
.transform((x) =>
x.kind === "gift_out" ? { ...x, unitPrice: null, amount: null, accountId: null } : x,
);
export type MovementFormInput = z.input<typeof movementFormSchema>;
export type MovementForm = z.output<typeof movementFormSchema>;