Browse Source

Task/rename transactionPoints to holdingBalancesByDate (#7923)

Rename transactionPoints to holdingBalancesByDate
task/rename-net-performance-percentages-to-performance-percentages
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
dcf622aa59
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 339
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 20
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  3. 48
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts
  4. 3
      apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts
  5. 6
      apps/api/src/app/portfolio/interfaces/holding-balances-at-date.interface.ts
  6. 11
      apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts
  7. 4
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  8. 22
      apps/api/src/app/portfolio/portfolio.service.ts

339
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -1,6 +1,8 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { HoldingBalance } from '@ghostfolio/api/app/portfolio/interfaces/holding-balance.interface';
import { HoldingBalancesAtDate } from '@ghostfolio/api/app/portfolio/interfaces/holding-balances-at-date.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { HoldingValuationItem } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation-item.interface'; import { HoldingValuationItem } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation-item.interface';
import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface'; import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface';
@ -8,8 +10,6 @@ import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/i
import { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.interface'; import { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type'; import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
@ -95,13 +95,13 @@ export abstract class PortfolioCalculator {
private endDate: Date; private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService; private exchangeRateDataService: ExchangeRateDataService;
private filters: Filter[]; private filters: Filter[];
private holdingBalancesByDate: HoldingBalancesAtDate[];
private portfolioSnapshotService: PortfolioSnapshotService; private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService; private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot; private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>; private snapshotPromise: Promise<void>;
private startDate: Date; private startDate: Date;
private subscriptionType?: SubscriptionType; private subscriptionType?: SubscriptionType;
private transactionPoints: TransactionPoint[];
private usePortfolioSnapshotCache: boolean; private usePortfolioSnapshotCache: boolean;
private userId: string; private userId: string;
@ -204,7 +204,7 @@ export abstract class PortfolioCalculator {
this.endDate = endOfDay(endDate); this.endDate = endOfDay(endDate);
this.startDate = startOfDay(startDate); this.startDate = startOfDay(startDate);
this.computeTransactionPoints(); this.computeHoldingBalancesByDate();
this.snapshotPromise = this.usePortfolioSnapshotCache this.snapshotPromise = this.usePortfolioSnapshotCache
? this.initialize() ? this.initialize()
@ -236,13 +236,15 @@ export abstract class PortfolioCalculator {
@LogPerformance @LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> { public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = this.transactionPoints.at(-1); const latestHoldingBalances = this.holdingBalancesByDate.at(-1);
const transactionPoints = this.transactionPoints?.filter(({ date }) => { const holdingBalancesByDate = this.holdingBalancesByDate?.filter(
({ date }) => {
return isBefore(parseDate(date), this.endDate); return isBefore(parseDate(date), this.endDate);
}); }
);
if (!transactionPoints.length) { if (!holdingBalancesByDate.length) {
return { return {
activitiesCount: 0, activitiesCount: 0,
createdAt: new Date(), createdAt: new Date(),
@ -265,8 +267,6 @@ export abstract class PortfolioCalculator {
const cashAssetProfileIdentifiers = new Set<string>(); const cashAssetProfileIdentifiers = new Set<string>();
const currencies: { [assetProfileIdentifier: string]: string } = {}; const currencies: { [assetProfileIdentifier: string]: string } = {};
const dataGatheringItems: DataGatheringItem[] = []; const dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
let totalCashInBaseCurrency = new Big(0); let totalCashInBaseCurrency = new Big(0);
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
@ -276,7 +276,7 @@ export abstract class PortfolioCalculator {
currency, currency,
dataSource, dataSource,
symbol symbol
} of transactionPoints[firstIndex - 1].items) { } of holdingBalancesByDate.at(-1).holdings) {
// Gather data for all assets except CASH // Gather data for all assets except CASH
if (assetSubClass !== 'CASH') { if (assetSubClass !== 'CASH') {
dataGatheringItems.push({ dataGatheringItems.push({
@ -288,16 +288,6 @@ export abstract class PortfolioCalculator {
currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency;
} }
for (let i = 0; i < transactionPoints.length; i++) {
if (
!isBefore(parseDate(transactionPoints[i].date), this.startDate) &&
firstTransactionPoint === null
) {
firstTransactionPoint = transactionPoints[i];
firstIndex = i;
}
}
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))), currencies: Array.from(new Set(Object.values(currencies))),
@ -362,10 +352,6 @@ export abstract class PortfolioCalculator {
return chartDate; return chartDate;
}); });
if (firstIndex > 0) {
firstIndex--;
}
const errors: ResponseError['errors'] = []; const errors: ResponseError['errors'] = [];
let hasAnyHoldingPerformanceErrors = false; let hasAnyHoldingPerformanceErrors = false;
@ -390,7 +376,7 @@ export abstract class PortfolioCalculator {
}; };
} = {}; } = {};
for (const item of lastTransactionPoint.items) { for (const item of latestHoldingBalances.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier(item); const assetProfileIdentifier = getAssetProfileIdentifier(item);
const marketPriceInBaseCurrency = ( const marketPriceInBaseCurrency = (
@ -909,6 +895,10 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalFeesWithCurrencyEffect; return this.snapshot.totalFeesWithCurrencyEffect;
} }
public getHoldingBalancesByDate() {
return this.holdingBalancesByDate;
}
protected abstract getHoldingPerformance({ protected abstract getHoldingPerformance({
chartDates, chartDates,
dataSource, dataSource,
@ -1300,16 +1290,16 @@ export abstract class PortfolioCalculator {
} }
public getInvestments(): { date: string; investment: Big }[] { public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) { if (this.holdingBalancesByDate.length === 0) {
return []; return [];
} }
return this.transactionPoints.map((transactionPoint) => { return this.holdingBalancesByDate.map(({ date, holdings }) => {
return { return {
date: transactionPoint.date, date,
investment: transactionPoint.items.reduce( investment: holdings.reduce(
(investment, transactionPointSymbol) => (investment, { investment: investmentOfHolding }) =>
investment.plus(transactionPointSymbol.investment), investment.plus(investmentOfHolding),
new Big(0) new Big(0)
) )
}; };
@ -1417,9 +1407,9 @@ export abstract class PortfolioCalculator {
} }
} }
if (this.transactionPoints?.length > 0) { if (this.holdingBalancesByDate?.length > 0) {
try { try {
const firstActivityDateString = this.transactionPoints[0].date; const firstActivityDateString = this.holdingBalancesByDate[0].date;
firstActivityDate = firstActivityDateString firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString) ? parseDate(firstActivityDateString)
: new Date(); : new Date();
@ -1486,10 +1476,6 @@ export abstract class PortfolioCalculator {
}; };
} }
public getTransactionPoints() {
return this.transactionPoints;
}
protected getUnitPriceAtEndDate({ protected getUnitPriceAtEndDate({
activities, activities,
dataSource, dataSource,
@ -1519,101 +1505,15 @@ export abstract class PortfolioCalculator {
return marketPriceAtEndDate; return marketPriceAtEndDate;
} }
private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
const chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (const date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of last 90 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
// Reduce the step size of last 30 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange({ dateRange });
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
// Make sure the first and last date of each calendar year is present
const interval = { start: startDate, end: endDate };
for (const date of eachYearOfInterval(interval)) {
const yearStart = startOfYear(date);
const yearEnd = endOfYear(date);
if (isWithinInterval(yearStart, interval)) {
// Add start of year (YYYY-01-01)
chartDateMap[format(yearStart, DATE_FORMAT)] = true;
}
if (isWithinInterval(yearEnd, interval)) {
// Add end of year (YYYY-12-31)
chartDateMap[format(yearEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap;
}
@LogPerformance @LogPerformance
private computeTransactionPoints() { private computeHoldingBalancesByDate() {
this.transactionPoints = []; this.holdingBalancesByDate = [];
const transactionPointSymbols: { const holdingBalancesByAssetProfileIdentifier: {
[assetProfileIdentifier: string]: TransactionPointSymbol; [assetProfileIdentifier: string]: HoldingBalance;
} = {}; } = {};
let lastDate: string = null; let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null; let latestHoldingBalances: HoldingBalancesAtDate = null;
for (const { for (const {
assetProfile, assetProfile,
@ -1625,7 +1525,7 @@ export abstract class PortfolioCalculator {
type, type,
unitPrice unitPrice
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let holdingBalance: HoldingBalance;
const assetSubClass = assetProfile.assetSubClass; const assetSubClass = assetProfile.assetSubClass;
const currency = assetProfile.currency; const currency = assetProfile.currency;
@ -1636,33 +1536,33 @@ export abstract class PortfolioCalculator {
const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile); const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile);
const oldAccumulatedSymbol = const previousHoldingBalance =
transactionPointSymbols[assetProfileIdentifier]; holdingBalancesByAssetProfileIdentifier[assetProfileIdentifier];
if (oldAccumulatedSymbol) { if (previousHoldingBalance) {
let investment = oldAccumulatedSymbol.investment; let investment = previousHoldingBalance.investment;
let newQuantity = quantity let newQuantity = quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(previousHoldingBalance.quantity);
if (type === 'BUY') { if (type === 'BUY') {
if (oldAccumulatedSymbol.investment.gte(0)) { if (previousHoldingBalance.investment.gte(0)) {
investment = oldAccumulatedSymbol.investment.plus( investment = previousHoldingBalance.investment.plus(
quantity.mul(unitPrice) quantity.mul(unitPrice)
); );
} else { } else {
investment = oldAccumulatedSymbol.investment.plus( investment = previousHoldingBalance.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice) quantity.mul(previousHoldingBalance.averagePrice)
); );
} }
} else if (type === 'SELL') { } else if (type === 'SELL') {
if (oldAccumulatedSymbol.investment.gt(0)) { if (previousHoldingBalance.investment.gt(0)) {
investment = oldAccumulatedSymbol.investment.minus( investment = previousHoldingBalance.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice) quantity.mul(previousHoldingBalance.averagePrice)
); );
} else { } else {
investment = oldAccumulatedSymbol.investment.minus( investment = previousHoldingBalance.investment.minus(
quantity.mul(unitPrice) quantity.mul(unitPrice)
); );
} }
@ -1674,28 +1574,27 @@ export abstract class PortfolioCalculator {
newQuantity = new Big(0); newQuantity = new Big(0);
} }
currentTransactionPointItem = { holdingBalance = {
assetSubClass, assetSubClass,
currency, currency,
dataSource, dataSource,
investment, investment,
skipErrors, skipErrors,
symbol, symbol,
activitiesCount: oldAccumulatedSymbol.activitiesCount + 1, activitiesCount: previousHoldingBalance.activitiesCount + 1,
averagePrice: newQuantity.eq(0) averagePrice: newQuantity.eq(0)
? new Big(0) ? new Big(0)
: investment.div(newQuantity).abs(), : investment.div(newQuantity).abs(),
dateOfFirstActivity: oldAccumulatedSymbol.dateOfFirstActivity, dateOfFirstActivity: previousHoldingBalance.dateOfFirstActivity,
dividend: new Big(0), fee: previousHoldingBalance.fee.plus(fee),
fee: oldAccumulatedSymbol.fee.plus(fee),
feeInBaseCurrency: feeInBaseCurrency:
oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency), previousHoldingBalance.feeInBaseCurrency.plus(feeInBaseCurrency),
includeInHoldings: oldAccumulatedSymbol.includeInHoldings, includeInHoldings: previousHoldingBalance.includeInHoldings,
quantity: newQuantity, quantity: newQuantity,
tags: oldAccumulatedSymbol.tags.concat(tags) tags: previousHoldingBalance.tags.concat(tags)
}; };
} else { } else {
currentTransactionPointItem = { holdingBalance = {
assetSubClass, assetSubClass,
currency, currency,
dataSource, dataSource,
@ -1707,75 +1606,131 @@ export abstract class PortfolioCalculator {
activitiesCount: 1, activitiesCount: 1,
averagePrice: unitPrice, averagePrice: unitPrice,
dateOfFirstActivity: date, dateOfFirstActivity: date,
dividend: new Big(0),
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type), includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor), investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor) quantity: quantity.mul(factor)
}; };
} }
currentTransactionPointItem.tags = uniqBy( holdingBalance.tags = uniqBy(holdingBalance.tags, 'id');
currentTransactionPointItem.tags,
'id'
);
transactionPointSymbols[assetProfileIdentifier] =
currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? []; holdingBalancesByAssetProfileIdentifier[assetProfileIdentifier] =
holdingBalance;
const newItems = items.filter((item) => { const holdings = (latestHoldingBalances?.holdings ?? []).filter(
return getAssetProfileIdentifier(item) !== assetProfileIdentifier; (holding) => {
}); return getAssetProfileIdentifier(holding) !== assetProfileIdentifier;
}
);
newItems.push(currentTransactionPointItem); holdings.push(holdingBalance);
newItems.sort((a, b) => { holdings.sort((a, b) => {
return ( return (
a.symbol?.localeCompare(b.symbol) || a.symbol?.localeCompare(b.symbol) ||
a.dataSource?.localeCompare(b.dataSource) a.dataSource?.localeCompare(b.dataSource)
); );
}); });
let fees = new Big(0); if (lastDate !== date || latestHoldingBalances === null) {
latestHoldingBalances = { date, holdings };
this.holdingBalancesByDate.push(latestHoldingBalances);
} else {
latestHoldingBalances.holdings = holdings;
}
lastDate = date;
}
}
private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add the dates of the holding balances
const chartDateMap = this.holdingBalancesByDate.reduce(
(result, { date }) => {
result[date] = true;
return result;
},
{}
);
if (type === 'FEE') { // 2. Add the dates in between, with the specified step size
fees = fee; for (const date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
} }
let interest = new Big(0); if (step > 1) {
// Reduce the step size of last 90 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (type === 'INTEREST') { // Reduce the step size of last 30 days
interest = quantity.mul(unitPrice); for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
} }
let liabilities = new Big(0); // Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
if (type === 'LIABILITY') { // Make sure some key dates are present
liabilities = quantity.mul(unitPrice); for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange({ dateRange });
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
} }
if (lastDate !== date || lastTransactionPoint === null) { if (
lastTransactionPoint = { !isBefore(dateRangeEnd, startDate) &&
date, !isAfter(dateRangeEnd, endDate)
fees, ) {
interest, chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
liabilities, }
items: newItems }
};
this.transactionPoints.push(lastTransactionPoint); // Make sure the first and last date of each calendar year is present
} else { const interval = { start: startDate, end: endDate };
lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees);
lastTransactionPoint.interest = for (const date of eachYearOfInterval(interval)) {
lastTransactionPoint.interest.plus(interest); const yearStart = startOfYear(date);
lastTransactionPoint.items = newItems; const yearEnd = endOfYear(date);
lastTransactionPoint.liabilities =
lastTransactionPoint.liabilities.plus(liabilities); if (isWithinInterval(yearStart, interval)) {
// Add start of year (YYYY-01-01)
chartDateMap[format(yearStart, DATE_FORMAT)] = true;
} }
lastDate = date; if (isWithinInterval(yearEnd, interval)) {
// Add end of year (YYYY-12-31)
chartDateMap[format(yearEnd, DATE_FORMAT)] = true;
}
} }
return chartDateMap;
} }
@LogPerformance @LogPerformance

20
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -75,7 +75,7 @@ describe('PortfolioCalculator', () => {
); );
}); });
describe('get transaction point', () => { describe('get holding balances by date', () => {
it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', () => { it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime());
@ -137,15 +137,19 @@ describe('PortfolioCalculator', () => {
userId: userDummyData.id userId: userDummyData.id
}); });
const transactionPoints = portfolioCalculator.getTransactionPoints(); const holdingBalancesByDate =
const lastTransactionPoint = portfolioCalculator.getHoldingBalancesByDate();
transactionPoints[transactionPoints.length - 1];
const position = lastTransactionPoint.items.find( const latestHoldingBalances = holdingBalancesByDate.at(-1);
(item) => item.symbol === 'MSFT'
const { investment, quantity } = latestHoldingBalances.holdings.find(
({ symbol }) => {
return symbol === 'MSFT';
}
); );
expect(position.investment.toNumber()).toBe(0); expect(investment.toNumber()).toBe(0);
expect(position.quantity.toNumber()).toBe(0); expect(quantity.toNumber()).toBe(0);
}); });
}); });
}); });

48
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts

@ -85,9 +85,11 @@ describe('RoaiPortfolioCalculator stock splits', () => {
[createSplit({ denominator: 1, numerator: 2 })] [createSplit({ denominator: 1, numerator: 2 })]
); );
const position = getLastPosition(portfolioCalculatorFactory, [activity]); const holdingBalance = getLatestHoldingBalance(portfolioCalculatorFactory, [
activity
]);
expect(position).toMatchObject({ expect(holdingBalance).toMatchObject({
averagePrice: new Big(50), averagePrice: new Big(50),
investment: new Big(1000), investment: new Big(1000),
quantity: new Big(20) quantity: new Big(20)
@ -100,9 +102,11 @@ describe('RoaiPortfolioCalculator stock splits', () => {
[createSplit({ denominator: 10, numerator: 1 })] [createSplit({ denominator: 10, numerator: 1 })]
); );
const position = getLastPosition(portfolioCalculatorFactory, [activity]); const holdingBalance = getLatestHoldingBalance(portfolioCalculatorFactory, [
activity
]);
expect(position).toMatchObject({ expect(holdingBalance).toMatchObject({
averagePrice: new Big(1000), averagePrice: new Big(1000),
investment: new Big(1000), investment: new Big(1000),
quantity: new Big(1) quantity: new Big(1)
@ -121,9 +125,12 @@ describe('RoaiPortfolioCalculator stock splits', () => {
unitPrice: 60 unitPrice: 60
}); });
const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); const holdingBalance = getLatestHoldingBalance(portfolioCalculatorFactory, [
buy,
sell
]);
expect(position).toMatchObject({ expect(holdingBalance).toMatchObject({
averagePrice: new Big(50), averagePrice: new Big(50),
investment: new Big(750), investment: new Big(750),
quantity: new Big(15) quantity: new Big(15)
@ -139,11 +146,15 @@ describe('RoaiPortfolioCalculator stock splits', () => {
] ]
); );
const position = getLastPosition(portfolioCalculatorFactory, [activity]); const holdingBalance = getLatestHoldingBalance(portfolioCalculatorFactory, [
activity
]);
expect(position.averagePrice).toEqual(new Big(150)); expect(holdingBalance.averagePrice).toEqual(new Big(150));
expect(position.quantity.toFixed(15)).toBe(new Big(20).div(3).toFixed(15)); expect(holdingBalance.quantity.toFixed(15)).toBe(
expect(position.investment.toNumber()).toBeCloseTo(1000, 12); new Big(20).div(3).toFixed(15)
);
expect(holdingBalance.investment.toNumber()).toBeCloseTo(1000, 12);
}); });
it('resets quantity and investment when the adjusted position is closed', () => { it('resets quantity and investment when the adjusted position is closed', () => {
@ -158,18 +169,21 @@ describe('RoaiPortfolioCalculator stock splits', () => {
unitPrice: 60 unitPrice: 60
}); });
const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); const holdingBalance = getLatestHoldingBalance(portfolioCalculatorFactory, [
buy,
sell
]);
expect(position.quantity).toEqual(new Big(0)); expect(holdingBalance.quantity).toEqual(new Big(0));
expect(position.investment).toEqual(new Big(0)); expect(holdingBalance.investment).toEqual(new Big(0));
}); });
it('preserves existing behavior when no splits exist', () => { it('preserves existing behavior when no splits exist', () => {
const position = getLastPosition(portfolioCalculatorFactory, [ const holdingBalance = getLatestHoldingBalance(portfolioCalculatorFactory, [
createActivity({ unitPrice: 100 }) createActivity({ unitPrice: 100 })
]); ]);
expect(position).toMatchObject({ expect(holdingBalance).toMatchObject({
averagePrice: new Big(100), averagePrice: new Big(100),
investment: new Big(1000), investment: new Big(1000),
quantity: new Big(10) quantity: new Big(10)
@ -208,7 +222,7 @@ describe('RoaiPortfolioCalculator stock splits', () => {
}); });
}); });
function getLastPosition( function getLatestHoldingBalance(
portfolioCalculatorFactory: PortfolioCalculatorFactory, portfolioCalculatorFactory: PortfolioCalculatorFactory,
activities: Activity[] activities: Activity[]
) { ) {
@ -219,7 +233,7 @@ function getLastPosition(
userId: userDummyData.id userId: userDummyData.id
}); });
return calculator.getTransactionPoints().at(-1).items[0]; return calculator.getHoldingBalancesByDate().at(-1).holdings[0];
} }
function createActivity({ function createActivity({

3
apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts → apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts

@ -1,14 +1,13 @@
import { AssetSubClass, DataSource, Tag } from '@prisma/client'; import { AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
export interface TransactionPointSymbol { export interface HoldingBalance {
activitiesCount: number; activitiesCount: number;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
averagePrice: Big; averagePrice: Big;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
dateOfFirstActivity: string; dateOfFirstActivity: string;
dividend: Big;
fee: Big; fee: Big;
feeInBaseCurrency: Big; feeInBaseCurrency: Big;
includeInHoldings: boolean; includeInHoldings: boolean;

6
apps/api/src/app/portfolio/interfaces/holding-balances-at-date.interface.ts

@ -0,0 +1,6 @@
import { HoldingBalance } from './holding-balance.interface';
export interface HoldingBalancesAtDate {
date: string;
holdings: HoldingBalance[];
}

11
apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts

@ -1,11 +0,0 @@
import { Big } from 'big.js';
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
export interface TransactionPoint {
date: string;
fees: Big;
interest: Big;
items: TransactionPointSymbol[];
liabilities: Big;
}

4
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -646,8 +646,8 @@ describe('PortfolioService', () => {
jest jest
.spyOn(portfolioCalculatorFactory, 'createCalculator') .spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({ .mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({ positions: [] }), getHoldingBalancesByDate: jest.fn().mockReturnValue([]),
getTransactionPoints: jest.fn().mockReturnValue([]) getSnapshot: jest.fn().mockResolvedValue({ positions: [] })
} as unknown as PortfolioCalculator); } as unknown as PortfolioCalculator);
}); });

22
apps/api/src/app/portfolio/portfolio.service.ts

@ -970,7 +970,8 @@ export class PortfolioService {
subscriptionType: user.subscription?.type subscriptionType: user.subscription?.type
}); });
const transactionPoints = portfolioCalculator.getTransactionPoints(); const holdingBalancesByDate =
portfolioCalculator.getHoldingBalancesByDate();
const { positions } = await portfolioCalculator.getSnapshot(); const { positions } = await portfolioCalculator.getSnapshot();
@ -1071,8 +1072,11 @@ export class PortfolioService {
historicalDataItems historicalDataItems
)) { )) {
while ( while (
j + 1 < transactionPoints.length && j + 1 < holdingBalancesByDate.length &&
!isAfter(parseDate(transactionPoints[j + 1].date), parseDate(date)) !isAfter(
parseDate(holdingBalancesByDate[j + 1].date),
parseDate(date)
)
) { ) {
j++; j++;
} }
@ -1080,15 +1084,15 @@ export class PortfolioService {
let currentAveragePrice = 0; let currentAveragePrice = 0;
let currentQuantity = 0; let currentQuantity = 0;
const currentSymbol = transactionPoints[j]?.items.find( const holdingBalance = holdingBalancesByDate[j]?.holdings.find(
(transactionPointSymbol) => { ({ symbol: holdingBalanceSymbol }) => {
return transactionPointSymbol.symbol === symbol; return holdingBalanceSymbol === symbol;
} }
); );
if (currentSymbol) { if (holdingBalance) {
currentAveragePrice = currentSymbol.averagePrice.toNumber(); currentAveragePrice = holdingBalance.averagePrice.toNumber();
currentQuantity = currentSymbol.quantity.toNumber(); currentQuantity = holdingBalance.quantity.toNumber();
} }
historicalDataArray.push({ historicalDataArray.push({

Loading…
Cancel
Save