mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
### 為什麼改 (Why) - **變更目標**:同步 Ghostfolio 官方最新 upstream/main 主線,並解除合流衝突。 - **動機與痛點**:使 PR 能在 GitHub 上呈現綠色可直接合併狀態 (Able to merge)。 ### 改了什麼 (What) - **合流衝突解除**:成功合併 `CHANGELOG.md` (放置於 3.44.0 區塊)、`config.ts` (`as const` 與 `zh-TW`) 及 `user-account-settings.html`。 ### 驗證狀態 (Verification) - [x] 衝突全數解除,0 Conflict Markers。pull/7565/head
606 changed files with 63774 additions and 43897 deletions
@ -0,0 +1,72 @@ |
|||
--- |
|||
name: karpathy-guidelines |
|||
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. |
|||
license: MIT |
|||
--- |
|||
|
|||
# Karpathy Guidelines |
|||
|
|||
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls. |
|||
|
|||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. |
|||
|
|||
## 1. Think Before Coding |
|||
|
|||
**Don't assume. Don't hide confusion. Surface tradeoffs.** |
|||
|
|||
Before implementing: |
|||
|
|||
- State your assumptions explicitly. If uncertain, ask. |
|||
- If multiple interpretations exist, present them - don't pick silently. |
|||
- If a simpler approach exists, say so. Push back when warranted. |
|||
- If something is unclear, stop. Name what's confusing. Ask. |
|||
|
|||
## 2. Simplicity First |
|||
|
|||
**Minimum code that solves the problem. Nothing speculative.** |
|||
|
|||
- No features beyond what was asked. |
|||
- No abstractions for single-use code. |
|||
- No "flexibility" or "configurability" that wasn't requested. |
|||
- No error handling for impossible scenarios. |
|||
- If you write 200 lines and it could be 50, rewrite it. |
|||
|
|||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. |
|||
|
|||
## 3. Surgical Changes |
|||
|
|||
**Touch only what you must. Clean up only your own mess.** |
|||
|
|||
When editing existing code: |
|||
|
|||
- Don't "improve" adjacent code, comments, or formatting. |
|||
- Don't refactor things that aren't broken. |
|||
- Match existing style, even if you'd do it differently. |
|||
- If you notice unrelated dead code, mention it - don't delete it. |
|||
|
|||
When your changes create orphans: |
|||
|
|||
- Remove imports/variables/functions that YOUR changes made unused. |
|||
- Don't remove pre-existing dead code unless asked. |
|||
|
|||
The test: Every changed line should trace directly to the user's request. |
|||
|
|||
## 4. Goal-Driven Execution |
|||
|
|||
**Define success criteria. Loop until verified.** |
|||
|
|||
Transform tasks into verifiable goals: |
|||
|
|||
- "Add validation" → "Write tests for invalid inputs, then make them pass" |
|||
- "Fix the bug" → "Write a test that reproduces it, then make it pass" |
|||
- "Refactor X" → "Ensure tests pass before and after" |
|||
|
|||
For multi-step tasks, state a brief plan: |
|||
|
|||
``` |
|||
1. [Step] → verify: [check] |
|||
2. [Step] → verify: [check] |
|||
3. [Step] → verify: [check] |
|||
``` |
|||
|
|||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. |
|||
@ -0,0 +1 @@ |
|||
../../.agents/skills/karpathy-guidelines |
|||
@ -0,0 +1,14 @@ |
|||
merge: Resolve merge conflicts with upstream main |
|||
|
|||
### 為什麼改 (Why) |
|||
|
|||
- **變更目標**:同步 Ghostfolio 官方最新 upstream/main 主線,並解除合流衝突。 |
|||
- **動機與痛點**:使 PR 能在 GitHub 上呈現綠色可直接合併狀態 (Able to merge)。 |
|||
|
|||
### 改了什麼 (What) |
|||
|
|||
- **合流衝突解除**:成功合併 `CHANGELOG.md` (放置於 3.44.0 區塊)、`config.ts` (`as const` 與 `zh-TW`) 及 `user-account-settings.html`。 |
|||
|
|||
### 驗證狀態 (Verification) |
|||
|
|||
- [x] 衝突全數解除,0 Conflict Markers。 |
|||
@ -0,0 +1 @@ |
|||
min-release-age=7 |
|||
@ -1,6 +1,6 @@ |
|||
import { Account } from '@prisma/client'; |
|||
import { AccountWithBalance } from '@ghostfolio/common/types'; |
|||
|
|||
export interface CashDetails { |
|||
accounts: Account[]; |
|||
accounts: AccountWithBalance[]; |
|||
balanceInBaseCurrency: number; |
|||
} |
|||
|
|||
@ -0,0 +1,21 @@ |
|||
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; |
|||
import { DateRange } from '@ghostfolio/common/types'; |
|||
|
|||
import { Type as ActivityType } from '@prisma/client'; |
|||
import { Transform, TransformFnParams } from 'class-transformer'; |
|||
import { IsEnum, IsOptional, Matches } from 'class-validator'; |
|||
import { isString } from 'lodash'; |
|||
|
|||
export class ActivitiesFilterDto extends FilterDto { |
|||
@IsEnum(ActivityType, { each: true }) |
|||
@IsOptional() |
|||
@Transform(({ value }: TransformFnParams) => { |
|||
return isString(value) ? value.split(',') : value; |
|||
}) |
|||
activityTypes?: ActivityType[]; |
|||
|
|||
@IsOptional() |
|||
@Matches(DATE_RANGE_PATTERN) |
|||
range?: DateRange; |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
import { Prisma } from '@prisma/client'; |
|||
import { Type } from 'class-transformer'; |
|||
import { IsIn, IsInt, IsOptional, Min } from 'class-validator'; |
|||
|
|||
import { ActivitiesFilterDto } from './activities-filter.dto'; |
|||
|
|||
export class GetActivitiesDto extends ActivitiesFilterDto { |
|||
@IsInt() |
|||
@IsOptional() |
|||
@Min(0) |
|||
@Type(() => Number) |
|||
skip?: number; |
|||
|
|||
@IsIn(Object.values(Prisma.OrderScalarFieldEnum)) |
|||
@IsOptional() |
|||
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum; |
|||
|
|||
@IsIn(['asc', 'desc'] as Prisma.SortOrder[]) |
|||
@IsOptional() |
|||
sortDirection?: Prisma.SortOrder; |
|||
|
|||
@IsInt() |
|||
@IsOptional() |
|||
@Min(0) |
|||
@Type(() => Number) |
|||
take?: number; |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
import * as config from '@ghostfolio/common/config'; |
|||
import type { PropertyKey } from '@ghostfolio/common/types'; |
|||
|
|||
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class PropertyKeyPipe implements PipeTransform<string, PropertyKey> { |
|||
private readonly allowedKeys: Set<string>; |
|||
|
|||
public constructor() { |
|||
this.allowedKeys = new Set<string>( |
|||
Object.entries(config) |
|||
.filter(([key]) => { |
|||
return key.startsWith('PROPERTY_'); |
|||
}) |
|||
.map(([, value]) => { |
|||
return value as string; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
public transform(value: string): PropertyKey { |
|||
if (!this.allowedKeys.has(value)) { |
|||
throw new BadRequestException(`Invalid property key: ${value}`); |
|||
} |
|||
|
|||
return value as PropertyKey; |
|||
} |
|||
} |
|||
@ -0,0 +1,236 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { |
|||
CreateAssetProfileSplitDto, |
|||
UpdateAssetProfileDataDto |
|||
} from '@ghostfolio/common/dtos'; |
|||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; |
|||
import { AssetProfileResponse } from '@ghostfolio/common/interfaces'; |
|||
import { |
|||
AssetProfilesResponse, |
|||
EnhancedAssetProfile |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission } from '@ghostfolio/common/permissions'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
ParseIntPipe, |
|||
Patch, |
|||
Post, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client'; |
|||
import { parseISO } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AssetProfilesService } from './asset-profiles.service'; |
|||
|
|||
@Controller('asset-profiles') |
|||
export class AssetProfilesController { |
|||
public constructor( |
|||
private readonly apiService: ApiService, |
|||
private readonly assetProfilesService: AssetProfilesService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getAssetProfiles( |
|||
@Query('assetSubClasses') filterByAssetSubClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('presetId') presetId?: MarketDataPreset, |
|||
@Query('query') filterBySearchQuery?: string, |
|||
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number, |
|||
@Query('sortColumn') sortColumn?: string, |
|||
@Query('sortDirection') sortDirection?: Prisma.SortOrder, |
|||
@Query('take', new ParseIntPipe({ optional: true })) take?: number |
|||
): Promise<AssetProfilesResponse> { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAssetSubClasses, |
|||
filterByDataSource, |
|||
filterBySearchQuery |
|||
}); |
|||
|
|||
return this.assetProfilesService.getAssetProfiles({ |
|||
filters, |
|||
presetId, |
|||
skip, |
|||
sortColumn, |
|||
sortDirection, |
|||
take |
|||
}); |
|||
} |
|||
|
|||
@Get(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getAssetProfile( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<AssetProfileResponse> { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const canReadAllAssetProfiles = hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.readMarketData |
|||
); |
|||
|
|||
const canReadOwnAssetProfile = |
|||
assetProfile?.userId === this.request.user.id && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.readMarketDataOfOwnAssetProfile |
|||
); |
|||
|
|||
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { |
|||
throw new HttpException( |
|||
assetProfile?.userId |
|||
? getReasonPhrase(StatusCodes.NOT_FOUND) |
|||
: getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.assetProfilesService.getAssetProfile({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
} |
|||
|
|||
@Post(':dataSource/:symbol/splits') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async createSplit( |
|||
@Body() data: CreateAssetProfileSplitDto, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<AssetProfileSplit> { |
|||
const { id: symbolProfileId } = await this.validateAccessToSplits({ |
|||
dataSource, |
|||
symbol, |
|||
permission: permissions.createAssetProfileSplit, |
|||
permissionOfOwnAssetProfile: |
|||
permissions.createAssetProfileSplitOfOwnAssetProfile |
|||
}); |
|||
|
|||
return this.assetProfilesService.createSplit({ |
|||
dataSource, |
|||
symbol, |
|||
symbolProfileId, |
|||
date: parseISO(data.date), |
|||
denominator: data.denominator, |
|||
numerator: data.numerator |
|||
}); |
|||
} |
|||
|
|||
@Delete(':dataSource/:symbol/splits/:id') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async deleteSplit( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('id') id: string, |
|||
@Param('symbol') symbol: string |
|||
): Promise<void> { |
|||
const { id: symbolProfileId } = await this.validateAccessToSplits({ |
|||
dataSource, |
|||
symbol, |
|||
permission: permissions.deleteAssetProfileSplit, |
|||
permissionOfOwnAssetProfile: |
|||
permissions.deleteAssetProfileSplitOfOwnAssetProfile |
|||
}); |
|||
|
|||
return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Patch(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async updateAssetProfileData( |
|||
@Body() assetProfileData: UpdateAssetProfileDataDto, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<EnhancedAssetProfile> { |
|||
if (!this.request.user.settings.settings.isExperimentalFeatures) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.assetProfilesService.updateAssetProfileData( |
|||
{ dataSource, symbol }, |
|||
assetProfileData |
|||
); |
|||
} |
|||
|
|||
private async validateAccessToSplits({ |
|||
dataSource, |
|||
permission, |
|||
permissionOfOwnAssetProfile, |
|||
symbol |
|||
}: { |
|||
dataSource: DataSource; |
|||
permission: string; |
|||
permissionOfOwnAssetProfile: string; |
|||
symbol: string; |
|||
}) { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const canAccessAllAssetProfiles = hasPermission( |
|||
this.request.user.permissions, |
|||
permission |
|||
); |
|||
|
|||
const canAccessOwnAssetProfile = |
|||
assetProfile.userId === this.request.user.id && |
|||
hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile); |
|||
|
|||
if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) { |
|||
throw new HttpException( |
|||
assetProfile.userId |
|||
? getReasonPhrase(StatusCodes.NOT_FOUND) |
|||
: getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return assetProfile; |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AssetProfilesController } from './asset-profiles.controller'; |
|||
import { AssetProfilesService } from './asset-profiles.service'; |
|||
|
|||
@Module({ |
|||
controllers: [AssetProfilesController], |
|||
exports: [AssetProfilesService], |
|||
imports: [ |
|||
ActivitiesModule, |
|||
ApiModule, |
|||
AssetProfileSplitModule, |
|||
BenchmarkModule, |
|||
DataGatheringQueueModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
MarketDataModule, |
|||
PrismaModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
], |
|||
providers: [AssetProfilesService] |
|||
}) |
|||
export class AssetProfilesModule {} |
|||
@ -0,0 +1,587 @@ |
|||
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
|||
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; |
|||
import { |
|||
applyAssetProfileOverrides, |
|||
getAssetProfileIdentifier, |
|||
getCurrencyFromSymbol, |
|||
isCurrency |
|||
} from '@ghostfolio/common/helper'; |
|||
import { |
|||
AdminMarketDataDetails, |
|||
AssetProfileIdentifier, |
|||
AssetProfileItem, |
|||
AssetProfilesResponse, |
|||
EnhancedAssetProfile, |
|||
Filter |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { MarketDataPreset } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, NotFoundException } from '@nestjs/common'; |
|||
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; |
|||
import { groupBy } from 'lodash'; |
|||
|
|||
@Injectable() |
|||
export class AssetProfilesService { |
|||
public constructor( |
|||
private readonly activitiesService: ActivitiesService, |
|||
private readonly assetProfileSplitService: AssetProfileSplitService, |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async createSplit({ |
|||
dataSource, |
|||
date, |
|||
denominator, |
|||
numerator, |
|||
symbol, |
|||
symbolProfileId |
|||
}: { |
|||
date: Date; |
|||
denominator: number; |
|||
numerator: number; |
|||
symbolProfileId: string; |
|||
} & AssetProfileIdentifier) { |
|||
const assetProfileSplit = await this.assetProfileSplitService.upsert({ |
|||
date, |
|||
denominator, |
|||
numerator, |
|||
symbolProfileId |
|||
}); |
|||
|
|||
await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); |
|||
|
|||
return assetProfileSplit; |
|||
} |
|||
|
|||
public async deleteSplit({ |
|||
id, |
|||
symbolProfileId |
|||
}: { |
|||
id: string; |
|||
symbolProfileId: string; |
|||
}) { |
|||
const isDeleted = await this.assetProfileSplitService.deleteById({ |
|||
id, |
|||
symbolProfileId |
|||
}); |
|||
|
|||
if (!isDeleted) { |
|||
throw new NotFoundException(); |
|||
} |
|||
} |
|||
|
|||
public async getAssetProfile({ |
|||
dataSource, |
|||
symbol |
|||
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> { |
|||
let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0; |
|||
let currency: EnhancedAssetProfile['currency'] = '-'; |
|||
let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity']; |
|||
|
|||
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol)); |
|||
|
|||
if (isCurrencyAssetProfile) { |
|||
currency = getCurrencyFromSymbol(symbol); |
|||
({ activitiesCount, dateOfFirstActivity } = |
|||
await this.activitiesService.getStatisticsByCurrency(currency)); |
|||
} |
|||
|
|||
const [[assetProfile], marketData, splits] = await Promise.all([ |
|||
this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
} |
|||
]), |
|||
this.marketDataService.marketDataItems({ |
|||
orderBy: { |
|||
date: 'asc' |
|||
}, |
|||
where: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
}), |
|||
this.assetProfileSplitService.getSplits({ dataSource, symbol }) |
|||
]); |
|||
|
|||
if (assetProfile) { |
|||
assetProfile.dataProviderInfo = this.dataProviderService |
|||
.getDataProvider(assetProfile.dataSource) |
|||
.getDataProviderInfo(); |
|||
} |
|||
|
|||
return { |
|||
marketData, |
|||
splits, |
|||
assetProfile: assetProfile ?? { |
|||
activitiesCount, |
|||
currency, |
|||
dataSource, |
|||
dateOfFirstActivity, |
|||
symbol, |
|||
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined, |
|||
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined, |
|||
isActive: true |
|||
} |
|||
}; |
|||
} |
|||
|
|||
public async getAssetProfiles({ |
|||
filters = [], |
|||
presetId, |
|||
sortColumn, |
|||
sortDirection = 'asc', |
|||
skip, |
|||
take = Number.MAX_SAFE_INTEGER |
|||
}: { |
|||
filters?: Filter[]; |
|||
presetId?: MarketDataPreset; |
|||
skip?: number; |
|||
sortColumn?: string; |
|||
sortDirection?: Prisma.SortOrder; |
|||
take?: number; |
|||
}): Promise<AssetProfilesResponse> { |
|||
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> = |
|||
[{ symbol: 'asc' }]; |
|||
const where: Prisma.SymbolProfileWhereInput = {}; |
|||
|
|||
if (presetId === 'BENCHMARKS') { |
|||
const benchmarkAssetProfiles = |
|||
await this.benchmarkService.getBenchmarkAssetProfiles(); |
|||
|
|||
where.id = { |
|||
in: benchmarkAssetProfiles.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
}; |
|||
} else if (presetId === 'CURRENCIES') { |
|||
return this.getAssetProfilesForCurrencies(); |
|||
} else if ( |
|||
presetId === 'ETF_WITHOUT_COUNTRIES' || |
|||
presetId === 'ETF_WITHOUT_SECTORS' |
|||
) { |
|||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; |
|||
} else if (presetId === 'NO_ACTIVITIES') { |
|||
where.activities = { |
|||
none: {} |
|||
}; |
|||
} |
|||
|
|||
const { |
|||
ASSET_SUB_CLASS: [filterByAssetSubClass] = [], |
|||
DATA_SOURCE: [filterByDataSource] = [], |
|||
SEARCH_QUERY: [filterBySearchQuery] = [] |
|||
} = groupBy(filters, ({ type }) => { |
|||
return type; |
|||
}); |
|||
|
|||
const marketDataItems = await this.prismaService.marketData.groupBy({ |
|||
_count: true, |
|||
by: ['dataSource', 'symbol'] |
|||
}); |
|||
|
|||
if (filterByAssetSubClass) { |
|||
where.assetSubClass = AssetSubClass[filterByAssetSubClass.id]; |
|||
} |
|||
|
|||
if (filterByDataSource) { |
|||
where.dataSource = DataSource[filterByDataSource.id]; |
|||
} |
|||
|
|||
if (filterBySearchQuery) { |
|||
where.OR = [ |
|||
{ id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, |
|||
{ isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, |
|||
{ name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, |
|||
{ symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } |
|||
]; |
|||
} |
|||
|
|||
if (sortColumn) { |
|||
orderBy = [{ [sortColumn]: sortDirection }]; |
|||
|
|||
if (sortColumn === 'activitiesCount') { |
|||
orderBy = [ |
|||
{ |
|||
activities: { |
|||
_count: sortDirection |
|||
} |
|||
} |
|||
]; |
|||
} |
|||
} |
|||
|
|||
const extendedPrismaClient = this.getExtendedPrismaClient(); |
|||
|
|||
const symbolProfileResult = await Promise.all([ |
|||
extendedPrismaClient.symbolProfile.findMany({ |
|||
skip, |
|||
take, |
|||
where, |
|||
orderBy: [...orderBy, { id: sortDirection }], |
|||
select: { |
|||
_count: { |
|||
select: { |
|||
activities: true, |
|||
watchedBy: true |
|||
} |
|||
}, |
|||
activities: { |
|||
orderBy: [{ date: 'asc' }], |
|||
select: { date: true }, |
|||
take: 1 |
|||
}, |
|||
assetClass: true, |
|||
assetProfileOverrides: true, |
|||
assetSubClass: true, |
|||
comment: true, |
|||
countries: true, |
|||
currency: true, |
|||
dataSource: true, |
|||
id: true, |
|||
isin: true, |
|||
isActive: true, |
|||
isUsedByUsersWithSubscription: true, |
|||
name: true, |
|||
scraperConfiguration: true, |
|||
sectors: true, |
|||
symbol: true |
|||
} |
|||
}), |
|||
this.prismaService.symbolProfile.count({ where }) |
|||
]); |
|||
const symbolProfiles = symbolProfileResult[0]; |
|||
let count = symbolProfileResult[1]; |
|||
|
|||
const lastMarketPrices = await this.prismaService.marketData.findMany({ |
|||
distinct: ['dataSource', 'symbol'], |
|||
orderBy: { date: 'desc' }, |
|||
select: { |
|||
dataSource: true, |
|||
marketPrice: true, |
|||
symbol: true |
|||
}, |
|||
where: { |
|||
dataSource: { |
|||
in: symbolProfiles.map(({ dataSource }) => { |
|||
return dataSource; |
|||
}) |
|||
}, |
|||
symbol: { |
|||
in: symbolProfiles.map(({ symbol }) => { |
|||
return symbol; |
|||
}) |
|||
} |
|||
} |
|||
}); |
|||
|
|||
const lastMarketPriceMap = new Map<string, number>(); |
|||
|
|||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|||
lastMarketPriceMap.set( |
|||
getAssetProfileIdentifier({ dataSource, symbol }), |
|||
marketPrice |
|||
); |
|||
} |
|||
|
|||
let assetProfiles: AssetProfileItem[] = await Promise.all( |
|||
symbolProfiles.map(async (assetProfile) => { |
|||
const { |
|||
_count, |
|||
activities, |
|||
comment, |
|||
currency, |
|||
dataSource, |
|||
id, |
|||
isin, |
|||
isActive, |
|||
isUsedByUsersWithSubscription, |
|||
symbol |
|||
} = assetProfile; |
|||
|
|||
const { assetClass, assetSubClass, countries, name, sectors } = |
|||
applyAssetProfileOverrides( |
|||
assetProfile, |
|||
assetProfile.assetProfileOverrides |
|||
); |
|||
|
|||
const countriesCount = countries ? Object.keys(countries).length : 0; |
|||
|
|||
const lastMarketPrice = lastMarketPriceMap.get( |
|||
getAssetProfileIdentifier({ dataSource, symbol }) |
|||
); |
|||
|
|||
const marketDataItemCount = |
|||
marketDataItems.find((marketDataItem) => { |
|||
return ( |
|||
marketDataItem.dataSource === dataSource && |
|||
marketDataItem.symbol === symbol |
|||
); |
|||
})?._count ?? 0; |
|||
|
|||
const sectorsCount = sectors ? Object.keys(sectors).length : 0; |
|||
|
|||
return { |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
countriesCount, |
|||
currency, |
|||
dataSource, |
|||
id, |
|||
isActive, |
|||
isin, |
|||
lastMarketPrice, |
|||
marketDataItemCount, |
|||
name, |
|||
sectorsCount, |
|||
symbol, |
|||
activitiesCount: _count.activities, |
|||
date: activities?.[0]?.date, |
|||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, |
|||
watchedByCount: _count.watchedBy |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
if (presetId) { |
|||
if (presetId === 'ETF_WITHOUT_COUNTRIES') { |
|||
assetProfiles = assetProfiles.filter(({ countriesCount }) => { |
|||
return countriesCount === 0; |
|||
}); |
|||
} else if (presetId === 'ETF_WITHOUT_SECTORS') { |
|||
assetProfiles = assetProfiles.filter(({ sectorsCount }) => { |
|||
return sectorsCount === 0; |
|||
}); |
|||
} |
|||
|
|||
count = assetProfiles.length; |
|||
} |
|||
|
|||
return { |
|||
assetProfiles, |
|||
count |
|||
}; |
|||
} |
|||
|
|||
public async updateAssetProfileData( |
|||
{ dataSource, symbol }: AssetProfileIdentifier, |
|||
assetProfileData: UpdateAssetProfileDataDto |
|||
): Promise<EnhancedAssetProfile> { |
|||
const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`; |
|||
|
|||
const data = this.getAssetProfileDataUpdate(assetProfileData); |
|||
|
|||
if (Object.keys(data).length > 0) { |
|||
try { |
|||
await this.symbolProfileService.updateSymbolProfile( |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
this.symbolProfileService.getAssetProfileUpdateInput( |
|||
{ dataSource, symbol }, |
|||
data |
|||
) |
|||
); |
|||
} catch (error) { |
|||
if ( |
|||
error instanceof Prisma.PrismaClientKnownRequestError && |
|||
error.code === 'P2025' |
|||
) { |
|||
throw new NotFoundException(notFoundMessage); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
} |
|||
]); |
|||
|
|||
if (!assetProfile) { |
|||
throw new NotFoundException(notFoundMessage); |
|||
} |
|||
|
|||
return assetProfile; |
|||
} |
|||
|
|||
private getAssetProfileDataUpdate({ |
|||
countries, |
|||
holdings, |
|||
sectors |
|||
}: UpdateAssetProfileDataDto): Pick< |
|||
Prisma.SymbolProfileUpdateInput, |
|||
'countries' | 'holdings' | 'sectors' |
|||
> { |
|||
const data: Pick< |
|||
Prisma.SymbolProfileUpdateInput, |
|||
'countries' | 'holdings' | 'sectors' |
|||
> = {}; |
|||
|
|||
if (countries !== undefined) { |
|||
data.countries = countries as Prisma.JsonArray; |
|||
} |
|||
|
|||
if (holdings !== undefined) { |
|||
data.holdings = holdings as Prisma.JsonArray; |
|||
} |
|||
|
|||
if (sectors !== undefined) { |
|||
data.sectors = sectors as Prisma.JsonArray; |
|||
} |
|||
|
|||
return data; |
|||
} |
|||
|
|||
private async getAssetProfilesForCurrencies(): Promise<AssetProfilesResponse> { |
|||
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); |
|||
|
|||
const [lastMarketPrices, marketDataItems] = await Promise.all([ |
|||
this.prismaService.marketData.findMany({ |
|||
distinct: ['dataSource', 'symbol'], |
|||
orderBy: { date: 'desc' }, |
|||
select: { |
|||
dataSource: true, |
|||
marketPrice: true, |
|||
symbol: true |
|||
}, |
|||
where: { |
|||
dataSource: { |
|||
in: currencyPairs.map(({ dataSource }) => { |
|||
return dataSource; |
|||
}) |
|||
}, |
|||
symbol: { |
|||
in: currencyPairs.map(({ symbol }) => { |
|||
return symbol; |
|||
}) |
|||
} |
|||
} |
|||
}), |
|||
this.prismaService.marketData.groupBy({ |
|||
_count: true, |
|||
by: ['dataSource', 'symbol'] |
|||
}) |
|||
]); |
|||
|
|||
const lastMarketPriceMap = new Map<string, number>(); |
|||
|
|||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|||
lastMarketPriceMap.set( |
|||
getAssetProfileIdentifier({ dataSource, symbol }), |
|||
marketPrice |
|||
); |
|||
} |
|||
|
|||
const assetProfilePromises: Promise<AssetProfileItem>[] = currencyPairs.map( |
|||
async ({ dataSource, symbol }) => { |
|||
let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0; |
|||
let currency: EnhancedAssetProfile['currency'] = '-'; |
|||
let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity']; |
|||
|
|||
if (isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
currency = getCurrencyFromSymbol(symbol); |
|||
({ activitiesCount, dateOfFirstActivity } = |
|||
await this.activitiesService.getStatisticsByCurrency(currency)); |
|||
} |
|||
|
|||
const lastMarketPrice = lastMarketPriceMap.get( |
|||
getAssetProfileIdentifier({ dataSource, symbol }) |
|||
); |
|||
|
|||
const marketDataItemCount = |
|||
marketDataItems.find((marketDataItem) => { |
|||
return ( |
|||
marketDataItem.dataSource === dataSource && |
|||
marketDataItem.symbol === symbol |
|||
); |
|||
})?._count ?? 0; |
|||
|
|||
return { |
|||
activitiesCount, |
|||
currency, |
|||
dataSource, |
|||
lastMarketPrice, |
|||
marketDataItemCount, |
|||
symbol, |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CASH, |
|||
countriesCount: 0, |
|||
date: dateOfFirstActivity, |
|||
id: undefined, |
|||
isActive: true, |
|||
name: symbol, |
|||
sectorsCount: 0, |
|||
watchedByCount: 0 |
|||
}; |
|||
} |
|||
); |
|||
|
|||
const assetProfiles = await Promise.all(assetProfilePromises); |
|||
return { assetProfiles, count: assetProfiles.length }; |
|||
} |
|||
|
|||
private getExtendedPrismaClient() { |
|||
const symbolProfileExtension = Prisma.defineExtension((client) => { |
|||
return client.$extends({ |
|||
result: { |
|||
symbolProfile: { |
|||
isUsedByUsersWithSubscription: { |
|||
compute: async ({ id }) => { |
|||
const { _count } = |
|||
await this.prismaService.symbolProfile.findUnique({ |
|||
select: { |
|||
_count: { |
|||
select: { |
|||
activities: { |
|||
where: { |
|||
user: { |
|||
subscriptions: { |
|||
some: { |
|||
expiresAt: { |
|||
gt: new Date() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
where: { |
|||
id |
|||
} |
|||
}); |
|||
|
|||
return _count.activities > 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
return this.prismaService.$extends(symbolProfileExtension); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
|
|||
import { Transform, TransformFnParams } from 'class-transformer'; |
|||
import { IsBoolean } from 'class-validator'; |
|||
|
|||
export class GetBenchmarkMarketDataDto extends DateRangeFilterDto { |
|||
@IsBoolean() |
|||
@Transform(({ value }: TransformFnParams) => { |
|||
return value === 'true'; |
|||
}) |
|||
withExcludedAccounts? = false; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto'; |
|||
|
|||
import { Transform, TransformFnParams } from 'class-transformer'; |
|||
import { IsOptional, IsUUID } from 'class-validator'; |
|||
import { isString } from 'lodash'; |
|||
|
|||
export class GetExportDto extends ActivitiesFilterDto { |
|||
@IsOptional() |
|||
@IsUUID(undefined, { each: true }) |
|||
@Transform(({ value }: TransformFnParams) => { |
|||
return isString(value) ? value.split(',') : value; |
|||
}) |
|||
activityIds?: string[]; |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
import { IsUrl } from 'class-validator'; |
|||
|
|||
export class GetLogoDto { |
|||
@IsUrl({ |
|||
protocols: ['http', 'https'], |
|||
require_protocol: true |
|||
}) |
|||
url: string; |
|||
} |
|||
@ -1,231 +1,234 @@ |
|||
import { |
|||
activityDummyData, |
|||
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 { 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 } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
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 configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with BALN.SW buy and sell in two activities', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
feeInBaseCurrency: 1.55, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
feeInBaseCurrency: 1.65, |
|||
quantity: 1, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
quantity: 1, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'CHF', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 3, |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2021-11-22', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
grossPerformance: new Big('-12.6'), |
|||
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'-0.04408677396780965649' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('-0.0552834149755073478') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('-15.8') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('0'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('285.80000000000000396627'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|||
'285.80000000000000396627' |
|||
), |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: -15.8, |
|||
netPerformanceInPercentage: -0.05528341497550734703, |
|||
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
|||
netPerformanceWithCurrencyEffect: -15.8, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 0 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2021-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
import { |
|||
activityDummyData, |
|||
assetProfileDummyData, |
|||
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 { 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 } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
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 configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
PortfolioSnapshotServiceMock.reset(); |
|||
RedisCacheServiceMock.reset(); |
|||
|
|||
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 BALN.SW buy and sell in two activities', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
feeInBaseCurrency: 1.55, |
|||
quantity: 2, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
feeInBaseCurrency: 1.65, |
|||
quantity: 1, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
quantity: 1, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'CHF', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 3, |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2021-11-22', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
grossPerformance: new Big('-12.6'), |
|||
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'-0.04408677396780965649' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('-0.0552834149755073478') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('-15.8') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('0'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('285.80000000000000396627'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|||
'285.80000000000000396627' |
|||
), |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: -15.8, |
|||
netPerformanceInPercentage: -0.05528341497550734703, |
|||
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
|||
netPerformanceWithCurrencyEffect: -15.8, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 0 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2021-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
@ -1,190 +1,193 @@ |
|||
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 { 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/jnug-buy-and-sell-and-buy-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); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with JNUG buy and sell', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').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: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', |
|||
symbol: activity.symbol |
|||
}, |
|||
unitPriceInAssetProfileCurrency: activity.unitPrice |
|||
}) |
|||
); |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: exportResponse.user.settings.currency, |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 4, |
|||
averagePrice: new Big('0'), |
|||
currency: 'USD', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2025-12-11', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('4'), |
|||
feeInBaseCurrency: new Big('4'), |
|||
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
|||
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
|||
}, |
|||
marketPrice: 237.8000030517578, |
|||
marketPriceInBaseCurrency: 237.8000030517578, |
|||
quantity: new Big('0'), |
|||
symbol: 'JNUG', |
|||
tags: [], |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('4'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2025-12-11', investment: new Big('1885.05') }, |
|||
{ date: '2025-12-18', investment: new Big('2041.1') }, |
|||
{ date: '2025-12-28', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2025-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2025-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
import { |
|||
activityDummyData, |
|||
assetProfileDummyData, |
|||
loadExportFile, |
|||
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 { 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/jnug-buy-and-sell-and-buy-and-sell.json' |
|||
) |
|||
); |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
PortfolioSnapshotServiceMock.reset(); |
|||
RedisCacheServiceMock.reset(); |
|||
|
|||
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 JNUG buy and sell', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); |
|||
|
|||
const activities: Activity[] = exportResponse.activities.map( |
|||
(activity) => ({ |
|||
...activityDummyData, |
|||
...activity, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: activity.currency, |
|||
dataSource: activity.dataSource, |
|||
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', |
|||
symbol: activity.symbol |
|||
}, |
|||
date: parseDate(activity.date), |
|||
feeInAssetProfileCurrency: activity.fee, |
|||
feeInBaseCurrency: activity.fee, |
|||
unitPriceInAssetProfileCurrency: activity.unitPrice |
|||
}) |
|||
); |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: exportResponse.user.settings.currency, |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 4, |
|||
averagePrice: new Big('0'), |
|||
currency: 'USD', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2025-12-11', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('4'), |
|||
feeInBaseCurrency: new Big('4'), |
|||
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
|||
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
|||
}, |
|||
marketPrice: 237.8000030517578, |
|||
marketPriceInBaseCurrency: 237.8000030517578, |
|||
quantity: new Big('0'), |
|||
symbol: 'JNUG', |
|||
tags: [], |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('4'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2025-12-11', investment: new Big('1885.05') }, |
|||
{ date: '2025-12-18', investment: new Big('2041.1') }, |
|||
{ date: '2025-12-28', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2025-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2025-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
@ -1,264 +1,267 @@ |
|||
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 { 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/novn-buy-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); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with NOVN.SW buy and sell', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').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: 'Novartis AG', |
|||
symbol: activity.symbol |
|||
}, |
|||
unitPriceInAssetProfileCurrency: activity.unitPrice |
|||
}) |
|||
); |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: exportResponse.user.settings.currency, |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
|||
date: '2022-03-06', |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 0, |
|||
netPerformanceInPercentage: 0, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0, |
|||
netPerformanceWithCurrencyEffect: 0, |
|||
netWorth: 0, |
|||
totalAccountBalance: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
/** |
|||
* Closing price on 2022-03-07 is unknown, |
|||
* hence it uses the last unit price (2022-04-11): 87.8 |
|||
*/ |
|||
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
|||
date: '2022-03-07', |
|||
investmentValueWithCurrencyEffect: 151.6, |
|||
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
|||
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
|||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
|||
netPerformanceWithCurrencyEffect: 24, |
|||
netWorth: 175.6, // 2 * 87.8 = 175.6
|
|||
totalAccountBalance: 0, |
|||
totalInvestment: 151.6, |
|||
totalInvestmentValueWithCurrencyEffect: 151.6, |
|||
value: 175.6, // 2 * 87.8 = 175.6
|
|||
valueWithCurrencyEffect: 175.6 |
|||
}); |
|||
|
|||
expect( |
|||
portfolioSnapshot.historicalData[ |
|||
portfolioSnapshot.historicalData.length - 1 |
|||
] |
|||
).toEqual({ |
|||
date: '2022-04-11', |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 19.86, |
|||
netPerformanceInPercentage: 0.13100263852242744, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
|||
netPerformanceWithCurrencyEffect: 19.86, |
|||
netWorth: 0, |
|||
totalAccountBalance: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 2, |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2022-03-07', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('0'), |
|||
feeInBaseCurrency: new Big('0'), |
|||
grossPerformance: new Big('19.86'), |
|||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'0.13100263852242744063' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('19.86'), |
|||
netPerformancePercentage: new Big('0.13100263852242744063'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('0.13100263852242744063') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('19.86') |
|||
}, |
|||
marketPrice: 87.8, |
|||
marketPriceInBaseCurrency: 87.8, |
|||
quantity: new Big('0'), |
|||
symbol: 'NOVN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('151.6'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
|||
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(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: 19.86, |
|||
netPerformanceInPercentage: 0.13100263852242744063, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
|||
netPerformanceWithCurrencyEffect: 19.86, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2022-03-07', investment: new Big('151.6') }, |
|||
{ date: '2022-04-08', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2022-03-01', investment: 151.6 }, |
|||
{ date: '2022-04-01', investment: -151.6 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2022-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
import { |
|||
activityDummyData, |
|||
assetProfileDummyData, |
|||
loadExportFile, |
|||
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 { 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/novn-buy-and-sell.json' |
|||
) |
|||
); |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
PortfolioSnapshotServiceMock.reset(); |
|||
RedisCacheServiceMock.reset(); |
|||
|
|||
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 NOVN.SW buy and sell', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
|||
|
|||
const activities: Activity[] = exportResponse.activities.map( |
|||
(activity) => ({ |
|||
...activityDummyData, |
|||
...activity, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: activity.currency, |
|||
dataSource: activity.dataSource, |
|||
name: 'Novartis AG', |
|||
symbol: activity.symbol |
|||
}, |
|||
date: parseDate(activity.date), |
|||
feeInAssetProfileCurrency: activity.fee, |
|||
feeInBaseCurrency: activity.fee, |
|||
unitPriceInAssetProfileCurrency: activity.unitPrice |
|||
}) |
|||
); |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: exportResponse.user.settings.currency, |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
|||
date: '2022-03-06', |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 0, |
|||
netPerformanceInPercentage: 0, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0, |
|||
netPerformanceWithCurrencyEffect: 0, |
|||
netWorth: 0, |
|||
totalCashInBaseCurrency: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
/** |
|||
* Closing price on 2022-03-07 is unknown, |
|||
* hence it uses the last unit price (2022-04-11): 87.8 |
|||
*/ |
|||
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
|||
date: '2022-03-07', |
|||
investmentValueWithCurrencyEffect: 151.6, |
|||
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
|||
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
|||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
|||
netPerformanceWithCurrencyEffect: 24, |
|||
netWorth: 175.6, // 2 * 87.8 = 175.6
|
|||
totalCashInBaseCurrency: 0, |
|||
totalInvestment: 151.6, |
|||
totalInvestmentValueWithCurrencyEffect: 151.6, |
|||
value: 175.6, // 2 * 87.8 = 175.6
|
|||
valueWithCurrencyEffect: 175.6 |
|||
}); |
|||
|
|||
expect( |
|||
portfolioSnapshot.historicalData[ |
|||
portfolioSnapshot.historicalData.length - 1 |
|||
] |
|||
).toEqual({ |
|||
date: '2022-04-11', |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 19.86, |
|||
netPerformanceInPercentage: 0.13100263852242744, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
|||
netPerformanceWithCurrencyEffect: 19.86, |
|||
netWorth: 0, |
|||
totalCashInBaseCurrency: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 2, |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2022-03-07', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('0'), |
|||
feeInBaseCurrency: new Big('0'), |
|||
grossPerformance: new Big('19.86'), |
|||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'0.13100263852242744063' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('19.86'), |
|||
netPerformancePercentage: new Big('0.13100263852242744063'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('0.13100263852242744063') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('19.86') |
|||
}, |
|||
marketPrice: 87.8, |
|||
marketPriceInBaseCurrency: 87.8, |
|||
quantity: new Big('0'), |
|||
symbol: 'NOVN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('151.6'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
|||
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(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: 19.86, |
|||
netPerformanceInPercentage: 0.13100263852242744063, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
|||
netPerformanceWithCurrencyEffect: 19.86, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2022-03-07', investment: new Big('151.6') }, |
|||
{ date: '2022-04-08', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2022-03-01', investment: 151.6 }, |
|||
{ date: '2022-04-01', investment: -151.6 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2022-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
@ -0,0 +1,7 @@ |
|||
export class PortfolioSnapshotComputationError extends Error { |
|||
public constructor(message: string) { |
|||
super(message); |
|||
|
|||
this.name = 'PortfolioSnapshotComputationError'; |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
|
|||
import { Transform, TransformFnParams } from 'class-transformer'; |
|||
import { IsBoolean } from 'class-validator'; |
|||
|
|||
export class GetDetailsDto extends DateRangeFilterDto { |
|||
@IsBoolean() |
|||
@Transform(({ value }: TransformFnParams) => { |
|||
return value === 'true'; |
|||
}) |
|||
withMarkets? = false; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
import { GroupBy } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsOptional } from 'class-validator'; |
|||
|
|||
export class GetDividendsDto extends DateRangeFilterDto { |
|||
@IsIn(['month', 'year'] as GroupBy[]) |
|||
@IsOptional() |
|||
groupBy?: GroupBy; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
import { HoldingType } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsOptional, IsString } from 'class-validator'; |
|||
|
|||
export class GetHoldingsDto extends DateRangeFilterDto { |
|||
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[]) |
|||
@IsOptional() |
|||
holdingType?: HoldingType; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
query?: string; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
import { GroupBy } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsOptional } from 'class-validator'; |
|||
|
|||
export class GetInvestmentsDto extends DateRangeFilterDto { |
|||
@IsIn(['month', 'year'] as GroupBy[]) |
|||
@IsOptional() |
|||
groupBy?: GroupBy; |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue