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
+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}`;
}