Skip to main content

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

  1. Run EOD Once Per Day: Ensure EOD is idempotent or prevent duplicate runs
  2. Scheduled Execution: Run EOD automatically after market close
  3. Monitor Performance: Track EOD execution time for optimization
  4. Validate Results: Review snapshots for anomalies
  5. 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:

  1. Batch Operations: Process securities in batches
  2. Parallel Processing: Run independent operations in parallel
  3. Caching: Cache frequently accessed data (fees configuration)
  4. Optimize Queries: Use efficient database queries

See Also