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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user