EOD (End-of-Day) Service
The EOD Service orchestrates the complex end-of-day processing for investment funds, including portfolio valuation, interest accrual, fee calculation, NAV computation, and snapshot creation.
Location
worker/use case/eod/service.ts
Dependencies
The EOD Service depends on nearly all core services:
constructor(
private accountingService: AggregateAccountingService,
private fundService: FundService,
private bankAccountService: BankAccountService,
private fdrService: FdrService,
private equityPortfolioService: EquityPortfolioService,
private bondPortfolioService: BondPortfolioService,
private unrealizedGainLossService: UnrealizedGainLossService,
) {}
Three-Phase Workflow
Input Schema
export interface RunEODRequest {
fundId: number;
businessDate: Date;
}
Phase 1: Portfolio Valuation
Purpose: Value all portfolio positions at market prices and record unrealized gains/losses
1.1 Sync Securities
await this.equityPortfolioService.ensureEquities();
await this.bondPortfolioService.ensureBonds();
Fetches latest security master data from DSE API to ensure we have current securities.
1.2 Generate Portfolio Snapshots
// Equity snapshot
await this.equityPortfolioService.generateDailySnapshot({
fundId,
asOf: businessDate,
});
// Bond snapshot
await this.bondPortfolioService.generateDailySnapshot({
fundId,
asOf: businessDate,
});
Creates point-in-time snapshots of all holdings with:
- Quantity held
- Cost basis
- Current market price
- Market value (quantity × price)
1.3 Create Unrealized Gain/Loss Entries
await this.unrealizedGainLossService.createEquityUnrealizedGainLossEntries(
fundId,
businessDate,
);
await this.unrealizedGainLossService.createBondUnrealizedGainLossEntries(
fundId,
businessDate,
);
For each position, creates journal entries:
If market value > cost basis (gain):
Dr. Investment in Securities (increase asset)
Cr. Unrealized Gain (increase revenue)
If market value < cost basis (loss):
Dr. Unrealized Loss (increase expense)
Cr. Investment in Securities (decrease asset)
Phase 2: Interest Accrual & Depreciation
Purpose: Accrue daily interest and record depreciation
2.1 Bank Account Interest
private async processBankAccountInterest(fundId: number, businessDate: Date) {
const bankAccounts = await this.bankAccountService.getActiveBankAccounts(fundId);
for (const account of bankAccounts) {
const dailyInterest = this.calculateDailyInterest(account);
if (dailyInterest > 0) {
await this.createInterestAccrualEntry(fundId, account.id, dailyInterest, businessDate);
}
}
}
private calculateDailyInterest(account: BankAccountEntity): number {
const dailyRate = percentageToDecimal(account.interestRate) / DAYS_PER_YEAR;
return Math.round(account.currentBalance * dailyRate);
}
Journal Entry:
Dr. Accrued Interest Receivable (Bank)
Cr. Bank Interest Income
2.2 FDR Interest
private async processFdrInterest(fundId: number, businessDate: Date) {
const fdrs = await this.fdrService.getActiveFdrsByFund(fundId);
for (const fdr of fdrs) {
const dailyInterest = this.calculateFdrDailyInterest(fdr);
if (dailyInterest > 0) {
await this.createFdrInterestAccrualEntry(fundId, fdr.id, dailyInterest, businessDate);
}
}
}
private calculateFdrDailyInterest(fdr: FdrEntity): number {
const dailyRate = annualBpsToDailyRate(fdr.interestRateBps);
return Math.round(fdr.principal * dailyRate);
}
Journal Entry:
Dr. Accrued Interest Receivable (FDR)
Cr. FDR Interest Income
2.3 Bond Coupon Interest
private async processBondInterest(fundId: number, businessDate: Date) {
const bondHoldings = await this.bondPortfolioService.getHoldingsForDate({
fundId,
asOf: businessDate,
});
for (const holding of bondHoldings) {
if (holding.quantitySaleable <= 0) continue;
const bonds = await this.bondPortfolioService.getBondsByIds([holding.bondId]);
const bond = bonds[0];
const dailyInterest = this.calculateBondDailyInterest(
holding.quantitySaleable,
bond.faceValueMinorUnits,
bond.couponRateBps,
);
if (dailyInterest > 0) {
await this.createBondInterestAccrualEntry(
fundId,
holding.bondId,
dailyInterest,
businessDate,
);
}
}
}
private calculateBondDailyInterest(
quantity: number,
faceValueMinorUnits: number,
couponRateBps: number,
): number {
// Annual coupon = quantity × face value × coupon rate (in bps)
const annualCoupon = (quantity * faceValueMinorUnits * couponRateBps) / BASIS_POINTS_SCALE;
// Daily interest = annual coupon / days per year
return Math.round(annualCoupon / DAYS_PER_YEAR);
}
Journal Entry:
Dr. Accrued Interest Receivable (Bond)
Cr. Bond Interest Income
2.4 Depreciation/Amortization
private async processDepreciationForFund(fundId: number, businessDate: Date) {
const assets = await this.accountingService.getAmortizableAssetsByFund(fundId);
const accounts = await this.accountingService.getAccountsByFundId(fundId);
for (const asset of assets.filter((a) => a.status === "active")) {
const dailyDepreciation = Math.round(
asset.acquisitionCost / ((asset.usefulLifeMonths / MONTHS_PER_YEAR) * DAYS_PER_YEAR),
);
if (dailyDepreciation <= 0) continue;
await this.accountingService.createJournalEntry({
fundId,
description: `Daily depreciation - ${asset.name}`,
createdBy: "test-user-id-1",
date: businessDate,
lines: [
{ accountId: depreciationExpense.id, amount: dailyDepreciation },
{ accountId: accumulatedDepreciation.id, amount: -dailyDepreciation },
],
});
}
}
Journal Entry:
Dr. Depreciation Expense
Cr. Accumulated Depreciation - Formation Expenses
Phase 3: NAV Calculation & Snapshot
Purpose: Calculate NAV and create comprehensive fund snapshot
3.1 Calculate Pre-Fee NAV
const preFeeNav = await this.accountingService.getNetAssetValueAsOf(
fundId,
businessDate,
);
Formula:
Pre-Fee NAV = Total Assets - Total Liabilities
All Phase 1 and Phase 2 journal entries are included in this calculation.
3.2 Calculate Fees
const fees = await this.fundService.calculateAllFees(
fundId,
preFeeNav, // Use pre-fee NAV for fee calculations
preFeeNav, // Total assets = pre-fee NAV
preFeeNav, // Initial fund size = pre-fee NAV (for BSEC calculation)
0, // Months since formation (for formation fee)
);
Calculates:
- Management Fee: Tiered based on NAV
- Trustee Fee: Percentage of total assets
- Custodian Fee: Percentage of total assets
- CDBL Fee: Fixed annual amount (prorated daily)
- BSEC Fee: Registration + NAV-based
- Formation Fee: Amortized over 5 years
3.3 Calculate Post-Fee NAV
const postFeeNav = preFeeNav - fees.totalFees;
3.4 Calculate NAV Per Unit
const navPerUnit = Math.round(postFeeNav / DEFAULT_TOTAL_UNITS);
3.5 Collect Snapshot Data
const snapshotData = {
totalAssets: await this.accountingService.getTotalByAccountTypeAsOf(
fundId,
"asset",
businessDate,
),
totalLiabilities: await this.accountingService.getTotalByAccountTypeAsOf(
fundId,
"liability",
businessDate,
),
totalEquity: await this.accountingService.getTotalByAccountTypeAsOf(
fundId,
"equity",
businessDate,
),
portfolio: {
equity: await this.getEquityPortfolioData(fundId, businessDate),
bonds: { totalValue: 0, holdings: [] },
fdr: { totalValue: 0, holdings: [] },
bankAccounts: { totalValue: 0, accounts: [] },
},
accounting: {
assets: await this.getAccountBalancesByType(fundId, "asset", businessDate),
liabilities: await this.getAccountBalancesByType(
fundId,
"liability",
businessDate,
),
equity: await this.getAccountBalancesByType(fundId, "equity", businessDate),
revenue: await this.getAccountBalancesByType(
fundId,
"revenue",
businessDate,
),
expenses: await this.getAccountBalancesByType(
fundId,
"expense",
businessDate,
),
},
fees: {
managementFee: fees.managementFee,
trusteeFee: fees.trusteeFee,
custodianFee: fees.custodianFee,
cdblFee: fees.cdblFee,
bsecFee: fees.bsecFee,
formationFee: fees.formationFee,
totalFees: fees.totalFees,
},
metadata: {
totalUnits: DEFAULT_TOTAL_UNITS,
navPerUnit: navPerUnit,
calculationTimestamp: businessDate.toISOString(),
eodProcessedBy: 1,
},
};
3.6 Create Snapshot
await this.fundService.createSnapshot({
fundId,
businessDate,
nav: postFeeNav,
snapshot: snapshotData,
});
Usage Example
await eodService.runEODForFund({
fundId: 1,
businessDate: new Date("2024-01-31"),
});
// Verify snapshot was created
const snapshot = await fundService.getSnapshot(1, new Date("2024-01-31"));
console.log(`NAV: ${snapshot.nav}`);
console.log(`NAV per unit: ${snapshot.snapshot.metadata.navPerUnit}`);
console.log(`Total fees: ${snapshot.snapshot.fees.totalFees}`);
Sequence of Operations
Error Handling
async runEODForFund(request: RunEODRequest): Promise<void> {
try {
await this.fundService.updateEODDate(request.fundId, request.businessDate);
// PHASE 1
try {
await this.processPortfolioValuation(request.fundId, request.businessDate);
} catch (error) {
console.error(`Portfolio valuation failed for fund ${request.fundId}:`, error);
throw error; // Critical - cannot proceed without portfolio valuation
}
// PHASE 2
try {
await this.processDepreciationForFund(request.fundId, request.businessDate);
await this.processBankAccountInterest(request.fundId, request.businessDate);
await this.processFdrInterest(request.fundId, request.businessDate);
await this.processBondInterest(request.fundId, request.businessDate);
} catch (error) {
console.error(`Interest/depreciation processing failed:`, error);
// Continue to Phase 3 - interest accrual failure shouldn't block NAV calculation
}
// PHASE 3
await this.createFundSnapshot(request.fundId, request.businessDate);
} catch (error) {
console.error(`EOD processing failed for fund ${request.fundId}:`, error);
throw error;
}
}
Best Practices
- Run EOD Once Per Day: Ensure EOD is idempotent or prevent duplicate runs
- Scheduled Execution: Run EOD automatically after market close
- Monitor Performance: Track EOD execution time for optimization
- Validate Results: Review snapshots for anomalies
- Handle Holidays: Skip EOD on non-business days
Common Issues
Issue: Portfolio valuation fails
Cause: Market data not available for securities
Solution: Ensure DSE API is accessible, handle missing prices gracefully
Issue: NAV calculation incorrect
Cause: Interest not accrued properly or fees miscalculated
Solution: Verify all Phase 2 journal entries created successfully
Issue: EOD runs twice for same date
Cause: No idempotency check
Solution: Check if snapshot already exists before processing
Performance Considerations
EOD processing can be slow for funds with many positions:
- Batch Operations: Process securities in batches
- Parallel Processing: Run independent operations in parallel
- Caching: Cache frequently accessed data (fees configuration)
- Optimize Queries: Use efficient database queries
See Also
- Fund Service API - Fee calculations and snapshots
- Portfolio Services API - Portfolio valuation
- Accounting Service API - NAV calculation
- Unrealized G/L Service - Unrealized gain/loss entries