9a60085d14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
9.8 KiB
TypeScript
286 lines
9.8 KiB
TypeScript
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,
|
||
};
|
||
}
|