Files
butcem/src/shared/portfolio.ts
T

286 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { QUANTITY_SCALE } from "./quantity";
import { addDays, addMonths } from "./recurrence";
/**
* 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}`;
}
// --- Dönemsel rapor (Bölüm 6.6) ---
export type DatedMovement = MovementInput & { date: string };
/** Dönem noktaları: başlangıç, aradaki ay sonları ve bitiş ("YYYY-MM-DD", artan, tekil). */
export function periodPoints(from: string, to: string): string[] {
const out = [from];
let monthEnd = addMonths(`${from.slice(0, 7)}-01`, 0, "last");
while (monthEnd < to) {
if (monthEnd > from) out.push(monthEnd);
monthEnd = addMonths(addDays(monthEnd, 1), 0, "last");
}
if (to > from) out.push(to);
return out;
}
export type PeriodPoint = { date: string; valueMinor: number | null; costMinor: number };
export type PeriodReport = {
from: string;
to: string;
/** Dönem başındaki (başlangıç gününden önceki hareketlerle) değer. */
startValueMinor: number | null;
endValueMinor: number | null;
/** Dönemdeki alımlarda ödenen. */
investedMinor: number;
/** Dönemdeki satışlardan alınan. */
proceedsMinor: number;
/** Dönemde gelen hediyelerin girişteki değeri. */
giftInMinor: number;
/** Dönemde çıkan hediyelerin o günkü değeri (fiyat yoksa maliyeti). */
giftOutMinor: number;
/**
* Dönem kârı: bitiş değeri + satış + çıkan hediye − başlangıç değeri − alım − gelen hediye.
* Fiyat eksikse null.
*/
profitMinor: number | null;
/** Kârın, dönemde bağlanan sermayeye (başlangıç + alım + gelen hediye) oranı (baz puan). */
profitBp: number | null;
points: PeriodPoint[];
};
/**
* Bir varlığın dönem raporu. `movements` kronolojik sıralı; `priceAt(date)` o tarihteki (veya
* öncesindeki en yakın) değerleme fiyatı (alış), yoksa null.
*/
export function periodReport(
movements: DatedMovement[],
priceAt: (date: string) => number | null,
from: string,
to: string,
): PeriodReport {
const valueAt = (date: string, inclusive: boolean) => {
const run = runMovements(movements.filter((m) => (inclusive ? m.date <= date : m.date < date)));
const price = priceAt(date);
return {
run,
value:
run.quantity === 0
? 0
: price === null
? null
: Math.round((run.quantity * price) / QUANTITY_SCALE),
};
};
const start = valueAt(from, false);
const end = valueAt(to, true);
const all = runMovements(movements);
let invested = 0;
let proceeds = 0;
let giftIn = 0;
let giftOut = 0;
movements.forEach((m, i) => {
if (m.date < from || m.date > to) return;
if (m.kind === "buy") invested += m.amountMinor;
else if (m.kind === "sell") proceeds += m.amountMinor;
else if (m.kind === "gift_in") giftIn += m.amountMinor;
else {
const price = priceAt(m.date);
giftOut +=
price === null
? all.steps[i]!.costOutMinor
: Math.round((m.quantity * price) / QUANTITY_SCALE);
}
});
const profitMinor =
start.value === null || end.value === null
? null
: end.value + proceeds + giftOut - start.value - invested - giftIn;
const base = (start.value ?? 0) + invested + giftIn;
const points = periodPoints(from, to).map((date, i) => {
const p = i === 0 ? start : valueAt(date, true);
return { date, valueMinor: p.value, costMinor: p.run.costMinor };
});
return {
from,
to,
startValueMinor: start.value,
endValueMinor: end.value,
investedMinor: invested,
proceedsMinor: proceeds,
giftInMinor: giftIn,
giftOutMinor: giftOut,
profitMinor,
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
points,
};
}
/** Birden çok varlığın raporlarını toplar (noktalar aynı tarihlerde). Eksik fiyatlı değerler atlanır. */
export function sumReports(reports: PeriodReport[], from: string, to: string): PeriodReport {
const add = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b);
const points = periodPoints(from, to).map((date, i) => ({
date,
valueMinor: reports.reduce<number | null>((a, r) => add(a, r.points[i]?.valueMinor ?? null), 0),
costMinor: reports.reduce((a, r) => a + (r.points[i]?.costMinor ?? 0), 0),
}));
const sum = (k: "investedMinor" | "proceedsMinor" | "giftInMinor" | "giftOutMinor") =>
reports.reduce((a, r) => a + r[k], 0);
const startValueMinor = reports.reduce<number | null>((a, r) => add(a, r.startValueMinor), 0);
const endValueMinor = reports.reduce<number | null>((a, r) => add(a, r.endValueMinor), 0);
const profitMinor = reports.reduce<number | null>((a, r) => add(a, r.profitMinor), 0);
const base = (startValueMinor ?? 0) + sum("investedMinor") + sum("giftInMinor");
return {
from,
to,
startValueMinor,
endValueMinor,
investedMinor: sum("investedMinor"),
proceedsMinor: sum("proceedsMinor"),
giftInMinor: sum("giftInMinor"),
giftOutMinor: sum("giftOutMinor"),
profitMinor,
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
points,
};
}