mirror of https://github.com/ghostfolio/ghostfolio
28 changed files with 1074 additions and 10 deletions
@ -0,0 +1,188 @@ |
|||
import { |
|||
activityDummyData, |
|||
loadExportFile, |
|||
symbolProfileDummyData, |
|||
userDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { applySplitAdjustments } from '@ghostfolio/api/helper/portfolio.helper'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
import { join } from 'node:path'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculator', () => { |
|||
let exportResponse: ExportResponse; |
|||
|
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeAll(() => { |
|||
exportResponse = loadExportFile( |
|||
join( |
|||
__dirname, |
|||
'../../../../../../../test/import/ok/nvda-buy-with-split-and-sell.json' |
|||
) |
|||
); |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with NVDA buy (before a 10:1 split) and sell', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2024-06-14').getTime()); |
|||
|
|||
const activities: Activity[] = exportResponse.activities.map( |
|||
(activity) => ({ |
|||
...activityDummyData, |
|||
...activity, |
|||
date: parseDate(activity.date), |
|||
feeInAssetProfileCurrency: activity.fee, |
|||
feeInBaseCurrency: activity.fee, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: activity.currency, |
|||
dataSource: activity.dataSource, |
|||
name: 'NVIDIA Corporation', |
|||
symbol: activity.symbol |
|||
}, |
|||
unitPriceInAssetProfileCurrency: activity.unitPrice |
|||
}) |
|||
); |
|||
|
|||
// Mirrors the production flow in
|
|||
// ActivitiesService.getActivitiesForPortfolioCalculator
|
|||
const adjustedActivities = applySplitAdjustments({ |
|||
activities, |
|||
splits: [ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2024-06-10'), |
|||
factor: 10, |
|||
symbol: 'NVDA' |
|||
} |
|||
] |
|||
}); |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities: adjustedActivities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: exportResponse.user.settings.currency, |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 2, |
|||
averagePrice: new Big('0'), |
|||
currency: 'USD', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2024-06-03', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('0'), |
|||
feeInBaseCurrency: new Big('0'), |
|||
grossPerformance: new Big('50'), // 10 × (125 - 120) = 50
|
|||
grossPerformanceWithCurrencyEffect: new Big('50'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('50'), |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('50') |
|||
}, |
|||
marketPrice: 125, |
|||
marketPriceInBaseCurrency: 125, |
|||
quantity: new Big('0'), |
|||
symbol: 'NVDA', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('1200'), // 10 × 120 = 1200
|
|||
timeWeightedInvestmentWithCurrencyEffect: new Big('1200'), |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('0'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2024-06-03', investment: new Big('1200') }, |
|||
{ date: '2024-06-14', investment: new Big('0') } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,176 @@ |
|||
import { DataSource } from '@prisma/client'; |
|||
|
|||
import { applySplitAdjustments } from './portfolio.helper'; |
|||
|
|||
describe('applySplitAdjustments', () => { |
|||
const symbolProfile = { |
|||
dataSource: DataSource.YAHOO, |
|||
symbol: 'NVDA' |
|||
}; |
|||
|
|||
const otherSymbolProfile = { |
|||
dataSource: DataSource.YAHOO, |
|||
symbol: 'AAPL' |
|||
}; |
|||
|
|||
const createActivity = ({ |
|||
date, |
|||
fee = 1, |
|||
quantity, |
|||
SymbolProfile = symbolProfile, |
|||
unitPrice |
|||
}: { |
|||
date: Date; |
|||
fee?: number; |
|||
quantity: number; |
|||
SymbolProfile?: typeof symbolProfile; |
|||
unitPrice: number; |
|||
}) => { |
|||
return { |
|||
date, |
|||
fee, |
|||
quantity, |
|||
SymbolProfile, |
|||
unitPrice, |
|||
unitPriceInAssetProfileCurrency: unitPrice |
|||
}; |
|||
}; |
|||
|
|||
it('should return activities unchanged if there are no splits', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2024-01-15'), |
|||
quantity: 10, |
|||
unitPrice: 400 |
|||
}) |
|||
]; |
|||
|
|||
expect(applySplitAdjustments({ activities, splits: [] })).toStrictEqual( |
|||
activities |
|||
); |
|||
}); |
|||
|
|||
it('should adjust an activity before a split', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2024-01-15'), |
|||
quantity: 10, |
|||
unitPrice: 400 |
|||
}) |
|||
]; |
|||
|
|||
const [activity] = applySplitAdjustments({ |
|||
activities, |
|||
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }] |
|||
}); |
|||
|
|||
expect(activity.quantity).toBe(40); |
|||
expect(activity.unitPrice).toBe(100); |
|||
expect(activity.unitPriceInAssetProfileCurrency).toBe(100); |
|||
}); |
|||
|
|||
it('should keep the value and the fee unchanged', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2024-01-15'), |
|||
fee: 7, |
|||
quantity: 3, |
|||
unitPrice: 700 |
|||
}) |
|||
]; |
|||
|
|||
const [activity] = applySplitAdjustments({ |
|||
activities, |
|||
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }] |
|||
}); |
|||
|
|||
expect(activity.quantity * activity.unitPrice).toBe(3 * 700); |
|||
expect(activity.fee).toBe(7); |
|||
}); |
|||
|
|||
it('should not adjust an activity on or after the split date', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2024-06-10'), |
|||
quantity: 10, |
|||
unitPrice: 100 |
|||
}), |
|||
createActivity({ |
|||
date: new Date('2024-07-01'), |
|||
quantity: 20, |
|||
unitPrice: 110 |
|||
}) |
|||
]; |
|||
|
|||
const adjustedActivities = applySplitAdjustments({ |
|||
activities, |
|||
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }] |
|||
}); |
|||
|
|||
expect(adjustedActivities).toStrictEqual(activities); |
|||
}); |
|||
|
|||
it('should adjust for a reverse split', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2024-01-15'), |
|||
quantity: 100, |
|||
unitPrice: 2 |
|||
}) |
|||
]; |
|||
|
|||
const [activity] = applySplitAdjustments({ |
|||
activities, |
|||
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 0.1 }] |
|||
}); |
|||
|
|||
expect(activity.quantity).toBe(10); |
|||
expect(activity.unitPrice).toBe(20); |
|||
}); |
|||
|
|||
it('should compound multiple splits', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2023-01-15'), |
|||
quantity: 10, |
|||
unitPrice: 600 |
|||
}), |
|||
createActivity({ |
|||
date: new Date('2024-01-15'), |
|||
quantity: 10, |
|||
unitPrice: 300 |
|||
}) |
|||
]; |
|||
|
|||
const adjustedActivities = applySplitAdjustments({ |
|||
activities, |
|||
splits: [ |
|||
{ ...symbolProfile, date: new Date('2023-06-10'), factor: 2 }, |
|||
{ ...symbolProfile, date: new Date('2024-06-10'), factor: 3 } |
|||
] |
|||
}); |
|||
|
|||
expect(adjustedActivities[0].quantity).toBe(60); |
|||
expect(adjustedActivities[0].unitPrice).toBe(100); |
|||
expect(adjustedActivities[1].quantity).toBe(30); |
|||
expect(adjustedActivities[1].unitPrice).toBe(100); |
|||
}); |
|||
|
|||
it('should not adjust activities of other symbols', () => { |
|||
const activities = [ |
|||
createActivity({ |
|||
date: new Date('2024-01-15'), |
|||
quantity: 10, |
|||
SymbolProfile: otherSymbolProfile, |
|||
unitPrice: 190 |
|||
}) |
|||
]; |
|||
|
|||
const adjustedActivities = applySplitAdjustments({ |
|||
activities, |
|||
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }] |
|||
}); |
|||
|
|||
expect(adjustedActivities).toStrictEqual(activities); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,12 @@ |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AssetProfileSplitService } from './asset-profile-split.service'; |
|||
|
|||
@Module({ |
|||
exports: [AssetProfileSplitService], |
|||
imports: [PrismaModule], |
|||
providers: [AssetProfileSplitService] |
|||
}) |
|||
export class AssetProfileSplitModule {} |
|||
@ -0,0 +1,104 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { AssetProfileSplit } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class AssetProfileSplitService { |
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async deleteById({ |
|||
dataSource, |
|||
id, |
|||
symbol |
|||
}: AssetProfileIdentifier & { id: string }) { |
|||
return this.prismaService.assetProfileSplit.deleteMany({ |
|||
where: { |
|||
dataSource, |
|||
id, |
|||
symbol |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { |
|||
return this.prismaService.assetProfileSplit.deleteMany({ |
|||
where: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async getSplits({ |
|||
assetProfileIdentifiers |
|||
}: { |
|||
assetProfileIdentifiers: AssetProfileIdentifier[]; |
|||
}): Promise<AssetProfileSplit[]> { |
|||
if (assetProfileIdentifiers.length === 0) { |
|||
return []; |
|||
} |
|||
|
|||
return this.prismaService.assetProfileSplit.findMany({ |
|||
orderBy: [ |
|||
{ |
|||
date: 'asc' |
|||
} |
|||
], |
|||
where: { |
|||
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => { |
|||
return { |
|||
dataSource, |
|||
symbol |
|||
}; |
|||
}) |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async upsert({ |
|||
dataSource, |
|||
date, |
|||
factor, |
|||
symbol |
|||
}: AssetProfileIdentifier & { |
|||
date: Date; |
|||
factor: number; |
|||
}): Promise<AssetProfileSplit> { |
|||
return this.prismaService.assetProfileSplit.upsert({ |
|||
create: { |
|||
dataSource, |
|||
date, |
|||
factor, |
|||
symbol |
|||
}, |
|||
update: { |
|||
factor |
|||
}, |
|||
where: { |
|||
dataSource_date_symbol: { |
|||
dataSource, |
|||
date, |
|||
symbol |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async updateAssetProfileIdentifier( |
|||
oldAssetProfileIdentifier: AssetProfileIdentifier, |
|||
newAssetProfileIdentifier: AssetProfileIdentifier |
|||
) { |
|||
return this.prismaService.assetProfileSplit.updateMany({ |
|||
data: { |
|||
dataSource: newAssetProfileIdentifier.dataSource, |
|||
symbol: newAssetProfileIdentifier.symbol |
|||
}, |
|||
where: { |
|||
dataSource: oldAssetProfileIdentifier.dataSource, |
|||
symbol: oldAssetProfileIdentifier.symbol |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import { IsISO8601, IsNumber, Validate } from 'class-validator'; |
|||
|
|||
import { IsSplitFactorConstraint } from '../validator-constraints/is-split-factor'; |
|||
|
|||
export class CreateAssetProfileSplitDto { |
|||
@IsISO8601() |
|||
date: string; |
|||
|
|||
@IsNumber() |
|||
@Validate(IsSplitFactorConstraint) |
|||
factor: number; |
|||
} |
|||
@ -1,8 +1,9 @@ |
|||
import { MarketData } from '@prisma/client'; |
|||
import { AssetProfileSplit, MarketData } from '@prisma/client'; |
|||
|
|||
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; |
|||
|
|||
export interface AdminMarketDataDetails { |
|||
assetProfile: Partial<EnhancedSymbolProfile>; |
|||
marketData: MarketData[]; |
|||
splits: AssetProfileSplit[]; |
|||
} |
|||
|
|||
@ -1,8 +1,9 @@ |
|||
import { MarketData } from '@prisma/client'; |
|||
import { AssetProfileSplit, MarketData } from '@prisma/client'; |
|||
|
|||
import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface'; |
|||
|
|||
export interface AssetProfileResponse { |
|||
assetProfile: Partial<EnhancedSymbolProfile>; |
|||
marketData: MarketData[]; |
|||
splits: AssetProfileSplit[]; |
|||
} |
|||
|
|||
@ -0,0 +1,15 @@ |
|||
import { |
|||
ValidatorConstraint, |
|||
ValidatorConstraintInterface |
|||
} from 'class-validator'; |
|||
|
|||
@ValidatorConstraint({ name: 'isSplitFactor' }) |
|||
export class IsSplitFactorConstraint implements ValidatorConstraintInterface { |
|||
public defaultMessage() { |
|||
return 'factor must be a positive number other than 1'; |
|||
} |
|||
|
|||
public validate(aFactor: number) { |
|||
return typeof aFactor === 'number' && aFactor > 0 && aFactor !== 1; |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
-- CreateTable |
|||
CREATE TABLE "public"."AssetProfileSplit" ( |
|||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
"dataSource" "public"."DataSource" NOT NULL, |
|||
"date" TIMESTAMP(3) NOT NULL, |
|||
"factor" DOUBLE PRECISION NOT NULL, |
|||
"id" TEXT NOT NULL, |
|||
"symbol" TEXT NOT NULL, |
|||
"updatedAt" TIMESTAMP(3) NOT NULL, |
|||
|
|||
CONSTRAINT "AssetProfileSplit_pkey" PRIMARY KEY ("id") |
|||
); |
|||
|
|||
-- CreateIndex |
|||
CREATE UNIQUE INDEX "AssetProfileSplit_dataSource_date_symbol_key" ON "public"."AssetProfileSplit"("dataSource", "date", "symbol"); |
|||
|
|||
-- CreateIndex |
|||
CREATE INDEX "AssetProfileSplit_dataSource_symbol_idx" ON "public"."AssetProfileSplit"("dataSource", "symbol"); |
|||
|
|||
-- CreateIndex |
|||
CREATE INDEX "AssetProfileSplit_date_idx" ON "public"."AssetProfileSplit"("date"); |
|||
@ -0,0 +1,34 @@ |
|||
{ |
|||
"meta": { |
|||
"date": "2024-06-14T21:28:05.857Z", |
|||
"version": "dev" |
|||
}, |
|||
"activities": [ |
|||
{ |
|||
"fee": 0, |
|||
"quantity": 10, |
|||
"type": "SELL", |
|||
"unitPrice": 125, |
|||
"currency": "USD", |
|||
"dataSource": "YAHOO", |
|||
"date": "2024-06-14T00:00:00.000Z", |
|||
"symbol": "NVDA" |
|||
}, |
|||
{ |
|||
"fee": 0, |
|||
"quantity": 1, |
|||
"type": "BUY", |
|||
"unitPrice": 1200, |
|||
"currency": "USD", |
|||
"dataSource": "YAHOO", |
|||
"date": "2024-06-03T00:00:00.000Z", |
|||
"symbol": "NVDA" |
|||
} |
|||
], |
|||
"user": { |
|||
"settings": { |
|||
"currency": "USD", |
|||
"performanceCalculationType": "ROAI" |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue