feat: add investment reports, bond components, dashboard, and service implementation
This commit is contained in:
@@ -6,21 +6,23 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
ALL_HOLDINGS,
|
||||
ALLOCATION_TARGET_IS_RBI_LINKED,
|
||||
INSTRUMENT_LABELS,
|
||||
InstrumentType,
|
||||
InvestmentHolding,
|
||||
TARGET_ALLOCATION_PCT,
|
||||
accruedInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount
|
||||
} from '../investment-data';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { INSTRUMENT_LABELS, InstrumentType, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
import {
|
||||
InvestmentBucketReportDto,
|
||||
InvestmentBucketRowDto,
|
||||
InvestmentFdDto,
|
||||
InvestmentFdLimitDto,
|
||||
InvestmentInterestReportDto,
|
||||
InvestmentNonSlrLimitDto,
|
||||
InvestmentPurchaseDto,
|
||||
InvestmentService,
|
||||
apiErrorMessage
|
||||
} from '../investment.service';
|
||||
|
||||
interface InstrumentSummary {
|
||||
type: InstrumentType;
|
||||
@@ -28,25 +30,45 @@ interface InstrumentSummary {
|
||||
route: string;
|
||||
count: number;
|
||||
faceValue: number;
|
||||
bookValue: number;
|
||||
accruedInterest: number;
|
||||
maturityValue: number;
|
||||
sharePct: number;
|
||||
}
|
||||
|
||||
interface AllocationRow {
|
||||
type: InstrumentType;
|
||||
interface LimitRow {
|
||||
label: string;
|
||||
targetPct: number;
|
||||
actualPct: number;
|
||||
actualValue: number;
|
||||
variancePct: number;
|
||||
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
|
||||
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
|
||||
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
|
||||
isRbiLinked: boolean;
|
||||
basis: string;
|
||||
limit: number | null;
|
||||
used: number;
|
||||
usedPct: number | null;
|
||||
within: boolean;
|
||||
}
|
||||
|
||||
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
|
||||
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
|
||||
/** A holding maturing soon, or past maturity and not yet redeemed / closed. */
|
||||
interface MaturingRow {
|
||||
dealNo: string;
|
||||
particulars: string;
|
||||
instrument: string;
|
||||
maturityDate: string;
|
||||
days: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const ROUTE: Record<InstrumentType, string> = {
|
||||
FD: '/investment/fd',
|
||||
GOVT_SECURITY: '/investment/bonds',
|
||||
NON_SLR: '/investment/bonds',
|
||||
BOND: '/investment/bonds',
|
||||
MUTUAL_FUND: '/investment/bonds',
|
||||
SHARE: '/investment/share'
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross-instrument portfolio dashboard on live data: what is held today by instrument (face value, book value,
|
||||
* interest earned and not yet received, value at maturity), where the portfolio stands against the RBI limits, and
|
||||
* what matures in the next 90 days.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-investment-dashboard',
|
||||
templateUrl: './investment-dashboard.component.html',
|
||||
@@ -60,107 +82,216 @@ interface AllocationRow {
|
||||
RouterLink
|
||||
]
|
||||
})
|
||||
export class InvestmentDashboardComponent {
|
||||
asOnDate = '';
|
||||
export class InvestmentDashboardComponent implements OnInit {
|
||||
private readonly investmentService = inject(InvestmentService);
|
||||
|
||||
asOnDate = todayDDMMYYYY();
|
||||
loading = false;
|
||||
error = '';
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
||||
{ type: 'FD', route: '/investment/fd' },
|
||||
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
||||
{ type: 'NON_SLR', route: '/investment/bonds' },
|
||||
{ type: 'BOND', route: '/investment/bonds' },
|
||||
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
|
||||
{ type: 'SHARE', route: '/investment/share' }
|
||||
];
|
||||
|
||||
constructor() {
|
||||
const d = new Date();
|
||||
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||
summaries: InstrumentSummary[] = [];
|
||||
limits: LimitRow[] = [];
|
||||
limitNote = '';
|
||||
maturingSoon: MaturingRow[] = [];
|
||||
totalReceivable = 0;
|
||||
totalProvisioned = 0;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
|
||||
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
|
||||
}
|
||||
|
||||
get summaries(): InstrumentSummary[] {
|
||||
return this.types.map(({ type, route }) => {
|
||||
const holdings = this.holdingsOf(type);
|
||||
return {
|
||||
type,
|
||||
label: INSTRUMENT_LABELS[type],
|
||||
route,
|
||||
count: holdings.length,
|
||||
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
|
||||
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
|
||||
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
|
||||
};
|
||||
load(): void {
|
||||
const today = this.asOnDate;
|
||||
const d = parseDDMMYYYY(today);
|
||||
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
forkJoin({
|
||||
bucket: this.investmentService.getBucketReport(today),
|
||||
interest: this.investmentService.getInterestReport(fyStart, today),
|
||||
purchases: this.investmentService.getPurchases(),
|
||||
fds: this.investmentService.getFds(),
|
||||
fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
|
||||
nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
|
||||
}).subscribe({
|
||||
next: (r) => {
|
||||
this.build(r.bucket, r.interest, r.purchases, r.fds, r.fdQuote.limit ?? null, r.nonSlr);
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.loading = false;
|
||||
this.error = apiErrorMessage(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
|
||||
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
|
||||
* a treasury desk can't rebalance to the exact decimal every day. */
|
||||
get allocationVsTarget(): AllocationRow[] {
|
||||
const total = this.totalFaceValue;
|
||||
return this.types.map(({ type }) => {
|
||||
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
|
||||
const actualPct = total ? (value / total) * 100 : 0;
|
||||
const targetPct = TARGET_ALLOCATION_PCT[type];
|
||||
const variancePct = +(actualPct - targetPct).toFixed(1);
|
||||
const statusLabel: AllocationRow['statusLabel'] =
|
||||
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
|
||||
return {
|
||||
type,
|
||||
label: INSTRUMENT_LABELS[type],
|
||||
targetPct,
|
||||
actualPct: +actualPct.toFixed(1),
|
||||
actualValue: value,
|
||||
variancePct,
|
||||
statusLabel,
|
||||
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
|
||||
};
|
||||
});
|
||||
private build(
|
||||
bucket: InvestmentBucketReportDto,
|
||||
interest: InvestmentInterestReportDto,
|
||||
purchases: InvestmentPurchaseDto[],
|
||||
fds: InvestmentFdDto[],
|
||||
fdLimit: InvestmentFdLimitDto | null,
|
||||
nonSlr: InvestmentNonSlrLimitDto
|
||||
): void {
|
||||
const purchaseById = new Map<number, InvestmentPurchaseDto>(
|
||||
purchases.map((p) => [
|
||||
p.id,
|
||||
p
|
||||
])
|
||||
);
|
||||
const fdById = new Map<number, InvestmentFdDto>(
|
||||
fds
|
||||
.filter((f) => f.id != null)
|
||||
.map((f) => [
|
||||
f.id as number,
|
||||
f
|
||||
])
|
||||
);
|
||||
const receivable = new Map<string, number>(
|
||||
interest.rows.map((x) => [
|
||||
x.instrument + ':' + x.refId,
|
||||
x.receivable
|
||||
])
|
||||
);
|
||||
this.totalReceivable = interest.totalReceivable;
|
||||
this.totalProvisioned = interest.totalProvisioned;
|
||||
|
||||
// Bucket rows are the Govt Securities, bonds and FDs held today; the purchase tells a Non-SLR from a Bond.
|
||||
const typeOf = (r: InvestmentBucketRowDto): InstrumentType =>
|
||||
r.instrument === 'FD' ? 'FD' : (purchaseById.get(r.refId)?.bondType ?? 'BOND');
|
||||
const acc = new Map<InstrumentType, InstrumentSummary>();
|
||||
const sum = (type: InstrumentType): InstrumentSummary => {
|
||||
let s = acc.get(type);
|
||||
if (!s) {
|
||||
s = {
|
||||
type,
|
||||
label: INSTRUMENT_LABELS[type],
|
||||
route: ROUTE[type],
|
||||
count: 0,
|
||||
faceValue: 0,
|
||||
bookValue: 0,
|
||||
accruedInterest: 0,
|
||||
maturityValue: 0,
|
||||
sharePct: 0
|
||||
};
|
||||
acc.set(type, s);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
for (const r of bucket.rows) {
|
||||
const s = sum(typeOf(r));
|
||||
s.count += 1;
|
||||
s.faceValue += r.faceValue;
|
||||
s.bookValue += r.bookValue;
|
||||
s.accruedInterest += receivable.get((r.instrument === 'FD' ? 'FD' : 'SECURITY') + ':' + r.refId) ?? 0;
|
||||
// A security is redeemed at face value; an FD pays its maturity amount (just the principal on a payout FD).
|
||||
s.maturityValue +=
|
||||
r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue;
|
||||
}
|
||||
// Mutual funds and shares have no maturity, so they are not in the buckets; their units are held at cost.
|
||||
for (const p of purchases) {
|
||||
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
|
||||
const s = sum(p.bondType);
|
||||
s.count += 1;
|
||||
s.faceValue += p.faceValue;
|
||||
s.bookValue += p.bookValue;
|
||||
s.maturityValue += p.bookValue;
|
||||
}
|
||||
}
|
||||
const order: InstrumentType[] = [
|
||||
'GOVT_SECURITY',
|
||||
'FD',
|
||||
'NON_SLR',
|
||||
'BOND',
|
||||
'MUTUAL_FUND',
|
||||
'SHARE'
|
||||
];
|
||||
const total = [...acc.values()].reduce((t, s) => t + s.bookValue, 0);
|
||||
this.summaries = order
|
||||
.map((t) => sum(t))
|
||||
.map((s) => ({ ...s, sharePct: total > 0 ? (s.bookValue / total) * 100 : 0 }));
|
||||
|
||||
this.limits = [];
|
||||
this.limitNote = '';
|
||||
if (fdLimit?.grossLimit != null) {
|
||||
const used = fdLimit.fdOutstanding + fdLimit.bankBalances;
|
||||
this.limits.push(
|
||||
this.limitRow(
|
||||
'Deposits with other banks',
|
||||
'FDs + current accounts, ≤ 20% of deposits',
|
||||
fdLimit.grossLimit,
|
||||
used
|
||||
)
|
||||
);
|
||||
}
|
||||
if (nonSlr.nonSlrLimit != null) {
|
||||
this.limits.push(
|
||||
this.limitRow(
|
||||
'Non-SLR investments',
|
||||
'Bonds, CP / CD, MF, ≤ 10% of deposits',
|
||||
nonSlr.nonSlrLimit,
|
||||
nonSlr.nonSlrOutstanding
|
||||
)
|
||||
);
|
||||
this.limits.push(
|
||||
this.limitRow(
|
||||
'Unlisted non-SLR',
|
||||
`≤ 10% of non-SLR held on ${nonSlr.asOnDate}`,
|
||||
nonSlr.unlistedLimit,
|
||||
nonSlr.unlistedOutstanding
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.limitNote = `Total deposits as on ${nonSlr.asOnDate} are not entered yet; enter them on the FD screen to see the RBI limits.`;
|
||||
}
|
||||
|
||||
this.maturingSoon = bucket.rows
|
||||
.filter((r) => r.daysToMaturity <= 90)
|
||||
.map((r) => ({
|
||||
dealNo: r.dealNo,
|
||||
particulars: r.particulars,
|
||||
instrument: INSTRUMENT_LABELS[typeOf(r)],
|
||||
maturityDate: r.maturityDate,
|
||||
days: r.daysToMaturity,
|
||||
amount: r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue
|
||||
}));
|
||||
}
|
||||
|
||||
private limitRow(label: string, basis: string, limit: number, used: number): LimitRow {
|
||||
return { label, basis, limit, used, usedPct: limit > 0 ? (used / limit) * 100 : null, within: used <= limit };
|
||||
}
|
||||
|
||||
get totalHoldings(): number {
|
||||
return ALL_HOLDINGS.length;
|
||||
return this.summaries.reduce((t, s) => t + s.count, 0);
|
||||
}
|
||||
|
||||
get totalFaceValue(): number {
|
||||
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
|
||||
return this.summaries.reduce((t, s) => t + s.faceValue, 0);
|
||||
}
|
||||
|
||||
get totalBookValue(): number {
|
||||
return this.summaries.reduce((t, s) => t + s.bookValue, 0);
|
||||
}
|
||||
|
||||
get totalAccruedInterest(): number {
|
||||
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
|
||||
return this.summaries.reduce((t, s) => t + s.accruedInterest, 0);
|
||||
}
|
||||
|
||||
get totalMaturityValue(): number {
|
||||
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
|
||||
return this.summaries.reduce((t, s) => t + s.maturityValue, 0);
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
|
||||
get overdueCount(): number {
|
||||
return this.maturingSoon.filter((m) => m.days < 0).length;
|
||||
}
|
||||
|
||||
get maturedCount(): number {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
|
||||
}
|
||||
|
||||
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
|
||||
get maturingSoon(): InvestmentHolding[] {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
|
||||
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
|
||||
);
|
||||
}
|
||||
|
||||
daysLeft(h: InvestmentHolding): number {
|
||||
return daysToMaturity(h, this.asOnDate);
|
||||
}
|
||||
|
||||
instrumentLabel(type: InstrumentType): string {
|
||||
return INSTRUMENT_LABELS[type];
|
||||
/** Holdings of one or more instrument types, for the Quick Entry cards. */
|
||||
countOf(...types: InstrumentType[]): number {
|
||||
return this.summaries.filter((s) => types.includes(s.type)).reduce((t, s) => t + s.count, 0);
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
@@ -176,54 +307,97 @@ export class InvestmentDashboardComponent {
|
||||
'Instrument Type',
|
||||
'Holdings',
|
||||
'Face Value',
|
||||
'Accrued Interest',
|
||||
'Book Value',
|
||||
'Share %',
|
||||
'Interest Receivable',
|
||||
'Maturity Value'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
12,
|
||||
22,
|
||||
10,
|
||||
18,
|
||||
18,
|
||||
10,
|
||||
18,
|
||||
18
|
||||
],
|
||||
rows: this.summaries.map((s) => [
|
||||
s.label,
|
||||
s.count,
|
||||
s.faceValue,
|
||||
s.accruedInterest,
|
||||
s.maturityValue
|
||||
])
|
||||
rows: [
|
||||
...this.summaries.map((s) => [
|
||||
s.label,
|
||||
s.count,
|
||||
s.faceValue,
|
||||
s.bookValue,
|
||||
Number(s.sharePct.toFixed(2)),
|
||||
s.accruedInterest,
|
||||
s.maturityValue
|
||||
]),
|
||||
[
|
||||
'Total',
|
||||
this.totalHoldings,
|
||||
this.totalFaceValue,
|
||||
this.totalBookValue,
|
||||
100,
|
||||
this.totalAccruedInterest,
|
||||
this.totalMaturityValue
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
sheetName: 'Portfolio Allocation',
|
||||
title: 'Portfolio Allocation — Target vs Actual',
|
||||
sheetName: 'RBI Limits',
|
||||
title: 'RBI Limits',
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Instrument Type',
|
||||
'Target Basis',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Actual Value',
|
||||
'Variance %',
|
||||
'Limit',
|
||||
'Basis',
|
||||
'Allowed',
|
||||
'Used',
|
||||
'Used %',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
20,
|
||||
12,
|
||||
12,
|
||||
40,
|
||||
18,
|
||||
18,
|
||||
10,
|
||||
14
|
||||
],
|
||||
rows: this.limits.map((l) => [
|
||||
l.label,
|
||||
l.basis,
|
||||
l.limit,
|
||||
l.used,
|
||||
l.usedPct === null ? null : Number(l.usedPct.toFixed(2)),
|
||||
l.within ? 'Within' : 'Above limit'
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Maturing in 90 days',
|
||||
title: 'Maturing Within 90 Days (and overdue)',
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Deal No',
|
||||
'Investment',
|
||||
'Particulars',
|
||||
'Maturity',
|
||||
'Days',
|
||||
'Amount'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
16,
|
||||
40,
|
||||
12,
|
||||
10,
|
||||
18
|
||||
],
|
||||
rows: this.allocationVsTarget.map((a) => [
|
||||
a.label,
|
||||
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
|
||||
a.targetPct,
|
||||
a.actualPct,
|
||||
a.actualValue,
|
||||
a.variancePct,
|
||||
a.statusLabel
|
||||
rows: this.maturingSoon.map((m) => [
|
||||
m.dealNo,
|
||||
m.instrument,
|
||||
m.particulars,
|
||||
m.maturityDate,
|
||||
m.days,
|
||||
m.amount
|
||||
])
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user