feat(assets): dönemsel rapor, net varlık ve aynî kayıt bağlantısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio";
|
||||
import {
|
||||
formatBp,
|
||||
holdingSummary,
|
||||
periodPoints,
|
||||
periodReport,
|
||||
runMovements,
|
||||
sumReports,
|
||||
type DatedMovement,
|
||||
type MovementInput,
|
||||
} from "./portfolio";
|
||||
|
||||
const buy = (quantity: number, amountMinor: number): MovementInput => ({
|
||||
kind: "buy",
|
||||
@@ -70,3 +79,67 @@ describe("ağırlıklı ortalama maliyet", () => {
|
||||
expect(formatBp(0)).toBe("%0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dönemsel rapor", () => {
|
||||
it("noktalar: başlangıç, ay sonları, bitiş", () => {
|
||||
expect(periodPoints("2026-05-06", "2026-08-15")).toEqual([
|
||||
"2026-05-06",
|
||||
"2026-05-31",
|
||||
"2026-06-30",
|
||||
"2026-07-31",
|
||||
"2026-08-15",
|
||||
]);
|
||||
expect(periodPoints("2026-05-31", "2026-06-30")).toEqual(["2026-05-31", "2026-06-30"]);
|
||||
});
|
||||
|
||||
// Örnek: 10 gr'ı 25.000'e aldım, her ay 1 gr ekledim; 6 ay sonra ne kazandım?
|
||||
const prices: Record<string, number> = {
|
||||
"2026-01-01": 2_500_00,
|
||||
"2026-02-01": 2_600_00,
|
||||
"2026-03-01": 2_700_00,
|
||||
"2026-07-01": 3_000_00,
|
||||
};
|
||||
const priceAt = (date: string) => {
|
||||
const keys = Object.keys(prices)
|
||||
.filter((k) => k <= date)
|
||||
.sort();
|
||||
return keys.length ? prices[keys.at(-1)!]! : null;
|
||||
};
|
||||
const ms: DatedMovement[] = [
|
||||
{ kind: "buy", date: "2026-01-01", quantity: 10_000, amountMinor: 25_000_00 },
|
||||
{ kind: "buy", date: "2026-02-01", quantity: 1_000, amountMinor: 2_600_00 },
|
||||
{ kind: "buy", date: "2026-03-01", quantity: 1_000, amountMinor: 2_700_00 },
|
||||
{ kind: "gift_out", date: "2026-07-01", quantity: 1_000, amountMinor: 0 },
|
||||
];
|
||||
|
||||
it("örnek senaryo: yatırılan, dönem sonu değer, kâr", () => {
|
||||
const r = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
|
||||
expect(r.startValueMinor).toBe(0);
|
||||
expect(r.investedMinor).toBe(30_300_00);
|
||||
// 11 gr × 3.000 = 33.000; çıkan 1 gr o günkü değeri 3.000
|
||||
expect(r.endValueMinor).toBe(33_000_00);
|
||||
expect(r.giftOutMinor).toBe(3_000_00);
|
||||
expect(r.profitMinor).toBe(5_700_00);
|
||||
expect(r.profitBp).toBe(1881);
|
||||
expect(r.points.map((p) => p.valueMinor)).toEqual([
|
||||
0, 25_000_00, 28_600_00, 32_400_00, 32_400_00, 32_400_00, 32_400_00, 33_000_00,
|
||||
]);
|
||||
});
|
||||
|
||||
it("başlangıçta eldeki değer dönem içi kârdan düşülür; fiyat yoksa null", () => {
|
||||
const r = periodReport(ms, priceAt, "2026-03-15", "2026-07-02");
|
||||
expect(r.startValueMinor).toBe(32_400_00);
|
||||
expect(r.investedMinor).toBe(0);
|
||||
expect(r.profitMinor).toBe(33_000_00 + 3_000_00 - 32_400_00);
|
||||
expect(periodReport(ms, () => null, "2026-01-01", "2026-07-02").profitMinor).toBeNull();
|
||||
});
|
||||
|
||||
it("toplam", () => {
|
||||
const a = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
|
||||
const t = sumReports([a, a], "2026-01-01", "2026-07-02");
|
||||
expect(t.investedMinor).toBe(60_600_00);
|
||||
expect(t.profitMinor).toBe(11_400_00);
|
||||
expect(t.points.at(-1)!.valueMinor).toBe(66_000_00);
|
||||
expect(sumReports([], "2026-01-01", "2026-01-31").points).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -144,3 +145,141 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,9 +101,15 @@ export const paymentFormSchema = z
|
||||
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
||||
amount: optionalMoney,
|
||||
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||
/** Aynı varlıkla ödemede bağlanacak varlık (Faz 9b): borçta varlıktan düşer, alacakta eklenir. */
|
||||
assetId: z.preprocess(emptyToNull, objectId.nullable()).default(null),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.assetId && x.method !== "asset")
|
||||
ctx.addIssue({ code: "custom", path: ["assetId"], message: k.assetOnlyWithAsset });
|
||||
if (x.assetId && x.amount)
|
||||
ctx.addIssue({ code: "custom", path: ["amount"], message: k.noAmountWithAsset });
|
||||
if (x.method === "asset" && x.quantity === null)
|
||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||
if (x.method === "cash") {
|
||||
|
||||
Reference in New Issue
Block a user